diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..c63790c7 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,25 @@ +.git +**/.venv +**/__pycache__ +**/*.pyc +**/.pytest_cache +**/.mypy_cache +**/.ruff_cache +**/*.egg-info +apps/backend/.local +apps/backend/local +apps/backend/tests +apps/backend/coverage +apps/backend/htmlcov +apps/backend/.coverage +**/.env +**/.env.* +!**/.env.example +node_modules +apps/frontend/node_modules +apps/frontend/dist +apps/frontend/coverage +apps/frontend/test-results +apps/frontend/e2e-report +.DS_Store +.claude diff --git a/.env.example b/.env.example index 59140246..a476aae3 100644 --- a/.env.example +++ b/.env.example @@ -7,9 +7,14 @@ REDIS_URL=redis://localhost:6379/0 STORAGE_PATH=./apps/backend/.local/storage CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173 AUTO_CREATE_TABLES=true +ANALYSIS_MAX_REPOSITORY_SOURCE_BYTES=1073741824 +ANALYSIS_MAX_PROCESS_RSS_BYTES=2147483648 +ANALYSIS_MAX_DURATION_SECONDS=1800 -# Docker Compose PostgreSQL defaults. Compose consumes these values; the local -# backend .env example uses SQLite so direct local startup works without services. -POSTGRES_USER=partha -POSTGRES_PASSWORD=partha -POSTGRES_DB=partha +# AI provider outbound egress policy. Hosted is the safe default: built-in +# cloud providers keep their fixed HTTPS origins and no tenant-configurable +# endpoint is enabled. To use a local/internal Ollama endpoint, explicitly set +# self_hosted and provide both exact administrator-owned allowlists below. +AI_EGRESS_MODE=hosted +AI_EGRESS_ALLOWED_BASE_URLS= +AI_EGRESS_ALLOWED_CIDRS= diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..75324ffb --- /dev/null +++ b/.gitattributes @@ -0,0 +1,33 @@ +# PARTHA .gitattributes +# Normalise line endings and keep binary assets safe across platforms. +# Generated lockfiles (package-lock.json) are large but text; Git/Linguist +# already treats them as generated, so we keep them as text for sane diffs. + +# Default: normalise to LF on commit, convert to native on checkout. +* text=auto eol=lf + +# Lockfiles and dependency manifests are text (large diffs, not binary). +*.lock text +package-lock.json text +pnpm-lock.yaml text +requirements*.txt text + +# Hand-authored SVG stays text (editable in-repo). +*.svg text + +# Binary assets must never be line-ending-converted. +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.webp binary +*.ico binary +*.woff binary +*.woff2 binary +*.ttf binary +*.otf binary +*.eot binary +*.zip binary +*.gz binary +*.tar binary +*.pdf binary diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000..1e1135a0 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @parthrohit22 diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 8e124728..384baa8a 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -3,6 +3,12 @@ description: Report broken or incorrect behavior in PARTHA. title: "bug: " labels: [bug] body: + - type: markdown + attributes: + value: | + Do not report security vulnerabilities here. Public issues are the wrong + channel — disclose them privately as described in + [SECURITY.md](https://github.com/Second-Origin/PARTHA/blob/dev/SECURITY.md). - type: textarea id: summary attributes: @@ -50,9 +56,9 @@ body: id: evidence attributes: label: Evidence - description: Logs, screenshots, response payloads, or audit references. + description: Logs, screenshots, or response payloads. Do not include secrets, credentials, or private repository contents. - type: textarea id: environment attributes: label: Environment - description: Browser, OS, Node/Python versions, Docker availability, branch/commit. + description: Browser, OS, Node/Python versions, and branch/commit. diff --git a/.github/ISSUE_TEMPLATE/engineering_task.yml b/.github/ISSUE_TEMPLATE/engineering_task.yml index b85f9fa3..1fc09df7 100644 --- a/.github/ISSUE_TEMPLATE/engineering_task.yml +++ b/.github/ISSUE_TEMPLATE/engineering_task.yml @@ -1,5 +1,5 @@ name: Engineering Task -description: Track technical debt, refactors, tests, infrastructure, or audit backlog work. +description: Track technical debt, refactors, tests, or infrastructure work. title: "chore: " labels: [engineering] body: @@ -21,7 +21,7 @@ body: id: references attributes: label: References - description: Link audit rows, files, logs, or related issues. + description: Link files, logs, or related issues. - type: textarea id: implementation attributes: diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..bab4f078 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,139 @@ +# Dependabot configuration for the PARTHA monorepo. +# +# Part of the dependency, secret, and code scanning baseline. Branch protection +# is already enabled on dev/main; Dependabot doesn't produce a status check of +# its own to require -- the PRs it opens go through the normal CI gate (ci.yml) +# like any other PR. +# +# Two ecosystems, one per app: npm for the React/TS frontend, pip for the +# FastAPI backend. +# +# Grouping policy +# --------------- +# The default "one PR per package" behaviour produces PRs that cannot go green, +# because several of our dependencies are only installable in lockstep: +# +# * `pydantic` pins `pydantic-core` exactly. Bumping the core alone yields +# `ResolutionImpossible` at `pip install`, before a single test runs. +# * `react` and `react-dom` must move together, and both are peer dependencies +# of `@testing-library/react` and `@xyflow/react`. Bumping either alone +# yields an ERESOLVE failure at `npm ci`. +# +# Those PRs are not review load, they are noise: no reviewer action can make +# them pass. The groups below force each cohort into a single PR so the +# resolver has a consistent set to work with, and the lockstep majors are +# ignored outright so they are raised as deliberate migration work instead of +# an unmergeable weekly bump. +version: 2 + +updates: + # --------------------------------------------------------------------------- + # Frontend — npm (apps/frontend) + # --------------------------------------------------------------------------- + - package-ecosystem: npm + directory: /apps/frontend + # `dev` is the real trunk and is now the repository default branch, so both + # version updates and security updates land here. CONTRIBUTING.md requires + # all PRs to target `dev`. + target-branch: dev + schedule: + interval: weekly + day: monday + time: "06:00" + timezone: Etc/UTC + open-pull-requests-limit: 5 + commit-message: + # Produces `chore(deps): ...` — Conventional Commits, per CONTRIBUTING.md. + prefix: chore + include: scope + groups: + # React and everything that peer-depends on it. Keeping these in one PR is + # what makes a React major reviewable at all: split across PRs, each half + # fails `npm ci` and none of them can be merged first. + react-ecosystem: + patterns: + - react + - react-dom + - "@types/react" + - "@types/react-dom" + - "@testing-library/react" + update-types: + - minor + - patch + frontend-minor-and-patch: + applies-to: version-updates + update-types: + - minor + - patch + ignore: + # React 19 is a coordinated migration, not a dependency bump: it moves + # react + react-dom + both `@types` packages and requires a + # v19-compatible `@testing-library/react`, with `@xyflow/react`, + # `framer-motion` and `react-dropzone` all re-verified. Dependabot raised + # it as two independent PRs (#249, #203); neither could install, so both + # sat red for twelve days. Track it as an issue and drop this ignore when + # the migration is scheduled. + - dependency-name: react + update-types: ["version-update:semver-major"] + - dependency-name: react-dom + update-types: ["version-update:semver-major"] + - dependency-name: "@types/react" + update-types: ["version-update:semver-major"] + - dependency-name: "@types/react-dom" + update-types: ["version-update:semver-major"] + # lucide-react v1 removed every brand icon, including `Github`, which + # PARTHA imports in four components. The bump therefore requires an icon + # migration in the same change and cannot be applied mechanically (#250). + - dependency-name: lucide-react + update-types: ["version-update:semver-major"] + + # --------------------------------------------------------------------------- + # Backend — pip (apps/backend) + # --------------------------------------------------------------------------- + - package-ecosystem: pip + directory: /apps/backend + target-branch: dev + schedule: + interval: weekly + day: monday + time: "06:00" + timezone: Etc/UTC + open-pull-requests-limit: 5 + commit-message: + prefix: chore + include: scope + groups: + # `pydantic` pins `pydantic-core` to an exact version, so the two must + # appear in the same PR or the group is uninstallable. This is what broke + # #266: `pydantic-core` moved to 2.47.0 while `pydantic` stayed at 2.13.4, + # which requires `pydantic-core==2.46.4`. + pydantic-stack: + patterns: + - pydantic + - pydantic-core + - pydantic_core + - pydantic-settings + update-types: + - minor + - patch + backend-minor-and-patch: + applies-to: version-updates + update-types: + - minor + - patch + ignore: + # `pydantic-core` is not a direct dependency: pyproject.toml declares + # `pydantic`, and requirements.txt is `pip freeze` output, so the core + # only appears there as a resolved transitive pin. `pydantic` pins it to + # an exact version, so it has no independent upgrade path — the correct + # version is always whatever the installed `pydantic` asks for. + # + # Grouping alone does not stop this. A group only forms when one of its + # members actually has an update; with `pydantic` already at the latest + # release, a lone `pydantic-core` bump falls through to the catch-all + # `backend-minor-and-patch` group and breaks the build exactly as before + # (#266, then #277 again after the groups landed). Ignoring it outright + # is the only thing that holds: it now moves only when `pydantic` moves + # and drags it along. + - dependency-name: pydantic-core + - dependency-name: pydantic_core diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 716fa336..2c50f38d 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,38 +1,75 @@ ## Summary -- + -## Type +## Linked issue -- [ ] Bug fix -- [ ] Feature -- [ ] Engineering task -- [ ] Documentation -- [ ] Security + -## Verification +Closes # -- [ ] Frontend build/lint run where relevant -- [ ] Backend tests run where relevant -- [ ] Docker Compose config/runtime readiness checked where relevant -- [ ] Release/deployment docs updated where relevant -- [ ] Manual verification described below +## Scope -Commands/results: + + +- Issue or RFC this advances: +- Why this is in scope (not a "quick useful surface" or breadth addition): +- Accepted evidence it is real (test/usage/repo state, not intent): + +## What changed + + + +## Acceptance criteria completed + + + +## Testing performed + + ```text ``` -## Risk and Rollback +## Screenshots + + + +## Security and data considerations + + + +## Dependencies and blocked work + + + +## Scope changes or remaining work -- Risk level: Low / Medium / High -- Rollback plan: + -## Checklist +## Contributor checklist -- [ ] No unrelated behavior changes -- [ ] No secrets, local env files, generated build artifacts, or local databases committed -- [ ] API behavior documented if changed -- [ ] UI states covered for loading, empty, error, and success where relevant -- [ ] Security/privacy impact considered +- [ ] This PR targets `dev` +- [ ] I claimed the issue and had it assigned or acknowledged before starting substantial work +- [ ] The branch was created from an up-to-date `upstream/dev` +- [ ] The branch is rebased on the latest `upstream/dev` +- [ ] This PR addresses one clearly scoped issue +- [ ] This PR is in scope: it advances a tracked issue or an accepted RFC (Scope section filled) +- [ ] Every acceptance criterion I claim as complete is actually complete +- [ ] Relevant tests pass +- [ ] Documentation is updated for any user-visible change +- [ ] No secrets, credentials, local env files, or generated artifacts are included +- [ ] No unrelated files were changed +- [ ] Closing syntax (`Closes`) is used only because the issue is fully resolved +- [ ] Dependencies and follow-up work are linked diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c4ce5df8..d8829f9f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,13 +5,87 @@ on: push: branches: - main - - master - dev +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: + repository-hygiene: + + name: Repository Hygiene + + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + + - name: Checkout + + uses: actions/checkout@v4 + + - name: Verify tracked generated files + + run: | + + matches=$(git ls-files | grep -E '(^|/)dist/|(^|/)\.env$|(^|/)\.env\.(local|development|production|test)$' || true) + + if [ -n "$matches" ]; then + + echo "Tracked generated or environment files detected:" + + echo "$matches" + + exit 1 + + fi + + echo "Repository hygiene checks passed." + + api-contract: + name: API Contract Drift + needs: repository-hygiene + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: apps/frontend/package-lock.json + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + cache: pip + + - name: Install frontend dependencies + run: npm ci --prefix apps/frontend + + - name: Install backend dependencies + run: | + python -m pip install -r apps/backend/requirements-dev.txt + python -m pip install -e apps/backend --no-deps + + - name: Check generated frontend API contract + run: npm --prefix apps/frontend run generate:api-contract -- --check + frontend: name: Frontend + needs: repository-hygiene runs-on: ubuntu-latest + timeout-minutes: 25 + steps: - name: Checkout uses: actions/checkout@v4 @@ -26,15 +100,76 @@ jobs: - name: Install dependencies run: npm ci --prefix apps/frontend + # Policy-aware, so a green build represents a reviewed dependency-risk + # posture rather than the absence of `npm audit`. Runtime exposure is + # gated harder than development-only exposure, and every exception needs + # a written reason and a review date that expires. + - name: Dependency audit policy tests + run: node --test scripts/dependency-audit.test.mjs + + - name: Dependency audit + run: node scripts/dependency-audit.mjs + - name: Lint run: npm --prefix apps/frontend run lint + - name: Test + run: npm --prefix apps/frontend run test + + # Additive reporting only (#320): the Test step above is what fails the + # job. This just publishes what it already produced (junit.xml from the + # vitest.config.ts CI-only reporter, lcov/json-summary from the + # existing coverage config) so a maintainer can inspect results and + # coverage without re-running the suite locally. + - name: Upload frontend test results and coverage + if: always() + uses: actions/upload-artifact@v4 + with: + name: frontend-test-results + path: | + apps/frontend/test-results/junit.xml + apps/frontend/coverage + if-no-files-found: warn + retention-days: 14 + - name: Build run: npm --prefix apps/frontend run build backend: name: Backend + needs: repository-hygiene runs-on: ubuntu-latest + timeout-minutes: 30 + + # Postgres validates the full migration round trip against the deployment + # dialect and lets the gated refresh-token concurrency test exercise its + # row-lock race. Redis lets the gated rate-limit tests exercise the real + # atomic script and TTL behaviour. Local runs retain SQLite and in-memory + # fallbacks where supported. + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: partha + POSTGRES_PASSWORD: partha + POSTGRES_DB: partha_test + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U partha -d partha_test" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + redis: + image: redis:7-alpine + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + steps: - name: Checkout uses: actions/checkout@v4 @@ -42,44 +177,237 @@ jobs: - name: Setup Python uses: actions/setup-python@v5 with: - python-version: '3.13' + python-version: "3.13" cache: pip + - name: Upgrade pip + run: python -m pip install --upgrade pip + - name: Install dependencies - run: python -m pip install -e apps/backend + run: | + python -m pip install -r apps/backend/requirements-dev.txt + python -m pip install -e apps/backend --no-deps + + - name: Static analysis + working-directory: apps/backend + run: | + ruff check app scripts tests + ruff format --check app scripts tests + mypy app scripts + + - name: Validate capability registry and README + run: python scripts/check-capabilities.py - name: Test working-directory: apps/backend - run: python -m pytest + env: + PARTHA_TEST_PG_URL: postgresql+psycopg://partha:partha@localhost:5432/partha_test + PARTHA_TEST_REDIS_URL: redis://localhost:6379/0 + run: >- + python -m pytest + --cov=app --cov-report=xml:coverage.xml --cov-report=html:htmlcov + --junitxml=junit.xml + + # Additive reporting only (#320): the Test step above is what fails the + # job; this just publishes what it already produced so a maintainer can + # inspect results and coverage without re-running the suite locally. + - name: Upload backend test results and coverage + if: always() + uses: actions/upload-artifact@v4 + with: + name: backend-test-results + path: | + apps/backend/junit.xml + apps/backend/coverage.xml + apps/backend/htmlcov + if-no-files-found: warn + retention-days: 14 + + # This is intentionally separate from pytest: it executes the maintainer + # command itself on two fresh, UUID-named PostgreSQL databases, proving + # both rehearsal paths without ever targeting the service's partha_test + # database. The admin connection below points at the server's default + # "postgres" maintenance database, not partha_test, so CREATE/DROP + # DATABASE never runs against the database pytest uses. The script + # keeps the connection URL out of its output. + - name: Rehearse migrations on isolated PostgreSQL + working-directory: apps/backend + env: + PARTHA_MIGRATION_REHEARSAL_CONFIRM: disposable + PARTHA_MIGRATION_REHEARSAL_PG_URL: postgresql+psycopg://partha:partha@localhost:5432/postgres + run: python scripts/rehearse_migrations.py --postgres + + # The pytest step above validates every benchmark invariant (including the + # failure paths). This step is the human-facing regression report and CI + # artifact: it runs the real benchmark command, writes Markdown + JSON to a + # temporary (never committed) directory, adds a summary to the job page, and + # its exit status is captured so the reports upload even on failure. A later + # step re-raises that status so a below-threshold benchmark fails the job. + - name: Run Repository Intelligence golden benchmark + id: ri_benchmark + working-directory: apps/backend + run: | + mkdir -p "$RUNNER_TEMP/ri-benchmark" + set +e + python tests/benchmark/run.py --report-dir "$RUNNER_TEMP/ri-benchmark" + echo "status=$?" >> "$GITHUB_OUTPUT" - docker-compose: - name: Docker Compose + - name: Validate benchmark report contract + if: always() && steps.ri_benchmark.outcome != 'skipped' + run: | + python - <<'PY' + import json + import os + from pathlib import Path + + report_path = Path(os.environ["RUNNER_TEMP"]) / "ri-benchmark" / "benchmark.json" + report = json.loads(report_path.read_text(encoding="utf-8")) + scoring = report.get("scoring", {}) + if scoring.get("status") != "scored": + raise SystemExit("benchmark did not publish real precision/recall measurements") + if scoring.get("realExtractorProvenance") is None: + raise SystemExit("benchmark did not publish real-extractor citation validity") + rendered = json.dumps(report).lower() + if "deferred" in rendered or "unavailable" in rendered: + raise SystemExit("benchmark report contains a skipped live measurement") + PY + + - name: Upload benchmark reports + if: always() && steps.ri_benchmark.outcome != 'skipped' + uses: actions/upload-artifact@v4 + with: + name: ri-golden-benchmark + path: ${{ runner.temp }}/ri-benchmark + if-no-files-found: error + + - name: Enforce benchmark gate + if: always() && steps.ri_benchmark.outcome != 'skipped' + run: | + if [ "${{ steps.ri_benchmark.outputs.status }}" != "0" ]; then + echo "Repository Intelligence golden benchmark failed (exit ${{ steps.ri_benchmark.outputs.status }})." + exit 1 + fi + echo "Repository Intelligence golden benchmark passed." + + prototype-acceptance: + name: Prototype Browser Acceptance + needs: + - frontend + - backend runs-on: ubuntu-latest + timeout-minutes: 15 + steps: - name: Checkout uses: actions/checkout@v4 - - name: Build backend image - run: docker build -t partha-backend:ci apps/backend + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: apps/frontend/package-lock.json + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + cache: pip - - name: Validate Compose configuration - run: docker compose config + - name: Install frontend + run: npm ci --prefix apps/frontend - - name: Run Compose stack - run: docker compose up --build -d + - name: Install backend + run: | + python -m pip install -r apps/backend/requirements-dev.txt + python -m pip install -e apps/backend --no-deps - - name: Verify Compose API readiness + - name: Install Chromium + # `--with-deps` runs `apt-get update`, which fails the whole job when + # any configured source has a transient bad index. The runner image + # pre-registers dl.google.com/linux/chrome-stable (as .list and/or + # deb822 .sources), and that repo has a recurring Release/Packages + # hash-mismatch window. Playwright downloads its own bundled Chromium + # from its CDN, so that repo is not needed here -- strip every apt + # source that points at it before the update so a Google-side hiccup + # can't red the acceptance job. run: | - for attempt in {1..30}; do - if curl -fsS http://127.0.0.1:8000/ready; then + sudo find /etc/apt/sources.list.d -type f \ + \( -name '*.list' -o -name '*.sources' \) \ + -exec grep -lE 'dl\.google\.com|packages\.microsoft\.com' {} + \ + | sudo xargs -r rm -f || true + sudo sed -i '/dl\.google\.com/d;/packages\.microsoft\.com/d' /etc/apt/sources.list || true + npm --prefix apps/frontend exec -- playwright install --with-deps chromium + + - name: Run disposable-fixture browser journeys + run: node scripts/run-e2e-acceptance.mjs + + - name: Upload browser report + if: always() + uses: actions/upload-artifact@v4 + with: + name: browser-acceptance-report + path: | + apps/frontend/e2e-report + apps/frontend/test-results + if-no-files-found: warn + retention-days: 14 + + docker-build: + name: Docker Build + needs: repository-hygiene + runs-on: ubuntu-latest + timeout-minutes: 15 + + # Validates the single-service image (#340) actually builds and boots -- + # the render.yaml Blueprint is only as trustworthy as this Dockerfile, + # and neither was exercised through Render itself to write it. + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Build image + run: docker build -t partha:ci-check . + + - name: Boot the image with a production-shaped config + run: | + fernet_key=$(python3 -c "import base64,hashlib; print(base64.urlsafe_b64encode(hashlib.sha256(b'ci-check').digest()).decode())") + docker run -d --name partha-ci-check \ + -p 8000:8000 \ + -e DATABASE_URL=sqlite:////tmp/partha-ci-check.db \ + -e STORAGE_PATH=/tmp/partha-ci-check-storage \ + -e AUTH_SECRET_KEY=ci-check-secret-key-at-least-32-characters-long \ + -e AI_ENCRYPTION_KEY="$fernet_key" \ + -e APP_ENV=production \ + -e CORS_ORIGINS=https://ci-check.example.com \ + -e ANALYSIS_WORKER_AUTOSTART=false \ + partha:ci-check + + - name: Wait for readiness + run: | + for _ in $(seq 1 30); do + if curl -sf http://127.0.0.1:8000/health > /dev/null; then exit 0 fi - docker compose ps - sleep 2 + sleep 1 done - docker compose logs api postgres redis + echo "Service never became healthy" + docker logs partha-ci-check exit 1 - - name: Stop Compose stack + - name: Verify the API and the mounted frontend both respond + run: | + curl -sf http://127.0.0.1:8000/health + curl -sf http://127.0.0.1:8000/ready + # A client-side route (not just "/") must resolve to the SPA shell, + # not a 404 -- this is the whole point of the catch-all in app.main. + curl -sf http://127.0.0.1:8000/dashboard | grep -qi " audit-output.txt 2>&1 + exit_code=$? + cat audit-output.txt + echo "exit_code=$exit_code" >> "$GITHUB_OUTPUT" + + - name: Ensure alert label exists + if: steps.audit.outputs.exit_code != '0' + env: + GH_TOKEN: ${{ github.token }} + run: | + gh label create dependency-audit-alert \ + --color b60205 \ + --description "Auto-filed by the scheduled dependency-audit workflow" \ + --force + + - name: File or update alert issue on new blocking findings + if: steps.audit.outputs.exit_code != '0' + env: + GH_TOKEN: ${{ github.token }} + run: | + existing=$(gh issue list --label dependency-audit-alert --state open --json number --jq '.[0].number // empty') + + { + echo "Scheduled \`node scripts/dependency-audit.mjs\` run against \`dev\` found blocking findings." + echo + echo "This is the same gate that blocks every PR's Frontend CI check. Catching it here," + echo "independent of any open PR, keeps a same-day fix available instead of it blindsiding" + echo "unrelated work later." + echo + echo '```' + cat audit-output.txt + echo '```' + echo + echo "Fix: bump the affected package(s) via \`apps/frontend/package.json\`'s \`overrides\` to a" + echo "patched version (confirm via the GitHub Advisory API's \`first_patched_version\`, not just" + echo "\`npm audit\`'s summary text -- it can be misleading about which version line a fix landed" + echo "on). Regenerate the lockfile with \`npm install --prefix apps/frontend\` specifically (not" + echo "from the repo root -- see \`scripts/dependency-audit.mjs\`'s own comment on why), then verify" + echo "\`node scripts/dependency-audit.mjs\` exits 0 before pushing." + } > alert-body.txt + + if [ -z "$existing" ]; then + gh issue create \ + --title "security: scheduled dependency audit found new blocking advisories on dev" \ + --label dependency-audit-alert \ + --assignee parthrohit22 \ + --body-file alert-body.txt + else + gh issue comment "$existing" --body-file alert-body.txt + fi + + - name: Close alert issue if the audit is clean + if: steps.audit.outputs.exit_code == '0' + env: + GH_TOKEN: ${{ github.token }} + run: | + existing=$(gh issue list --label dependency-audit-alert --state open --json number --jq '.[0].number // empty') + if [ -n "$existing" ]; then + gh issue comment "$existing" --body "Scheduled audit is now clean against \`dev\` -- closing." + gh issue close "$existing" + fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5a45833f..573e9bab 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,11 +5,45 @@ on: tags: - "v*" workflow_dispatch: + # Manual dispatch runs validation only. GitHub Release publication is gated + # to tag-triggered runs by the github-release job condition below. + +permissions: + contents: read jobs: validate: name: Validate Release Candidate runs-on: ubuntu-latest + timeout-minutes: 35 + + # Release validation runs the release-specific frontend and backend checks, + # including Postgres and Redis-backed tests. It is not the complete PR CI + # matrix: browser acceptance and the golden benchmark remain in ci.yml. + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: partha + POSTGRES_PASSWORD: partha + POSTGRES_DB: partha_test + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U partha -d partha_test" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + redis: + image: redis:7-alpine + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + steps: - name: Checkout uses: actions/checkout@v4 @@ -30,41 +64,53 @@ jobs: - name: Install frontend dependencies run: npm ci --prefix apps/frontend + # Pinned lockfile install, matching every other CI job (ci.yml) -- + # `pip install -e "apps/backend[test]"` would instead resolve + # pyproject.toml's loose ranges live from PyPI, so a release could be + # validated against completely different, untested dependency versions + # than what PR CI actually tested. - name: Install backend dependencies - run: python -m pip install -e apps/backend + run: | + python -m pip install -r apps/backend/requirements-dev.txt + python -m pip install -e apps/backend --no-deps + + - name: Frontend dependency audit + run: | + node --test scripts/dependency-audit.test.mjs + node scripts/dependency-audit.mjs - name: Lint frontend run: npm --prefix apps/frontend run lint + - name: Check generated frontend API contract + run: npm --prefix apps/frontend run generate:api-contract -- --check + - name: Build frontend run: npm --prefix apps/frontend run build - - name: Test backend + - name: Backend static analysis working-directory: apps/backend - run: python -m pytest + run: | + ruff check app + ruff format --check app + mypy app - - name: Build backend image - run: docker build -t partha-backend:release apps/backend + - name: Validate capability registry and README + run: python scripts/check-capabilities.py - - name: Validate Compose runtime - run: | - docker compose up --build -d - for attempt in {1..30}; do - if curl -fsS http://127.0.0.1:8000/ready; then - docker compose down -v - exit 0 - fi - sleep 2 - done - docker compose logs api postgres redis - docker compose down -v - exit 1 + - name: Test backend + working-directory: apps/backend + env: + PARTHA_TEST_PG_URL: postgresql+psycopg://partha:partha@localhost:5432/partha_test + PARTHA_TEST_REDIS_URL: redis://localhost:6379/0 + run: python -m pytest github-release: name: Publish GitHub Release needs: validate if: startsWith(github.ref, 'refs/tags/') runs-on: ubuntu-latest + timeout-minutes: 10 permissions: contents: write steps: diff --git a/.gitignore b/.gitignore index 22de1458..a1b8e4e3 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,8 @@ env/ .coverage .coverage.* htmlcov/ +apps/backend/coverage.xml +apps/backend/junit.xml # Databases *.db @@ -71,6 +73,22 @@ apps/backend/.env !apps/frontend/.env.example !apps/backend/.env.example +# Vercel CLI project-link metadata (apps/marketing, #382) -- not secret, but +# machine-local and not meant to be committed. +apps/marketing/.vercel + +# ========================================== +# Local-only strategy / roadmap docs (never commit) +# The Market-Fit Roadmap is the single source of truth for product direction, +# but it must stay local-only (not published to the public repo). +# ========================================== + +PARTHA_Market_Fit_Product_Roadmap.html +PARTHA_Defensible_Repository_Intelligence_Roadmap_2026_2027.html + +# Local Hermes agent state (desktop attachments, sessions, caches) +.hermes/ + # ========================================== # IDE # ========================================== @@ -111,12 +129,6 @@ tmp/ temp/ .cache/ -# ========================================== -# Docker -# ========================================== - -docker-compose.override.yml - # ========================================== # Misc # ========================================== @@ -136,9 +148,24 @@ docker-compose.override.yml coverage/ .nyc_output/ +# Repository Intelligence golden benchmark reports (generated; never commit — +# CI writes them to a temporary directory, developers to a throwaway dir) +apps/backend/.ri-benchmark/ +ri-benchmark-report/ + # ========================================== # OS generated # ========================================== .fseventsd -.Trashesg \ No newline at end of file +.Trashesg + +PARTHA_Defensible_Repository_Intelligence_Roadmap_2026_2027.html + +/confidential/ + +# Playwright browser-review artifacts (reports, traces, failure shots). +# Keep generated visual evidence out of the public documentation tree. +apps/frontend/test-results/ +apps/frontend/e2e-report/ +apps/frontend/playwright-report/ diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..4bea93b8 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,139 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual +identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall + community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances of + any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, + without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official email address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported privately to the project maintainer, **[@parthrohit22](https://github.com/parthrohit22)**. + +Reports may be sent through GitHub by contacting the maintainer directly on +their profile. Do not report conduct concerns in a public issue, a pull request, +or a code review comment. + +The address published in [`SECURITY.md`](SECURITY.md) is a vulnerability +disclosure channel and is not a conduct-reporting channel. + +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of +actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or permanent +ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the +community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][mozilla coc]. + +For answers to common questions about this code of conduct, see the FAQ at +[https://www.contributor-covenant.org/faq][faq]. Translations are available at +[https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[mozilla coc]: https://github.com/mozilla/diversity +[faq]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c520cec1..6b525adf 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,329 +1,472 @@ # Contributing to PARTHA -Thanks for helping build PARTHA. +These are the project rules, not a welcome page. If you follow them, your pull request can be reviewed and merged. If you do not, it will be sent back regardless of the quality of the code. -PARTHA is an Engineering Intelligence Platform. The central architectural rule is: +PARTHA is a Repository Intelligence Platform. One architectural rule sits above all others: -> Repository Intelligence is the source of truth. Product features consume it; they do not independently parse repositories. +> **Repository Intelligence is the shared repository-understanding layer. Architecture, dependencies, reviews, documentation, exports, and optional AI features consume it. AI must remain a downstream consumer, never an independent interpreter of the repository.** -This guide explains how to contribute safely, keep reviews focused, and preserve the system architecture as the project grows. +Throughout this document, **must** and **must not** are requirements, **should** is a strong expectation, and **may** is permission. + +## 0. Scope discipline (merge gate) + +PARTHA's direction is maintainer-directed and tracked in the open: accepted +RFCs under [`docs/architecture/`](docs/architecture/) and the issue tracker +are the source of truth for what is in scope, not an unpublished document. + +A pull request is **in scope only if it advances a tracked GitHub issue or an +accepted RFC, with accepted evidence.** A "quick useful surface" or breadth +addition that does not trace to either is **out of scope** and should not be +proposed or merged. The encouraged default is to *deepen the existing moat* +(versioned evidence, history and drift, governed team memory, workflow +outcomes); new surfaces or languages are the discouraged direction and need +explicit owner justification before you start. + +You must fill the **Scope** section of the pull request template on every +pull request — naming the issue or RFC it advances and the accepted evidence. +A pull request that cannot fill it is, by definition, out of scope and will be +sent back. If you are unsure whether a change is in scope, ask on the issue +before you start work. + +Setting product direction is reserved to the maintainer; propose changes to it +through an issue, not by editing an accepted RFC's conclusions. --- -## Development Setup +## 1. Setup ### Prerequisites -| Tool | Version | -| --- | --- | -| Node.js | 22 or newer recommended | -| Python | 3.12 or 3.13 | -| Docker | Required for Compose validation | -| Git | Required for repository import workflows | +| Tool | Version | Needed for | +| --- | --- | --- | +| Python | 3.12 or 3.13 | Backend | +| Node.js | 22 | Frontend | +| Git | any recent | Everything | -### Install +### Backend ```bash -git clone https://github.com/Second-Origin/PARTHA.git -cd PARTHA - -npm ci --prefix apps/frontend - cd apps/backend python3.13 -m venv .venv source .venv/bin/activate pip install -e . cd ../.. -``` - -### Configure -```bash -cp .env.example .env -cp apps/frontend/.env.example apps/frontend/.env -cp apps/backend/.env.example apps/backend/.env +npm run dev:backend ``` -The local backend env example uses SQLite and local filesystem storage. Docker Compose injects PostgreSQL, Redis, and container storage settings separately. +The backend defaults to SQLite and local filesystem storage, so it starts with no PostgreSQL and no Redis. No `.env` file is required for local development — every setting has a working default. Copy `apps/backend/.env.example` only to change one. -### Run +### Frontend ```bash -npm run dev:backend +npm ci --prefix apps/frontend npm run dev:frontend ``` -Useful endpoints: +Register an account through the UI, then sign in. + +For a fuller walkthrough — confirming the backend is actually up, running +every test/lint/build command, and fixing the local database or API-contract +failures you're most likely to hit — see +[docs/DEVELOPMENT.md](docs/DEVELOPMENT.md). -- frontend: `http://localhost:5173` -- backend OpenAPI: `http://localhost:8000/docs` -- readiness: `http://localhost:8000/ready` -- metrics: `http://localhost:8000/metrics` +### Where the code lives + +| Path | Contents | +| --- | --- | +| `apps/backend/app/intelligence/` | **Repository Intelligence engine and models.** The system's core boundary. | +| `apps/backend/app/api/` | Routes and dependency wiring | +| `apps/backend/app/services/` | Application services | +| `apps/backend/app/analysis/`, `graph/`, `review/`, `ai/`, `reports/` | Consumers of Repository Intelligence | +| `apps/backend/app/auth/`, `core/`, `models/`, `storage/` | Auth, config, ORM models, local storage | +| `apps/backend/alembic/`, `apps/backend/tests/` | Migrations, backend tests | +| `apps/frontend/src/` | `app/` shell and routes, `features/`, `shared/` | +| `docs/` | Public documentation | --- -## Branch Strategy +## 2. Fork-first workflow -| Branch | Purpose | -| --- | --- | -| `main` | Stable release snapshots. | -| `dev` | Active integration branch. | -| `feature/*` | New scoped feature work. | -| `fix/*` | Bug fixes. | -| `docs/*` | Documentation-only changes. | -| `chore/*` | Tooling, CI, maintenance, or repository hygiene. | +PARTHA uses a **fork-first** contribution model. You do not get push access to the official repository. -Rules: +**Contributors do not push directly to `dev`. They push a dedicated working branch to their own fork and open a pull request targeting `dev`.** + +All normal development pull requests target `dev`. The `main` branch is reserved for maintainer-controlled releases or promotion from `dev`. + +### Set up your fork once -- Do not push directly to `main`. -- Do not push directly to `dev`. -- Branch from the latest `dev`. -- Keep one branch focused on one issue or tightly related change. +1. Fork `Second-Origin/PARTHA` on GitHub. +2. Clone your fork and add the official repository as `upstream`: ```bash -git checkout dev -git pull origin dev -git checkout -b feature/repository-intelligence +git clone https://github.com//PARTHA.git +cd PARTHA +git remote add upstream https://github.com/Second-Origin/PARTHA.git +git remote -v ``` ---- +### For every piece of work -## Issue Workflow +```bash +git fetch upstream +git checkout -b feature/123-short-description upstream/dev +# ...commit your work... +git push -u origin feature/123-short-description +``` -Before starting non-trivial work: +Then open a pull request from your fork's branch to `Second-Origin/PARTHA:dev`. -1. Check existing GitHub Issues. -2. Choose or create a focused issue. -3. Confirm the intended scope. -4. Wait for assignment or maintainer agreement when the change is large. -5. Keep implementation aligned with the issue. +### You must not -Do not bundle unrelated cleanup into a feature PR. If you find an unrelated bug, open a separate issue. +- push directly to `dev` +- push directly to `main` +- develop directly on your fork's `dev` branch +- open ordinary feature or fix pull requests against `main` +- combine unrelated issues in one branch +- reuse a merged branch for new work +- self-merge your pull request +- rewrite or force-push another contributor's branch --- -## Pull Request Workflow +## 3. Claim an issue before you start -All PRs target `dev`. +Substantial work **must** be claimed first. Unclaimed work may be closed unmerged even if it is correct, because it may duplicate or conflict with work already in progress. -Use the PR template and include: +Before starting, you must: -- summary; -- related issue; -- changed files or systems; -- testing performed; -- risk and rollback notes; -- screenshots for UI changes; -- request/response examples for API changes; -- migration notes for persistence changes. +1. Read the complete issue. +2. Read its comments, linked issues, dependencies, and acceptance criteria. +3. Confirm it is open and not already assigned to someone else. +4. Comment on the issue stating **why** you want to work on it and requesting to be assigned. +5. Start working — you do **not** need to wait for a maintainer to acknowledge before you begin. The comment itself is the claim; the maintainer will assign you on GitHub when able. +6. Ask for clarification if the acceptance criteria are not testable, **before** you implement anything. -Use draft PRs for early architecture feedback. +If you cannot assign yourself because of GitHub permissions, your comment is the claim mechanism — post it, then begin. -Maintainers should be able to review the PR without reverse-engineering intent from the diff. +You must not begin substantial work on an issue that is already assigned to someone else. If one is, pick a different issue. ---- +You must not begin substantial work on: -## Code Standards +- an obsolete issue +- an issue whose scope is disputed +- an issue blocked by unmerged prerequisite work +- an issue without testable acceptance criteria -### Repository Intelligence Boundary +If no suitable issue exists, propose one using an existing issue template before implementing. Do not open a duplicate issue — search first, and comment on the existing one instead. -Do: +### Choosing a template -- add reusable extraction to `apps/backend/app/intelligence/` when a feature needs repository facts; -- let feature services transform existing repository intelligence into response models; -- preserve one source of truth for architecture, dependencies, documentation, review, exports, and AI context. +The repository provides three issue templates in [`.github/ISSUE_TEMPLATE/`](.github/ISSUE_TEMPLATE/): -Do not: +| Template | Use it for | +| --- | --- | +| **Bug Report** | Broken or incorrect behaviour. Give exact reproduction steps (route, endpoint, input repository, commands), the expected behaviour, the actual behaviour, evidence such as logs or payloads, and your environment. | +| **Feature Request** | A new capability or user workflow. State the problem first, then the proposed behaviour, the affected pages/endpoints/services, testable acceptance criteria, and any risks or dependencies. | +| **Engineering Task** | Technical debt, refactors, tests, infrastructure. State the task, why it matters now, the likely files and constraints, and acceptance criteria. | -- re-read dependency manifests inside feature-specific services; -- traverse repository files in consumers when the intelligence engine should own the fact; -- let AI providers parse repositories directly; -- duplicate parser logic in frontend code. +In every template: write acceptance criteria that someone other than you can verify, name the affected components, and disclose dependencies and blocking work. -### Backend +For documentation changes, open a Feature Request or Engineering Task describing what is inaccurate and what it should say. -- Keep routes thin. -- Put business logic in services. -- Use schemas for request/response boundaries. -- Use typed service interfaces and explicit errors. -- Preserve standardized error responses. -- Avoid broad exception handling unless it adds operational context. -- Keep provider-specific AI logic inside provider implementations. -- Keep report builders separate from renderers. +### Security vulnerabilities -### Frontend +**Security vulnerabilities must never be filed as public issues.** Report them privately through [SECURITY.md](SECURITY.md). Do not include a vulnerability, an exploit, or a proof of concept in an issue, a pull request, or a comment. -- Keep app shell, feature code, and shared utilities separated. -- Reuse shared API clients and shared types. -- Avoid `any` unless there is a concrete interoperability reason. -- Preserve loading, empty, error, and success states. -- Keep UI changes scoped to the affected feature. +--- -### General +## 4. Branch naming -- Remove dead code. -- Avoid speculative abstractions. -- Prefer small, reviewable changes. -- Do not commit secrets, local env files, local databases, build outputs, or generated caches. +Every issue gets a dedicated branch created from the latest `upstream/dev`. ---- +```text +/- +``` -## Testing Expectations +```text +feature/123-python-symbol-extraction +fix/145-owner-scope-analysis +docs/152-contribution-workflow +test/167-archive-regression +refactor/181-parser-boundary +security/193-provider-configuration +chore/204-ci-cache +``` -Run the checks relevant to your change. +Allowed types: `feature`, `fix`, `docs`, `test`, `refactor`, `chore`, `security`. -### Frontend +One branch normally addresses one issue. If an issue is too large for a single reviewable pull request, split the issue, or use explicitly linked dependent pull requests (§9). -```bash -npm run lint:frontend -npm run build:frontend -``` +--- -### Backend +## 5. Rebase onto `dev` + +You must rebase onto the latest `upstream/dev` **before opening** a pull request, and **again before final review**. ```bash -npm run test:backend +git fetch upstream +git rebase upstream/dev +git push --force-with-lease origin ``` -### Full Build Gate +Rules: -```bash -npm run build +- Resolve conflicts locally, and rerun the relevant validation afterwards. A rebase can silently break code that previously passed. +- Use `--force-with-lease`. Never use unrestricted `--force`. +- Do not merge `dev` into your working branch merely to avoid rebasing, unless a maintainer explicitly asks you to. +- Never rebase or force-push a branch owned by someone else. + +--- + +## 6. Pull requests + +When the issue is complete: + +1. Push your dedicated branch to your fork. +2. Open a pull request targeting `Second-Origin/PARTHA:dev`. +3. Use the existing [pull request template](.github/pull_request_template.md) — do not delete its sections. +4. Summarise what you implemented. +5. Link the issue. +6. Explain how you tested it, with the commands you ran. +7. Include screenshots or a recording for any visible UI change. +8. Identify configuration, migration, dependency, security, data, and compatibility implications. +9. Disclose dependencies and blocked work. +10. Request one reviewer: **`@parthrohit22`**. +11. Wait for approval and maintainer merge. + +If GitHub permissions prevent you from assigning a reviewer, request review in the pull request description or a comment. CODEOWNERS may also request review automatically. + +**You must not self-merge.** + +### Issue-closing syntax + +When a pull request **fully** completes an issue, its **description** must contain: + +```text +Closes #123 ``` -### Docker / Platform Changes +The closing statement must be in the pull request description — not only in a commit message, and not only in a later comment. -If you change Docker, Compose, environment, CI, startup, health, readiness, or observability: +Use `Closes`, `Fixes`, or `Resolves` **only** when every acceptance criterion is complete. -```bash -docker build -t partha-backend:local apps/backend -npm run docker:config -npm run docker:validate +If the pull request is partial, use one of: + +```text +Related to #123 +Part of #123 +Follow-up to #123 ``` -If you cannot run a check locally, say so in the PR and explain why. +Do **not** use closing syntax when: + +- any acceptance criterion remains incomplete +- tests required by the issue are missing +- documentation required by the issue is missing +- another pull request is still required +- the implementation deliberately changed scope +- any part of the work was deferred +- you cannot verify that the issue is actually solved + +### Scope changes + +If implementation reveals that the issue is inaccurate, unsafe, obsolete, blocked, or no longer achievable as written, you **must not** silently change scope. + +You must: + +1. Explain the discovery on the issue. +2. Update the pull request description. +3. State which acceptance criteria you completed. +4. State what remains incomplete. +5. Link any follow-up issues or dependent pull requests. +6. Ask whether the issue should be rewritten, split, superseded, or closed. + +A pull request that does not fully solve its issue must not use closing syntax. Important scope changes belong in the pull request description — do not leave essential information only in review comments. --- -## Documentation Standards +## 7. Review + +After opening a pull request you must: + +- wait for the automated checks +- respond to reviewer questions +- address requested changes +- keep the pull request focused on its issue +- update the description if scope changes +- keep the branch current with `dev` +- rerun tests after any rebase or conflict resolution +- wait for maintainer approval and merge -Update documentation when a change affects: +A reviewer approval does **not** override failing required checks. -- public behavior; -- setup or environment variables; -- API contracts; -- architecture boundaries; -- operational behavior; -- contributor workflows; -- product positioning. +Resolve a conversation only once the concern has actually been addressed, or a maintainer has made a decision. Do not resolve a reviewer's comment merely to clear the thread. -Documentation should be: +--- + +## 8. Merged branches are deleted -- accurate to the current implementation; -- explicit about limitations; -- free of placeholder docs unless the section is intentionally a screenshot/demo placeholder; -- linked from `docs/README.md` when durable. +Merged branches may be deleted automatically. Assume your working branch disappears after merge. -Use: +Therefore you must: -- `README.md` for public orientation; -- `docs/architecture/` for system boundaries and lifecycles; -- `docs/operations/` for deployment, release, dependency, and observability workflows; -- `docs/audit/` for evidence and audit trails; -- `docs/brand/` for visual identity guidance; -- `docs/product/` for product positioning and public-face audits. +- not leave unfinished work only on a branch that is being merged +- move unfinished work to a separate branch before merge +- not reuse a merged or deleted branch for unrelated work +- create follow-up branches from the latest `dev` +- preserve unmerged work in a dedicated dependent branch --- -## Legal and Licensing +## 9. Dependent and stacked branches -PARTHA is licensed under the Apache License 2.0. By submitting a contribution, you agree that your contribution is provided under the same license unless maintainers explicitly document a different arrangement. +Dependent branches are allowed **only** when the work genuinely cannot be reviewed independently. Do not use stacked pull requests to avoid properly splitting an oversized issue. -Contributor expectations: +1. Create the first branch from `upstream/dev`. +2. Create the dependent branch from the prerequisite branch. +3. Open the prerequisite pull request first. +4. State the dependency in the dependent pull request: -- Only contribute work you have the right to submit. -- Do not copy third-party code, images, fonts, datasets, or text into the project unless the license is compatible and attribution requirements are documented. -- Keep dependency additions reviewable so maintainers can evaluate license and security impact. -- Do not add custom license terms, headers, or notices without maintainer approval. + ```text + Depends on # + ``` -PARTHA does not currently require a CLA or DCO sign-off. If that changes, maintainers should document the policy in this file before enforcing it. +5. While the prerequisite is open, the dependent pull request may target the prerequisite branch, to keep its review diff clean. +6. Do not merge the dependent pull request before its prerequisite. +7. After the prerequisite merges: + - `git fetch upstream` + - rebase the dependent branch onto `upstream/dev` + - resolve conflicts + - rerun the relevant tests + - push with `--force-with-lease` + - retarget the dependent pull request to `dev` + - verify the final diff contains only the dependent work -This section is project policy, not legal advice. +Every dependent pull request must link its issue, its prerequisite pull request, any follow-up pull request, and the required merge order. --- -## Commit Conventions +## 10. Testing -Use concise Conventional Commit-style messages: +Run the checks relevant to your change. These are what CI runs. -```text -feat(repository): add safe file preview -fix(upload): report invalid archive errors -docs(readme): reposition public project overview -refactor(ai): isolate provider implementation -test(export): cover markdown renderer -chore(ci): validate compose readiness -``` +| Command | Runs | CI job | +| --- | --- | --- | +| `npm run test:backend` | Backend tests (pytest) | Backend | +| `ruff check app` · `ruff format --check app` · `mypy app` | Backend static analysis. Needs `requirements-dev.txt` — see the [backend README](apps/backend/README.md#static-analysis) | Backend | +| `python scripts/check-capabilities.py` | Capability registry and its generated README block are current and deterministic | Backend | +| `npm --prefix apps/frontend run test` | Frontend tests (vitest) | Frontend | +| `npm run lint:frontend` | ESLint | Frontend | +| `npm run build:frontend` | `tsc -b && vite build` — type errors surface here, not in lint | Frontend | +| `node scripts/dependency-audit.mjs` · `node --test scripts/dependency-audit.test.mjs` | Dependency audit policy and its own tests | Frontend | +| `npm --prefix apps/frontend run generate:api-contract -- --check` | Generated frontend DTOs have not drifted from the FastAPI schema | API Contract Drift | +| `npm run test:e2e` | Disposable fixtures plus the Playwright browser journeys | Prototype Browser Acceptance | +| `npm run test:accessibility` | The WCAG 2.2 AA baseline journeys only, on the same stack | Prototype Browser Acceptance | + +`npm run build` runs the frontend build plus the backend tests. It does **not** run frontend lint or frontend tests — run those separately. + +The CI job is still named "Prototype Browser Acceptance" even though the local command is `npm run test:e2e` — that job name is a required status check in branch protection, so renaming it needs a matching change to the branch ruleset, not just this file. -Common types: +The table is the full CI gate list, so a change can pass `test:backend` and +`build:frontend` and still fail on contract drift, the capability registry, the +dependency audit, or a browser journey. If you touched a backend schema, run the +contract check; if you touched navigation or a page's structure, run +`test:accessibility`. -| Type | Use for | +| If you changed… | You must run | | --- | --- | -| `feat` | User-facing or platform capability. | -| `fix` | Bug fix. | -| `refactor` | Internal change without intended behavior change. | -| `docs` | Documentation-only change. | -| `test` | Test additions or updates. | -| `chore` | Maintenance, tooling, CI, repository hygiene. | -| `security` | Security hardening or vulnerability fixes. | +| Backend logic, services, intelligence, parsers | `npm run test:backend` | +| API request/response shape | `npm run test:backend`, update the frontend client and types, `npm run build:frontend` | +| Database models | Add an Alembic migration, then `npm run test:backend` (migration up/down is covered) | +| Frontend code | `npm run lint:frontend`, `npm --prefix apps/frontend run test`, `npm run build:frontend` | +| Local startup, CI, config, health | Smoke-check the affected backend/frontend start command and run the relevant tests above | +| Anything user-visible | Update the documentation **in the same pull request** | + +Four backend tests are gated on real services and skip locally: two need `PARTHA_TEST_PG_URL` (the PostgreSQL analysis-job and refresh-token concurrency cases) and two need `PARTHA_TEST_REDIS_URL` (the Redis rate-limit backend cases). CI provides both services, so a locally green run showing four skips is expected rather than a problem. + +If you cannot run a check locally, say so in the pull request and explain why. **Do not claim a check you did not run.** + +Every CI run publishes JUnit test results and coverage reports as downloadable artifacts on the workflow run page (GitHub Actions run → Summary → Artifacts): `frontend-test-results` (junit.xml plus the vitest HTML/lcov coverage report) from the Frontend job, and `backend-test-results` (junit.xml plus the pytest-cov XML/HTML coverage report) from the Backend job. Artifacts are kept for 14 days and still upload on a failing run, so a failure's exact JUnit output is inspectable without reproducing it locally. + +### Migrations and breaking changes + +- Every schema change ships an Alembic migration, and it **must downgrade cleanly** — the migration test enforces this. +- Never edit a migration that has already merged. Add a new one. +- Backfills belong in the migration, not in application startup. +- A breaking API change requires the design to be agreed on the issue first, and the frontend client and documentation updated in the same pull request. --- -## Review Expectations +## 11. Architectural rules + +1. **Repository Intelligence is the shared repository-understanding boundary.** If a feature needs a repository fact, add reusable extraction to `app/intelligence/` and consume it from there. +2. **Consumers must not create separate repository parsers.** No walking the tree, no re-reading dependency manifests, no duplicating language or framework detection inside architecture, dependencies, review, documentation, export, or AI code. +3. **AI must remain an optional downstream consumer.** It must not read repository files or reinterpret the repository independently. +4. **Heuristic results must not be presented as guaranteed facts.** Most of what the engine infers — roles, modules, layers, symbols, frameworks — is inferred from paths and filenames. Label it accordingly in the API and the UI. See [Repository Intelligence](docs/architecture/REPOSITORY_INTELLIGENCE.md). +5. **Evidence must be represented only as precisely as the implementation supports.** Supported extractors produce validated one-based inclusive line spans. Column-level evidence is not provided. Do not emit invented line numbers, placeholder citations, or fabricated success states to make output look grounded. +6. **Planned capabilities must not be documented as implemented.** An API field, a model, or a class name is not evidence that a capability exists. +7. **Backend resources must be owner-scoped wherever authentication applies.** Use the owner-scoped accessors, not the unscoped ones. +8. **Never expose secrets, credentials, or repository contents in logs.** +9. **Security-sensitive changes require explicit tests and reviewer attention.** Say so plainly in the pull request. +10. **Avoid unrelated refactors inside a scoped issue.** Drive-by cleanup makes a diff unreviewable. Open a separate issue. + +**Current behaviour belongs in documentation. Future work belongs in GitHub issues.** + +### Code standards -Reviewers should check: +**Backend.** Keep routes thin and logic in services. Use schemas at the boundary. Preserve the standard error response shape. Avoid broad `except:` — catch what you can handle. -- issue scope; -- architecture boundaries; -- dependency direction; -- Repository Intelligence reuse; -- API compatibility; -- frontend/backend contract compatibility; -- error handling; -- security and secret handling; -- test coverage; -- documentation accuracy; -- operational impact. +**Frontend.** Keep shell, features, and shared code separate. Reuse the shared API client and types. **No new `any`** — use `unknown` and narrow it. Preserve loading, empty, error, and success states. -For stale branches, compare against the latest `origin/dev` and call out duplicate or superseded work. +**General.** Remove dead code. Avoid speculative abstractions. Never commit secrets, `.env` files, local databases, build outputs, or caches. --- -## Security and Secrets +## 12. Definition of Ready -Never commit: +An issue is ready to be claimed and started only when: -- `.env` files; -- API keys; -- provider credentials; -- database credentials; -- local databases; -- uploaded repositories; -- generated caches or build artifacts. +- its objective is clear +- its acceptance criteria are testable +- the affected component is identifiable +- dependencies and blockers are recorded +- security and data implications are identified +- it is not already assigned +- a maintainer has acknowledged the claim -If a secret is accidentally committed, notify maintainers immediately and rotate it. Removing it from a later commit is not enough. +An issue failing any of these is not "almost ready" — it needs design, not an assignee. --- -## Need Help? +## 13. Definition of Done + +Work is complete only when: + +- every claimed acceptance criterion is complete +- the implementation matches the agreed scope +- relevant tests are added or updated +- relevant tests pass +- documentation reflects the implemented behaviour +- security and data implications have been considered +- no credentials or sensitive information are committed +- the branch is rebased onto the latest `dev` +- the pull request contains no unrelated changes +- dependencies and follow-up work are linked +- the pull request description reflects the final outcome +- closing syntax is used only when the issue is fully resolved +- review feedback is addressed +- required checks pass +- the maintainer approves and merges the pull request + +> **Code written does not mean issue completed.** + +--- -If the architecture is unclear, ask before implementing. PARTHA benefits more from a small, well-scoped design discussion than a large PR that has to be unwound. +## 14. Conduct and licensing -Good contributor questions include: +All participation is governed by the [Code of Conduct](CODE_OF_CONDUCT.md). -- Should this fact belong in Repository Intelligence? -- Does this feature consume an existing model or need a new reusable extraction? -- Does this change affect API compatibility? -- Is this public behavior, internal architecture, or future roadmap? +PARTHA is licensed under the Apache License 2.0. By contributing, you agree your contribution is provided under that same license. Contribute only work you have the right to submit, and do not copy third-party code, images, fonts, datasets, or text into the project unless the license is compatible and the attribution is documented. There is currently no CLA or DCO requirement; if that changes, it will be documented here before being enforced. -Thanks for helping make PARTHA a trustworthy Engineering Intelligence Platform. +Questions about scope, an issue, or a pull request are welcome on [Discord](https://discord.gg/qvk9DcxDA) before you invest time in something that might be out of scope. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..173eef4e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,74 @@ +# Single-service hosting (#339, #340): build the frontend, then serve it +# from the same FastAPI process that serves the API. Two stages so the +# runtime image never needs Node.js -- only the built static output crosses +# the stage boundary. + +FROM node:22-slim AS frontend-build +WORKDIR /repo +COPY apps/frontend/package.json apps/frontend/package-lock.json apps/frontend/ +RUN npm ci --prefix apps/frontend +COPY apps/frontend apps/frontend +# BrandLogo.tsx reaches outside apps/frontend to ../../docs/assets for the +# product logo -- a real, pre-existing cross-boundary reference in the +# source, not something this Dockerfile introduced. The build context has to +# include it at the same relative position or the build fails. +COPY docs docs +RUN npm run build --prefix apps/frontend + +FROM python:3.13-slim AS backend-build +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 +WORKDIR /app + +# build-essential compiles any dependency without a prebuilt wheel; it is +# only ever needed at install time, so it is confined to this stage and +# never reaches the runtime image below. +RUN apt-get update \ + && apt-get install -y --no-install-recommends build-essential \ + && rm -rf /var/lib/apt/lists/* + +COPY apps/backend/pyproject.toml ./ +COPY apps/backend/app ./app +COPY apps/backend/alembic.ini ./ +COPY apps/backend/alembic ./alembic + +RUN python -m venv /opt/venv +ENV PATH="/opt/venv/bin:$PATH" +RUN pip install --no-cache-dir --upgrade pip \ + && pip install --no-cache-dir -e . + +FROM python:3.13-slim AS backend +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 +WORKDIR /app + +# git is a genuine runtime dependency -- app/github/client.py shells out to +# it to clone and inspect repositories being analyzed -- so it stays in the +# runtime image. The compiler toolchain above does not. +RUN apt-get update \ + && apt-get install -y --no-install-recommends git \ + && rm -rf /var/lib/apt/lists/* \ + && useradd --create-home --uid 1000 --shell /usr/sbin/nologin appuser + +COPY --from=backend-build /opt/venv /opt/venv +ENV PATH="/opt/venv/bin:$PATH" + +COPY apps/backend/pyproject.toml ./ +COPY apps/backend/app ./app +COPY apps/backend/alembic.ini ./ +COPY apps/backend/alembic ./alembic + +COPY --from=frontend-build /repo/apps/frontend/dist /app/frontend-dist +ENV FRONTEND_DIST_PATH=/app/frontend-dist + +RUN chown -R appuser:appuser /app +USER appuser + +EXPOSE 8000 +# $PORT is set by Render (and most PaaS hosts) at runtime; 8000 is only the +# local-Docker fallback. Migrations run here rather than as a separate, +# easy-to-forget manual step -- AUTO_CREATE_TABLES defaults to false outside +# development/test, so without this the app would boot against an unmigrated +# schema. Exec-form CMD wrapping an explicit shell (rather than bare shell +# form) so signals still reach the process directly. +CMD ["sh", "-c", "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-8000}"] diff --git a/README.md b/README.md index b3a704ce..2530313f 100644 --- a/README.md +++ b/README.md @@ -1,485 +1,255 @@

- PARTHA — Engineering Intelligence Platform + PARTHA — Repository Intelligence Platform

- PARTHA logo -

- -

PARTHA

- -

- Engineering Intelligence Platform -

- -

- Transform repositories into actionable engineering intelligence. -
- Understand systems, assess change impact, and make engineering decisions with confidence. -

- -

- Quick Start + Try the workflow + · + Direction + · + What works · - Capabilities + How it works · - Architecture + Run locally + · + Limitations · Docs · - Contributing + Discord

- License - Python 3.12+ - FastAPI - React - TypeScript - Docker Compose + Apache 2.0 license + Python 3.12–3.13 + Node.js 22 + Discord

---- +**PARTHA turns a repository revision into one sealed, queryable model and uses it to explain architecture, dependencies, review findings, insights, and documentation without letting each feature invent its own interpretation.** -## Why PARTHA Exists +> **PARTHA runs locally today, with its flagship workflow usable end to end.** It is not yet a hardened shared hosted service — see [Limitations and security](#limitations-and-security) before any shared deployment. -Modern software systems do not fail because engineers lack files. They fail because repository knowledge is fragmented. +PARTHA is for technical founders, staff and platform engineers, and engineering leads who need an inspectable starting point for understanding a codebase they did not write—or no longer fully trust their mental model of. -Architecture lives in code paths, dependency manifests, framework conventions, deployment files, API routes, tests, docs, and local team memory. As repositories grow, teams repeatedly ask the same questions: +## What PARTHA is aiming to become -- Where does this system start? -- Which modules own which responsibilities? -- Which dependencies matter? -- What files should a new contributor read first? -- What does this architecture imply for the next change? -- Where is the technical debt, and what evidence supports it? -- How can AI answer questions without guessing from raw files? +PARTHA is being built toward a private, versioned intelligence layer for software repositories. It should help people understand unfamiliar code faster and give AI a trusted, governed context instead of making it repeatedly reread the entire codebase. -PARTHA exists to turn software repositories into reusable engineering knowledge. +Today, PARTHA analyzes a supported repository revision and produces an immutable `ri.v1` intelligence snapshot. Repeated imports of the same repository are grouped into a durable lineage, exposed at `GET /repositories/{id}/lineage` and shown on the repository detail page. Over time, the project is intended to add refresh and cross-revision comparison on top of that lineage so its understanding can remain current as the codebase evolves. -The key idea is simple: +PARTHA can be self-hosted, and provider-backed AI is optional. AI consumes PARTHA's repository intelligence; it is not the independent source of truth. -> Analyse a repository once, build a repository intelligence layer, and let every product surface consume the same source of truth. +## From scattered code to shared understanding -PARTHA is not an AI chatbot, a repository summarizer, or a documentation generator. Those are surfaces. The platform is an Engineering Intelligence system built on Repository Intelligence. +Understanding an unfamiliar or fast-moving codebase usually means reconstructing the same facts repeatedly: entry points from folders, dependencies from manifests, boundaries from imports, and risk from partial tooling. Documentation, static analysis, and AI can each build a different private interpretation—and those interpretations drift. ---- +PARTHA takes a different approach. A bounded extraction pipeline turns the selected repository revision into a persistent Repository Intelligence snapshot. Architecture, Dependency Graph, Engineering Review, Insights, Documentation, exports, and optional AI all consume that shared model. -## Vision +The result is a codebase view that is: -PARTHA’s long-term direction is Engineering Decision Intelligence. +- **consistent across surfaces** — one stored fact model, not a parser per feature; +- **revision-bound** — a Git commit or uploaded-archive hash identifies the source; +- **inspectable** — supported facts carry extractor and source evidence; +- **honest about gaps** — unavailable or uncomputed assessments stay explicit. -```mermaid -flowchart LR - A[Repository Intelligence] --> B[Architecture Intelligence] - B --> C[Engineering Intelligence] - C --> D[Software Change Intelligence] - D --> E[Engineering Decision Intelligence] -``` +## Try the core workflow -Today, PARTHA focuses on the first layers: importing repositories, deriving reusable repository facts, generating architecture/dependency/review/documentation views, exporting reports, and grounding AI providers in repository context. +For the shortest path to value, [run PARTHA locally](#run-partha-locally) and use a real repository with Python or TypeScript/JavaScript code. -The goal is not to replace engineers. The goal is to make the engineering context visible, consistent, and reviewable. +1. **Add a repository.** Upload a ZIP, TAR, TAR.GZ, or TGZ archive, or import a **public GitHub** repository over HTTPS. +2. **Run analysis.** PARTHA starts a durable, cancellable background job and seals a snapshot for that exact repository revision. +3. **Inspect the codebase from several angles.** + - **Architecture** maps snapshot-backed modules and relationships in an interactive graph. + - **Dependency Graph** inventories supported direct declarations and their manifest locations. + - **Engineering Review** publishes only findings supported by stored evidence and keeps unassessed categories visible. + - **Insights** reports defined snapshot-local counts, ratios, diagnostics, languages, and extractor coverage. + - **Evidence Explorer** opens supported findings at the verified source span. +4. **Share the result.** Generate structural documentation or export Review, Documentation, Architecture, and Dependencies as JSON, Markdown, HTML, or PDF. ---- +Across those surfaces, PARTHA keeps the repository revision, snapshot identity, and canonical graph hash aligned. A missing or stale snapshot produces an unavailable state instead of silently falling back to another interpretation. -## Core Capabilities +## What works today -PARTHA is organised around engineering capabilities rather than individual screens. +Statuses describe executable behaviour on the current `dev` branch: -| Capability | Current implementation | Status | + +| Capability | Status | Current boundary | | --- | --- | --- | -| Repository Intelligence | Imports uploaded archives or public GitHub repositories, builds file trees, metadata, discovery facts, source-file intelligence, modules, dependencies, and a serialized knowledge graph. | Implemented / expanding | -| Architecture Intelligence | Builds an architecture model from repository intelligence, including modules, layers, relationships, summaries, request-flow hints, and frontend graph exploration. | Implemented heuristically | -| Dependency Intelligence | Reads dependency manifests through the Repository Intelligence Engine and returns dependency inventory/graph responses. Vulnerability and outdated checks are not implemented yet. | Partial | -| Documentation Intelligence | Generates Markdown or HTML documentation from repository facts, architecture, dependency, deployment, environment, and contribution signals. | Implemented / basic | -| Engineering Reviews | Produces heuristic findings, category scores, evidence-backed affected files/modules, and roadmap suggestions from repository intelligence. | Implemented heuristically | -| AI Workspace | Lets configured AI providers answer repository-aware questions from structured repository context. Providers do not parse repositories directly. | Implemented | -| Reports and Exports | Exports engineering review, documentation, architecture, and dependencies as JSON, Markdown, HTML, or PDF through a shared report pipeline. | Implemented | -| Platform Foundation | Provides Docker Compose, CI, release workflow, health/readiness, request IDs, metrics, structured logging, and environment validation. | Implemented baseline | - -PARTHA intentionally avoids claiming deeper capabilities before they exist. Change-impact analysis, richer semantic graphs, vulnerability scanning, authentication, and multi-user deployment controls are roadmap items. - ---- - -## System Overview - -```mermaid -flowchart TD - Repo[Repository
Upload or GitHub import] - Parser[Repository Parser
file tree + metadata] - RIE[Repository Intelligence Engine
facts + modules + dependencies + graph] - Models[Knowledge Models
architecture + dependencies + review + docs] - AI[AI Workspace
provider-grounded context] - Reports[Engineering Outputs
UI views + exports + reports] - - Repo --> Parser - Parser --> RIE - RIE --> Models - Models --> Reports - RIE --> AI - AI --> Reports -``` - -Repository Intelligence is the source of truth. Downstream features consume it; they should not re-parse repositories independently. - ---- - -## Architecture - -PARTHA is a monorepo with a React frontend and FastAPI backend. +| Archive upload and public GitHub import | **Implemented** | ZIP/TAR-family archives and shallow public GitHub HTTPS clones; size and path-safety limits apply. Private GitHub cloning and other repository hosts are not supported. | +| Repository explorer | **Implemented** | Owner-scoped file tree plus bounded text/image preview, binary detection, and truncation. | +| Authentication and owner isolation | **Implemented** | Email/password, Argon2, short-lived access tokens, rotating refresh tokens with reuse detection. Protected resources are owner-scoped; non-owner access returns 404. | +| Analysis lifecycle | **Implemented** | Database-backed, cancellable job with progress, bounded retry, lease renewal, and stale-worker recovery. | +| Repository Intelligence | **Implemented with disclosed limits** | Immutable, revision-addressed `ri.v1` snapshots with normalized facts, evidence, query APIs, and a total canonical graph hash. Semantic extraction is strongest for supported Python and TypeScript/JavaScript constructs. | +| Repository lineage | **Implemented with disclosed limits** | Repeated imports of the same repository and branch are grouped into a durable, owner-scoped lineage with duplicate-revision detection (RFC-0002). `GET /repositories/{id}/lineage` returns the ordered history and the repository detail page renders it. Refresh and cross-revision comparison on top of a lineage are not built. | +| Architecture and authentication explanation | **Implemented with disclosed limits** | Interactive snapshot-backed graph. Module/layer classification is heuristic. The cited authentication subgraph covers supported Python/FastAPI patterns only. | +| Dependency Graph | **Implemented with disclosed limits** | Direct declarations from `package.json`, `pyproject.toml`, and `requirements.txt` plus resolved pins from `package-lock.json` and `poetry.lock`, merged onto one dependency identity with repeated workspace declarations and exact spans. A lockfile pin is recorded as a resolution, never as a direct dependency edge, so transitive resolution is still not claimed. | +| Service-interaction discovery | **Implemented with disclosed limits** | Outbound HTTP call sites on `requests`, `httpx`, `fetch`, and `axios` resolve to a service node identified by its absolute origin, with the literal method and path on the call's own observation. A computed, relative, or shadowed destination is a diagnostic, never an edge. | +| Infrastructure-as-code resources | **Implemented with disclosed limits** | Declared Docker Compose services, volumes, and networks with their exact declaration spans. Templated values are disclosed rather than reported as observed, and no other IaC format is read. | +| Engineering Review | **Implemented with disclosed limits** | `engineering-review.v2`; evidence-addressed findings and explicit category states. No overall score, grade, health percentage, vulnerability result, or generated roadmap. | +| Repository Insights | **Implemented with disclosed limits** | `repository-insights.v1`; defined counts, ratios, diagnostics, language breakdowns, and extraction coverage from one snapshot. No change-over-time claims. | +| Documentation and report export | **Implemented with disclosed limits** | Documentation uses current-revision structural facts. Review, Documentation, Architecture, and Dependencies export through one JSON/Markdown/HTML/PDF pipeline. | +| AI provider integration | **Implemented with disclosed limits** | Per-user configuration for supported providers, encrypted API keys, and constrained outbound destinations. Free-form answers receive structural facts and observed paths—not source bytes or line spans—and return no automatic citations. | +| Asynchronous processing | **Implemented with disclosed limits** | Analysis runs off the request path. Import, extraction of the initial archive/clone, and file-tree parsing remain synchronous; one in-process worker handles analysis jobs. | +| Incremental re-analysis and revision comparison | **Planned** | The full repository is analysed again; no snapshot-to-snapshot product workflow is available. | +| Change-impact or blast-radius analysis | **Implemented with disclosed limits** | Owner-scoped traversal over one sealed snapshot's resolved import and dependency edges. It does not compare revisions or calculate churn or trends. | +| Vulnerability and outdated-dependency scanning | **Planned** | Dependency responses report explicit `not_computed` states; Review keeps vulnerability scanning `not_assessed`. No clean bill of health or zero count is fabricated. | +| Grounded, cited free-form AI answers | **Planned** | Provider answers are intentionally uncited because providers do not receive source content or line numbers. | + +**Implemented with disclosed limits** means the workflow exists with an explicit coverage or trust boundary. **Planned** means it is roadmap work and current responses do not manufacture an answer. **Rejected** means the capability is intentionally outside the product contract. + + +## One repository model, many consumers + +Repository Intelligence is PARTHA's single repository-understanding boundary. Repository source enters one bounded import and extraction path; product consumers query the resulting sealed snapshot rather than opening files or constructing parallel facts. + +`ri.v1` is PARTHA's versioned, sealed Repository Intelligence snapshot: the product's single read model. Each immutable snapshot describes one repository at one exact revision and is identified by `repository_id`, `revision`, `schema_version`, `producer_version_set`, and `config_hash`; every fact carries a truth class and, where the contract requires it, provenance tied to an exact source location in that stored revision. Architecture, Dependency Graph, Review, Insights, Documentation, Exports, and AI consume the sealed snapshot instead of re-parsing repository files; if the current-revision snapshot is missing or stale, PARTHA reports it as unavailable rather than falling back to a parallel interpretation. The governing contract is the accepted [RFC-0001](docs/architecture/REPOSITORY_INTELLIGENCE_V1_RFC.md). ```mermaid flowchart LR - UI[Frontend
React + Vite + TypeScript] - API[REST API
FastAPI] - Services[Application Services] - Intelligence[Repository Intelligence Engine] - Storage[(SQLite / PostgreSQL
Local Storage)] - AI[AI Provider Layer] - Reports[Report Export Pipeline] - - UI --> API - API --> Services - Services --> Intelligence - Services --> Reports - Services --> AI - Intelligence --> Storage - Reports --> UI - AI --> UI + Input["Repository input
archive · public GitHub"] + Import["Import
safe storage · revision identity · file inventory"] + Analyse["Durable analysis
Python · TypeScript/JavaScript · manifests
lockfiles · service interactions · Docker Compose"] + RI[("Sealed ri.v1 snapshot
facts · evidence · diagnostics · canonical hash")] + Product["Architecture · Dependencies · Review
Insights · Documentation · Exports"] + AI["AI provider
optional · structural context only"] + + Input --> Import --> Analyse --> RI --> Product + RI -.-> AI ``` -### Subsystems - -| Subsystem | Responsibility | -| --- | --- | -| Frontend | App shell, repository dashboard, upload/import, explorer, architecture graph, dependency view, review workspace, documentation workspace, AI workspace, settings, and export actions. | -| Backend API | Stable HTTP boundary for repositories, analysis, documentation, export, AI, health, readiness, and metrics. | -| Repository Service | Imports repositories, stores records, exposes file tree and safe file-preview access. | -| Repository Intelligence Engine | Builds reusable repository facts from parser output and source files. | -| Architecture Service | Converts repository intelligence into architecture graph response models. | -| Dependency Service | Converts repository intelligence dependencies into dependency graph response models. | -| Documentation Service | Generates documentation documents from existing repository intelligence and analysis outputs. | -| Engineering Review Service | Produces heuristic findings, scores, and roadmap suggestions from repository intelligence. | -| AI Layer | Builds structured repository context, prompt bundles, and provider-agnostic orchestration. | -| Provider Layer | Dedicated provider implementations for OpenAI, Anthropic, Gemini, OpenRouter, and Ollama. | -| Export Pipeline | Uses `ReportDocument` as an intermediate representation and renders JSON, Markdown, HTML, or PDF. | -| Storage | Uses SQLite by default for local development, PostgreSQL in Docker Compose, and local filesystem storage for repositories/uploads. | -| Platform Foundation | CI, Docker Compose validation, release workflow, health/readiness, request IDs, metrics, and structured logs. | - -### AI Architecture Boundary - -AI providers are consumers of Repository Intelligence, not producers of it. +The architectural rule is deliberately strict: -```mermaid -flowchart TD - RIE[Repository Intelligence] - Context[Repository Context Builder] - Prompt[Prompt Builder] - Orchestrator[AI Orchestrator] - Factory[Provider Factory] - Providers[OpenAI / Anthropic / Gemini / OpenRouter / Ollama] - - RIE --> Context - Context --> Prompt - Prompt --> Orchestrator - Orchestrator --> Factory - Factory --> Providers -``` +> If a feature needs a repository fact, it belongs in the shared engine: extractors in `apps/backend/app/extraction/`, resolution and the sealed read model in `apps/backend/app/intelligence/`. A consumer must never build a second parser. AI is an optional downstream consumer of Repository Intelligence, never an independent interpreter of the repository. -Provider implementations own HTTP requests, authentication, response parsing, and error normalization. They do not read repository files or rebuild analysis. +### Evidence, provenance, and integrity -### Export Architecture Boundary +PARTHA separates three ideas that are often blurred together: -```mermaid -flowchart LR - Route[Export Route] --> Service[ExportService] - Service --> Data[Existing Analysis / Documentation Output] - Data --> Document[ReportDocument] - Document --> Renderers[Markdown / HTML / PDF] - Data --> Json[JSON Export] -``` +- **Evidence** is the stored source artifact supporting a fact, such as a file, declaration, import, route, or configuration entry. +- **Provenance** records where a supported fact came from: revision, path, line span, extractor, and fact identity. +- **Integrity** is represented by the snapshot's canonical graph hash and revision manifest. The digest detects content differences inside this deployment; it is **not** a digital signature or proof of authorship. -The export pipeline consumes existing analysis output. It does not re-analyse repositories. +Coverage is surface-dependent. Supported extractors produce validated one-based inclusive line spans for Python and TypeScript/JavaScript facts; column-level evidence is not provided. Dependency declarations, the authentication explanation, and Review findings expose targeted evidence. Documentation uses structural facts, and free-form AI receives no source bytes or line numbers, so its prose has no automatic citations. ---- +Read [Repository Intelligence](docs/architecture/REPOSITORY_INTELLIGENCE.md) for the complete extraction boundary and [System Overview](docs/architecture/SYSTEM_OVERVIEW.md) for the runtime architecture. -## Repository Workflow +## Run PARTHA locally -```mermaid -sequenceDiagram - participant User - participant UI as Frontend - participant API as FastAPI - participant Repo as Repository Service - participant RIE as Repository Intelligence Engine - participant Views as Intelligence Consumers - - User->>UI: Upload archive or import GitHub URL - UI->>API: POST /repositories/upload or /repositories/github - API->>Repo: Store repository and parse file tree - Repo->>RIE: Build repository intelligence - RIE-->>Repo: Persist intelligence in repository metadata - UI->>API: POST /analysis/{id}/start - API->>Views: Build architecture/dependencies/review/docs - Views-->>UI: Engineering intelligence surfaces -``` - ---- - -## Screenshots - -Screenshots will be added when the public demo UI is ready. - -| Surface | Placeholder | -| --- | --- | -| Repository Dashboard | Upload and inspect repositories. | -| Architecture Intelligence | Explore modules, layers, relationships, and request-flow hints. | -| Engineering Review | Review findings, scores, evidence, and roadmap suggestions. | -| AI Workspace | Ask repository-grounded questions through configured providers. | -| Reports | Export architecture, dependency, documentation, and review artifacts. | - ---- +### Prerequisites -## Quick Start +| Tool | Version | Needed for | +| --- | --- | --- | +| Python | 3.12 or 3.13 | Backend | +| Node.js | 22 | Frontend and workflow scripts | +| Git | Recent version | Checkout and public GitHub import | -### Prerequisites +PARTHA currently uses separate backend and frontend development processes. The +development configuration uses SQLite, an in-memory rate limiter, and local +filesystem storage. No container runtime or external database is required. -| Tool | Version | -| --- | --- | -| Node.js | 22 or newer recommended | -| npm | Bundled with Node.js | -| Python | 3.12 or 3.13 | -| Docker | Required for Compose-based PostgreSQL/Redis workflow | -| Git | Required for GitHub repository import | +### 1. Start the backend -### Clone and Install +No `.env` file is required. ```bash git clone https://github.com/Second-Origin/PARTHA.git cd PARTHA -npm ci --prefix apps/frontend + cd apps/backend python3.13 -m venv .venv source .venv/bin/activate pip install -e . cd ../.. -``` - -### Configure Environment - -```bash -cp .env.example .env -cp apps/frontend/.env.example apps/frontend/.env -cp apps/backend/.env.example apps/backend/.env -``` - -The backend local example uses SQLite and local filesystem storage so direct local startup works without PostgreSQL or Redis. Docker Compose injects container-specific PostgreSQL, Redis, and storage values. -### Run Locally - -```bash npm run dev:backend -npm run dev:frontend ``` -Open: +The API starts at `http://localhost:8000`; OpenAPI is at `/docs` and readiness is at `/ready`. -- frontend: `http://localhost:5173` -- backend docs: `http://localhost:8000/docs` -- readiness: `http://localhost:8000/ready` -- metrics: `http://localhost:8000/metrics` +### 2. Start the frontend ---- - -## Local Development - -Common commands: +In a second terminal: ```bash +cd PARTHA +npm ci --prefix apps/frontend npm run dev:frontend -npm run dev:backend -npm run lint:frontend -npm run build:frontend -npm run test:backend -npm run build ``` -Backend helper scripts prefer `apps/backend/.venv` when present and fall back to `python`. +Open `http://localhost:5173`, register a local account, add a repository, and +start analysis. ---- +See the [AI provider egress policy](docs/security/AI_PROVIDER_EGRESS.md) before +configuring any custom or local provider endpoint. -## Docker +For running every test/lint/build command and fixing the local database or +API-contract failures you're most likely to hit, see +[Local development and troubleshooting](docs/DEVELOPMENT.md). -Validate the Docker Compose configuration: +## Verification -```bash -npm run docker:config -``` - -Run Compose with runtime readiness validation: +Run checks relevant to your change: ```bash -npm run docker:validate -``` - -Start the Compose stack for local infrastructure: +# Backend (avoids an inherited PYTHONPATH selecting the wrong environment) +cd apps/backend +PYTHONPATH= .venv/bin/python -m pytest +cd ../.. -```bash -npm run docker:up -``` +# Frontend +npm --prefix apps/frontend run test +npm run lint:frontend +npm run build:frontend -The Compose stack runs: - -| Service | Port | -| --- | --- | -| API | `8000` | -| PostgreSQL | `5432` | -| Redis | `6379` | - -Run the frontend separately with `npm run dev:frontend`. - ---- - -## Technology Decisions - -PARTHA uses pragmatic, inspectable tools rather than opaque infrastructure. - -| Decision | Why | -| --- | --- | -| React + Vite + TypeScript | Fast local iteration, typed frontend contracts, and a clean app/feature/shared structure. | -| FastAPI + Pydantic | Explicit request/response schemas, OpenAPI generation, and straightforward service boundaries. | -| SQLAlchemy + Alembic | Portable persistence across SQLite local development and PostgreSQL-backed deployments. | -| Repository Intelligence Engine | Centralizes repository facts so architecture, docs, reviews, exports, and AI do not drift. | -| Tree-sitter foundation | Provides a path toward deeper language-aware extraction while preserving heuristic fallbacks. | -| Provider abstraction | Keeps AI orchestration provider-agnostic and prevents providers from parsing repositories. | -| ReportDocument pipeline | Separates report construction from rendering so new export formats can be added safely. | -| Docker Compose | Provides reproducible local backend infrastructure without requiring production hosting decisions. | - ---- - -## Project Structure - -```text -PARTHA/ -├── apps/ -│ ├── backend/ FastAPI backend -│ └── frontend/ React frontend -├── docs/ -│ ├── architecture/ System and subsystem architecture docs -│ ├── assets/ Public README and brand assets -│ ├── audit/ Engineering audit records -│ ├── brand/ Visual identity guidance -│ ├── operations/ Deployment, observability, release, dependencies -│ └── product/ Product/public-face audits -├── packages/ Reserved for future shared packages -├── scripts/ Local workflow helpers -├── docker-compose.yml Local API/PostgreSQL/Redis stack -├── package.json Root workspace scripts -└── CONTRIBUTING.md Contributor guide +# Disposable fixtures and browser journeys +npm run test:e2e ``` -Backend responsibilities: - -| Path | Responsibility | -| --- | --- | -| `app/api/` | Routes and dependency wiring. | -| `app/services/` | Application services. | -| `app/intelligence/` | Repository Intelligence Engine and models. | -| `app/analysis/` | Architecture modelling. | -| `app/graph/` | Dependency graph construction. | -| `app/review/` | Engineering review generation. | -| `app/ai/` | AI orchestration, context, prompts, and providers. | -| `app/reports/` | Report document model, builders, renderers, export service. | -| `app/storage/` | Local repository/upload storage. | - -Frontend responsibilities: - -| Path | Responsibility | -| --- | --- | -| `src/app/` | App shell, routes, pages, and global store. | -| `src/features/` | Domain-specific hooks, state, and components. | -| `src/shared/` | Reusable UI, API clients, config, hooks, types, and utilities. | -| `src/styles/` | Global styling and design tokens. | - ---- - -## Documentation - -Start here: - -| Document | Purpose | -| --- | --- | -| `docs/README.md` | Documentation index. | -| `docs/product/PUBLIC_FACE_AUDIT.md` | Repository, documentation, and positioning audit for this public-face redesign. | -| `docs/brand/VISUAL_IDENTITY.md` | Visual identity, colors, logo, diagrams, and documentation style. | -| `docs/architecture/REPOSITORY_INTELLIGENCE_ENGINE.md` | Repository Intelligence Engine architecture and boundaries. | -| `docs/architecture/AI_ARCHITECTURE.md` | AI architecture, provider layer, context, and prompt flow. | -| `docs/operations/production-deployment.md` | Production deployment baseline and operational limits. | -| `docs/operations/observability.md` | Request IDs, logs, redaction, metrics, readiness. | -| `docs/operations/release-management.md` | Versioning and release workflow. | -| `docs/operations/dependency-management.md` | Dependency maintenance policy. | - ---- +The browser acceptance suite exercises defined Architecture, Engineering Review, Insights, evidence, and responsive-accessibility journeys. Passing it verifies those journeys; it does not imply complete product coverage. -## Roadmap +## Limitations and security -### Current Milestone +### Product limitations -Vrrently building the foundation for Repository Intelligence and Engineering Intelligence: +- **Trusted-environment use.** PARTHA has not been operated or hardened for broad shared hosting. +- **Narrow semantic coverage.** The capability registry declares the Python and TypeScript/JavaScript constructs that receive the deepest extraction. Other languages primarily contribute file inventory. Role, module, layer, framework, and entry-point classifications can be heuristic. +- **Narrow dependency coverage.** Direct declarations are extracted from three manifest formats, and exact pins from two lockfile formats (`package-lock.json`, `poetry.lock`) are recorded as resolutions on the same dependency identity. A pin is never promoted to a direct dependency edge, so transitive resolution is not claimed. Vulnerability scanning and outdated-version scanning are not implemented. +- **No repository evolution workflow.** Analysis is whole-repository; incremental analysis, revision comparison, and churn/trend analysis are unavailable. The sealed-snapshot impact query does not compare revisions or calculate historical change. +- **Surface-dependent evidence.** A sealed snapshot does not make every product sentence line-cited. In particular, generated structural documentation and free-form AI have stricter evidence limits. +- **In-process execution.** A daemon worker thread inside the API process handles one analysis job at a time; there is no separate worker service or external job queue. -- repository ingestion and safe file preview; -- reusable repository intelligence; -- architecture/dependency/review/documentation consumers; -- AI provider integration grounded in repository context; -- report exports; -- operational baseline for local and controlled deployments. +### Security guidance -### Future Milestones +All non-auth product routes require authentication, repository access is owner-scoped, provider API keys are Fernet-encrypted at rest, and AI egress is validated against a deployment-owned allowlist with DNS pinning. These controls are meaningful, but they are not a claim of production hardening. -| Milestone | Direction | -| --- | --- | -| Richer Repository Intelligence | Deeper symbol extraction, relationship detection, persisted graph artifacts, and language-aware analysis. | -| Software Change Intelligence | Impact analysis, affected modules, dependency-aware change planning, and review assistance. | -| Engineering Decision Intelligence | Decision support based on architecture, dependencies, risk, ownership, and repository history. | -| Production Multi-User Platform | Authentication, authorization, teams, retention policies, secret management, audit trails, and hosted deployment controls. | +Outside `development` and `test`, the backend requires: -### Current Non-Goals +- `AUTH_SECRET_KEY` with at least 32 characters; +- `AI_ENCRYPTION_KEY` containing a valid Fernet key; +- independent network egress controls for AI providers. -PARTHA does not yet provide public multi-user SaaS controls, vulnerability scanning, deep semantic change-impact analysis, or full OpenTelemetry tracing. +Registration is gated by an admin-managed email allowlist, in every environment. On a genuinely fresh instance — an empty database, nobody pre-approved — the first account anyone registers (password or OAuth) is auto-approved automatically and becomes that instance's owner; this is what lets a self-hoster actually use their own deployment. Every registration after that first one needs an existing account holder to approve the email first, with `apps/backend/scripts/approve_email.py`. ---- +Do not expose the development configuration directly to the public internet. Review [SECURITY.md](SECURITY.md) and the [AI provider egress policy](docs/security/AI_PROVIDER_EGRESS.md) before any shared deployment. Report vulnerabilities privately—never in a public issue. -## Contributing +## Documentation and contributing -PARTHA welcomes focused engineering contributions that preserve the Repository Intelligence boundary. +- [Documentation index](docs/README.md) — current public documentation and reading paths. +- [Local development and troubleshooting](docs/DEVELOPMENT.md) — start the stack, run every test/lint/build command, and fix the failures a new contributor is most likely to hit. +- [System Overview](docs/architecture/SYSTEM_OVERVIEW.md) — components, runtime flow, persistence, and trust boundaries. +- [Repository Intelligence](docs/architecture/REPOSITORY_INTELLIGENCE.md) — extraction, snapshot, consumer, and evidence rules. +- [Accepted `ri.v1` RFC](docs/architecture/REPOSITORY_INTELLIGENCE_V1_RFC.md) — the versioned snapshot contract. +- [Repository Lineage RFC](docs/architecture/REPOSITORY_LINEAGE_RFC.md) — accepted design (RFC-0002) for grouping repeated imports of the same repository into a durable lineage, implemented per [#299](https://github.com/Second-Origin/PARTHA/issues/299): the `repository_lineages` table, owner-scoped grouping, and duplicate-revision detection run on every import, with a read-only history at `GET /repositories/{id}/lineage` and on the repository detail page ([#400](https://github.com/Second-Origin/PARTHA/issues/400)). Refresh and cross-revision comparison on top of a lineage are not built. +- [Connecting an AI provider](docs/operations/AI_PROVIDER_SETUP.md) — the Settings and `ai/*` API setup path, per-provider requirements, the egress-policy prerequisite for a local Ollama endpoint, and Ollama's slow-first-request behaviour. +- [Backend guide](apps/backend/README.md) and [frontend guide](apps/frontend/README.md) — area-specific setup and commands. +- [CONTRIBUTING.md](CONTRIBUTING.md) — fork-first workflow, issue claiming, branch conventions, validation, and pull-request requirements. +- [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) — expected conduct. +- [Discord](https://discord.gg/qvk9DcxDA) — ask questions, follow progress, and talk with the community and maintainers. -Start with: - -- `CONTRIBUTING.md` for setup, branch strategy, issue workflow, pull request process, testing, and documentation standards. -- `docs/architecture/REPOSITORY_INTELLIGENCE_ENGINE.md` before changing analysis behavior. -- `docs/architecture/AI_ARCHITECTURE.md` before changing AI behavior. - -Key rule: - -> If a feature needs repository facts, add reusable extraction to Repository Intelligence first. Do not create a second parser inside a feature. - ---- - -## Project Identity - -PARTHA is the public product name. - -The internal expansion is: - -> Platform for Architecture, Repository Intelligence, Transformation & Heuristic Analysis - -The expansion explains the project origin, but the public brand should remain simple: **PARTHA — Engineering Intelligence Platform**. - ---- +Before changing analysis, parsing, or AI-grounding behaviour, read [Repository Intelligence](docs/architecture/REPOSITORY_INTELLIGENCE.md) in full. Current behaviour belongs in documentation; future work belongs in GitHub issues. ## License -PARTHA is licensed under the Apache License 2.0. - -See the [LICENSE](LICENSE) file for the full license text. +PARTHA is available under the [Apache License 2.0](LICENSE). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..874a8b84 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,57 @@ +# Security Policy + +## Supported Versions + +PARTHA is currently in active development. + +Security fixes are provided for the latest development branch (`dev`) and the latest stable release on `main`. + +| Version | Supported | +|---------|-----------| +| Latest `dev` | ✅ | +| Latest release | ✅ | +| Older releases | ❌ | + +--- + +## Reporting a Vulnerability + +If you discover a security vulnerability in PARTHA, please **do not create a public GitHub issue**. + +Instead, report it privately by emailing: + +**parthrohit60@gmail.com** + +Include, where possible: + +- A description of the vulnerability +- Steps to reproduce +- Potential impact +- Suggested remediation (if known) + +--- + +## Response Process + +We aim to: + +- Acknowledge reports within **72 hours** +- Investigate and validate the issue +- Develop and test a fix +- Publish a security patch when appropriate +- Credit the reporter (if they wish) + +--- + +## Security Practices + +PARTHA uses several automated security measures, including: + +- GitHub CodeQL static analysis +- Dependabot dependency monitoring +- GitHub Dependabot security advisories +- Required pull request reviews +- Protected branches +- Continuous Integration validation + +Security is considered throughout development, but no software is guaranteed to be free of vulnerabilities. Responsible disclosure is appreciated. diff --git a/apps/backend/.env.example b/apps/backend/.env.example index a87ba508..655e2958 100644 --- a/apps/backend/.env.example +++ b/apps/backend/.env.example @@ -6,3 +6,30 @@ REDIS_URL=redis://localhost:6379/0 STORAGE_PATH=./.local/storage CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173 AUTO_CREATE_TABLES=true +# Per-analysis fail-closed budgets. Source bytes cover the complete persisted +# repository manifest; RSS and duration protect the shared API/worker process. +ANALYSIS_MAX_REPOSITORY_SOURCE_BYTES=1073741824 +ANALYSIS_MAX_PROCESS_RSS_BYTES=2147483648 +ANALYSIS_MAX_DURATION_SECONDS=1800 +# Required outside development/test. Generate with: python -c "import secrets; print(secrets.token_urlsafe(64))" +AUTH_SECRET_KEY= +ACCESS_TOKEN_TTL_SECONDS=900 +REFRESH_TOKEN_TTL_SECONDS=1209600 +# Fernet key that encrypts each user's AI provider API key at rest. Required +# outside development/test. Generate with: +# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" +AI_ENCRYPTION_KEY= +# Provider egress is fail-safe by default. Built-in cloud providers use only +# their fixed HTTPS origins. A local/internal Ollama endpoint requires explicit +# administrator approval in both lists; ordinary tenants cannot change these. +AI_EGRESS_MODE=hosted +AI_EGRESS_ALLOWED_BASE_URLS= +AI_EGRESS_ALLOWED_CIDRS= +# Rate limiting: fixed-window budgets per client (per minute). Backend +# "memory" is per-process; use "redis" when running multiple workers. +RATE_LIMIT_ENABLED=true +RATE_LIMIT_BACKEND=memory +RATE_LIMIT_DEFAULT_PER_MINUTE=120 +RATE_LIMIT_AUTH_PER_MINUTE=10 +RATE_LIMIT_AI_PER_MINUTE=20 +RATE_LIMIT_HEAVY_PER_MINUTE=30 diff --git a/apps/backend/.gitattributes b/apps/backend/.gitattributes new file mode 100644 index 00000000..858270cb --- /dev/null +++ b/apps/backend/.gitattributes @@ -0,0 +1 @@ +tests/benchmark/fixtures/adversarial/adv-source-edgecases/src/large.py text eol=lf diff --git a/apps/backend/Dockerfile b/apps/backend/Dockerfile deleted file mode 100644 index e8faf5f7..00000000 --- a/apps/backend/Dockerfile +++ /dev/null @@ -1,22 +0,0 @@ -FROM python:3.12-slim - -ENV PYTHONDONTWRITEBYTECODE=1 \ - PYTHONUNBUFFERED=1 - -WORKDIR /app - -RUN apt-get update \ - && apt-get install -y --no-install-recommends git build-essential \ - && rm -rf /var/lib/apt/lists/* - -COPY pyproject.toml ./ -COPY app ./app -COPY alembic.ini ./ -COPY alembic ./alembic - -RUN pip install --no-cache-dir --upgrade pip \ - && pip install --no-cache-dir -e . - -EXPOSE 8000 - -CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/apps/backend/README.md b/apps/backend/README.md index 7ad6b2bc..e75400a6 100644 --- a/apps/backend/README.md +++ b/apps/backend/README.md @@ -1,44 +1,295 @@ # PARTHA Backend -FastAPI backend for repository ingestion, parsing, architecture analysis, dependency graphing, and engineering review. +FastAPI backend for repository ingestion, Repository Intelligence, architecture and dependency analysis, engineering review, documentation generation, exports, and AI orchestration. -This app lives at `apps/backend` in the PARTHA monorepo. +All repository-derived product reads resolve an owner-scoped sealed `ri.v1` +snapshot matching the repository's current revision. Documentation, exports, +and free-form AI context do not read legacy JSON or rebuild from repository +files. A missing or stale snapshot is unavailable (404), with no fallback. +Historical `repo_metadata["intelligence"]` values may remain stored but are +ignored. Free-form AI additionally requires a configured provider and receives +no source-file contents. -## Local Development +This app lives at `apps/backend` in the PARTHA monorepo. For contributor workflow and engineering rules, see the root [CONTRIBUTING.md](../../CONTRIBUTING.md). For what the engine actually extracts, see [Repository Intelligence](../../docs/architecture/REPOSITORY_INTELLIGENCE.md). For a full local-setup walkthrough and troubleshooting, see [docs/DEVELOPMENT.md](../../docs/DEVELOPMENT.md). + +## Local development + +Requires Python 3.12 or 3.13 (`>=3.12,<3.14`). ```bash cd apps/backend -python3.12 -m venv .venv +python3.13 -m venv .venv source .venv/bin/activate pip install -e . -python -m uvicorn app.main:app --reload +python -m uvicorn app.main:app --reload --reload-dir app ``` -By default, local development uses SQLite at `.local/partha.db` and storage at `.local/storage` so the app can start without services. Docker Compose injects PostgreSQL, Redis, and container storage settings separately. +Or from the repository root: `npm run dev:backend` (prefers `apps/backend/.venv`, falls back to `python`). -Useful system endpoints: +`--reload-dir app` restricts the reload watcher to backend source. Without it, uvicorn watches the whole `apps/backend` working directory, including `.local/storage` — the local filesystem storage the app itself writes to during ingestion and analysis — so an in-progress analysis job's own writes could trigger a server restart and drop open requests (#161). Migration files under `alembic/` are applied with an explicit `alembic upgrade` command, not hot-reloaded, so they are intentionally not watched. -| Endpoint | Purpose | -| --- | --- | -| `GET /health` | Lightweight liveness check with the current environment label. | -| `GET /ready` | Readiness check for database connectivity and configured storage writability. | -| `GET /metrics` | Plain-text runtime counters for request volume, status families, routes, and cumulative duration. | +Local development defaults to SQLite at `.local/partha.db` and storage at `.local/storage`, so the app starts with no PostgreSQL and no Redis. **No `.env` file is required** — every setting has a working default. Copy `.env.example` to `.env` only to change one. + +### SQLite concurrency (development only) -Backend logs default to human-readable text. Set `LOG_FORMAT=json` for structured logs in containers or hosted environments. Every request receives an `X-Request-ID` response header; pass `X-Request-ID` on inbound requests to preserve an upstream trace identifier. +The durable analysis worker (a background thread) and API request handlers read and write the same SQLite file concurrently. Every SQLite connection is opened with `PRAGMA journal_mode=WAL` and a 5-second `PRAGMA busy_timeout` (`app/core/database.py`, a no-op on PostgreSQL): WAL lets a reader always see the last committed snapshot without waiting on an in-progress writer, and the busy timeout bounds the remaining writer-vs-writer wait instead of failing immediately (#162). This does not extend to multiple *processes* sharing one SQLite file; multi-process deployments should use PostgreSQL. The analysis worker's own per-stage transaction boundaries (why a stage's facts are flushed but not committed until the stage checkpoint) are documented directly in `app/workers/analysis_worker.py`'s module docstring and were deliberately left unchanged — restructuring them risks the job-recovery guarantees (leases, retries, stale-worker takeover) that same docstring exists to protect, and WAL removes the actual reader-blocking symptom without needing to. -## Docker +`AUTH_SECRET_KEY` falls back to a fixed insecure value when `APP_ENV` is `development` or `test`. Outside those environments the app **refuses to start** without an explicit secret of at least 32 characters: ```bash -cd ../.. -docker compose up --build +python -c "import secrets; print(secrets.token_urlsafe(64))" ``` -Swagger UI is available at `http://localhost:8000/docs`. +## Tests + +```bash +python -m pytest # from apps/backend +npm run test:backend # from the repository root +``` + +Tests use per-test SQLite and the in-memory rate limiter by default. When `PARTHA_TEST_PG_URL` is set, the migration round trip runs against a fresh temporary PostgreSQL database and the PostgreSQL refresh-token concurrency test is enabled; without it, migrations fall back to SQLite and the concurrency test skips. Redis integration tests skip unless `PARTHA_TEST_REDIS_URL` is set. CI provides both services. + +## Static analysis + +The runtime lockfile deliberately excludes development tooling. Install the +pinned development toolchain before running the backend static-analysis gates: + +```bash +cd apps/backend +python -m pip install -r requirements-dev.txt +python -m pip install -e . --no-deps +ruff check app +ruff format --check app +mypy app +``` + +Ruff targets the production package and Python 3.12, the project's minimum +supported Python version. Run `ruff format app` to apply the repository's +formatter, then review the resulting diff before committing. + +## Migrations + +```bash +alembic upgrade head +alembic downgrade -1 +``` + +`AUTO_CREATE_TABLES` defaults to true in `development`/`test` and false elsewhere, so non-dev environments rely on migrations rather than `create_all`. Every migration must downgrade cleanly — `tests/test_migrations.py` enforces it. + +Before a schema release, run the disposable [migration rehearsal](../../docs/operations/DATABASE_MIGRATION_REHEARSAL.md): + +```bash +python scripts/rehearse_migrations.py +``` + +This validates the clean chain and the supported `0004_ai_provider_configs` baseline without touching the local or configured application database. It does not make every downgrade a data-preserving production rollback; the runbook defines the backup/restore decision path. + +### Local database schema drift (development/test only) + +`create_all` creates any table missing from the database but never alters an existing one and never advances the `alembic_version` stamp, so an existing local database can silently drift from the code after a schema-changing merge — without a check, that surfaces later as an opaque `IntegrityError`/`OperationalError` on whatever request happens to touch the drifted column or table, not as a clear migration error. + +To prevent that, startup in `development`/`test` compares the database's Alembic revision against head (`app/core/schema_sync.py`, wired into `app.main`'s lifespan): + +- **Up to date** — no action. +- **Behind head, no physical conflict** — upgrades the database automatically (`alembic upgrade head`, run through the same in-process API `tests/test_migrations.py` uses, not the CLI) and logs exactly what it did. This is the common case after pulling a schema-changing merge. +- **Behind head, but a pending migration's table already exists physically** — refuses to start rather than attempt an upgrade that would crash with "table already exists". This happens when `AUTO_CREATE_TABLES` built a table without ever advancing the stamp. The startup error names the conflicting table(s) and the exact recovery: + + ```bash + cd apps/backend && .venv/bin/alembic stamp # mark migrations already reflected physically as applied + cd apps/backend && .venv/bin/alembic upgrade head # apply whatever genuinely remains pending + ``` + +- **A brand-new, empty database** — `create_all` builds every table directly from the current models (by definition already head's shape), then the database is stamped at head directly; no migration body runs and no drift check is needed. + +Production/staging are unaffected: this check is a no-op outside `development`/`test`, so those environments keep relying on an operator running migrations explicitly. + +## System endpoints + +| Endpoint | Purpose | +| --- | --- | +| `GET /health` | Process liveness, with the current environment label. | +| `GET /ready` | Readiness: database connectivity and writable storage. Returns 503 when a check fails. | +| `GET /metrics` | Plain-text counters: request volume, status families, routes, cumulative duration, rate-limit counters. | +| `GET /docs` | OpenAPI / Swagger UI. | + +Logs default to human-readable text; set `LOG_FORMAT=json` for structured logs. Structured-log extras are redacted for keys containing `api_key`, `apikey`, `authorization`, `password`, `secret`, or `token`. Every response carries `X-Request-ID`; an inbound `X-Request-ID` is preserved. + +## Authentication + +`/auth` provides register, login, refresh, logout, and `/auth/me`. Passwords are hashed with Argon2; access tokens are HS256; refresh tokens rotate on every use, live in an httpOnly cookie, and reuse of a spent token revokes the whole family. + +Every non-public API route requires a valid Bearer token. Repository resolution is +owner-scoped in the service layer across analysis and all product consumers, so +one account cannot query another account's repository or snapshots. + +## AI Workspace endpoints + +| Endpoint | Purpose | +| --- | --- | +| `GET /ai/providers` | Static, non-secret setup metadata for every supported provider — display name, whether it needs an API key and/or a base URL, its default model, an official setup link, and a short ordered setup checklist. Backs the frontend's provider picker so it never hardcodes provider requirements. | +| `GET`/`PUT /ai/config` | Read or replace the caller's provider configuration. The API key is Fernet-encrypted at rest; only its last four characters are ever returned. | +| `POST /ai/test` | Validate a configuration against the provider without storing an answer. | +| `POST /ai/query` | Ask a question. Receives sealed-snapshot structural facts and observed paths — never source bytes or line spans — so answers carry no automatic citations. | +| `GET /ai/conversations?repositoryId=…` | The persisted thread for one repository, oldest turn first. | + +Requests to a self-hosted Ollama endpoint (`/ai/test` and `/ai/query`) get a +longer read budget (10 minutes) than the 60s applied to hosted providers: local +model loading and CPU generation legitimately take longer, and cutting the +connection off surfaced as "hung, then failed". A wrong or unreachable base URL +still fails within a 10s connect timeout, and PARTHA holds Ollama to one +in-flight request at a time (#414) since a local box has no spare parallel +headroom. + +**Conversation turns are durable.** Both the question and the answer are written +to `ai_conversation_messages`, one ordered thread per owner per repository, so +the workspace restores its history when a user navigates away and returns +(#231). The most recent turns are replayed to the provider as context, which is +what makes a follow-up question resolve. Two consequences worth stating plainly: +this is real egress of user-authored text alongside the structural context, and +**there is no delete endpoint** — a user cannot yet clear their own thread, and +history is removed only when the repository itself is deleted, which cascades. +Any interface built on these routes must describe retention accurately rather +than implying the workspace forgets. -## First Import Flow +## AI provider egress + +AI provider traffic is centrally checked at configuration save time and again +immediately before every outbound request. `AI_EGRESS_MODE=hosted` is the safe +default: fixed cloud providers retain their code-owned HTTPS origins and no +tenant-configurable endpoint is enabled. To use a trusted local or internal +Ollama endpoint, a deployment administrator must set `AI_EGRESS_MODE=self_hosted` +and provide both an exact `AI_EGRESS_ALLOWED_BASE_URLS` entry and matching +`AI_EGRESS_ALLOWED_CIDRS` entry. These are not tenant settings. + +The sender validates every DNS answer, pins the HTTP connection to a validated +IP while preserving the original Host/SNI name, ignores ambient proxy settings, +and rejects redirects. A shared or hosted environment still needs an independent +firewall, egress proxy, cloud egress rule, or mesh policy. See +[AI provider egress policy](../../docs/security/AI_PROVIDER_EGRESS.md) +for configuration, rollout, and migration details. + +For the end-to-end setup path — the Settings flow, the `ai/*` calls, the +per-provider requirements table, and Ollama's slow-first-request behaviour — +see [Connecting an AI provider](../../docs/operations/AI_PROVIDER_SETUP.md). + +## First import ```bash curl -X POST http://localhost:8000/repositories/github \ + -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{"url":"https://github.com/octocat/Hello-World"}' ``` + +Only public GitHub HTTPS URLs are accepted. Clone/archive extraction and initial +file-tree parsing finish before the import response. Submit analysis separately; +the start endpoint returns immediately after durably enqueueing the work: + +```bash +curl -X POST http://localhost:8000/analysis//start \ + -H "Authorization: Bearer " +``` + +### Analysis job lifecycle + +Analysis jobs have exactly five observable states: `queued`, `running`, +`completed`, `failed`, and `cancelled`. The API-process lifespan starts a daemon +worker thread; no separate worker deployment is required. Progress advances only +at completed pipeline stages. Failed work retries with bounded exponential +backoff up to the job's attempt limit, then becomes `failed`. + +`POST /analysis/{repository_id}/cancel` cancels queued work immediately and +requests cooperative cancellation of running work. Workers renew a guarded +database lease periodically during stages as well as at stage boundaries, so +long-running analysis remains owned and cancellation is noticed promptly. +Startup and periodic stale job sweeps reclaim expired leases, fail orphaned +building snapshots, and either +requeue or fail the job within its attempt budget. If a process dies after a +snapshot was sealed but before the job completion commit, the sweep reconciles +the job to `completed` without producing a duplicate snapshot. + +Operational settings are `ANALYSIS_WORKER_AUTOSTART`, +`ANALYSIS_JOB_POLL_INTERVAL_SECONDS`, and `ANALYSIS_JOB_LEASE_SECONDS`. +Each job also fails closed when it exceeds +`ANALYSIS_MAX_REPOSITORY_SOURCE_BYTES`, `ANALYSIS_MAX_PROCESS_RSS_BYTES`, or +`ANALYSIS_MAX_DURATION_SECONDS`. Resource breaches are terminal +`resource_exceeded` failures rather than retries, because replaying the same +repository under the same limits cannot succeed. + +Repository responses include first-class source identity: + +```json +{ + "revision": { + "kind": "git", + "value": "0123456789abcdef0123456789abcdef01234567", + "ref": "refs/heads/main" + }, + "commitSha": "0123456789abcdef0123456789abcdef01234567" +} +``` + +For uploads, `kind` is `upload`, `value` is `sha256:<64 lowercase hex>`, and `ref` is `null`. `commitSha` remains a compatibility alias of `revision.value`; authoritative identity lives in the indexed revision columns, not `repo_metadata`. Re-importing the same source at a new commit or a changed archive creates a new repository revision. + +## Snapshot-backed product endpoints + +Architecture, Engineering Review, and Insights resolve the authenticated +owner's latest sealed `ri.v1` snapshot and bind every response to its exact +repository revision and snapshot identity. + +- `GET /analysis/{repository_id}/architecture` returns the normalized graph and + provenance manifest. +- `GET /analysis/{repository_id}/review` returns + `engineering-review.v2`: deterministic, evidence-linked findings and explicit + assessed/not-assessed category states. It emits no score, grade, or invented + metric. +- `GET /analysis/{repository_id}/insights` returns `repository-insights.v1`: + defined snapshot counts, breakdowns, diagnostics, extractor coverage, and + provenance. Change history is explicitly unavailable until comparable + snapshots are implemented. +- `GET /analysis/{repository_id}/dependencies` returns `dependency-graph.v2`: + sealed-snapshot dependency nodes, declarations (with manifest path and line + span merged across manifests, #156), and resolved `depends_on` edges. It + does not provide vulnerability or outdated-package scanning. +- `GET /analysis/{repository_id}/architecture/authentication` returns the cited + authentication subgraph. Coverage is limited to supported Python/FastAPI + patterns. +- `GET /analysis/{repository_id}/evidence` returns the stored evidence for a + fact in the current snapshot, which is what lets a UI prove a citation + instead of asserting one. +- `GET /analysis/{repository_id}/revision-manifest` and its `/verify` companion + expose the snapshot's revision identity and canonical graph hash. The digest + detects content differences inside this deployment; it is **not** a signature + or a proof of authorship. + +## Repository and job endpoints + +| Endpoint | Purpose | +| --- | --- | +| `POST /repositories/upload`, `POST /repositories/github` | Import an archive or a public GitHub URL. Extraction and file-tree parsing complete before the response. | +| `GET /repositories`, `GET /repositories/{id}` | Owner-scoped listing and detail. The list is not paginated: it returns every repository the caller owns. | +| `GET /repositories/{id}/file` | Path-checked bounded preview for the explorer. Feeds no analysis. | +| `GET /repositories/{id}/lineage` | Ordered repository-lineage history (RFC-0002). A standalone (unlineaged) repository returns `isLineaged: false` and itself as the only entry. | +| `DELETE /repositories/{id}` | Deletes the repository and cascades to its snapshots and conversation turns. | +| `POST /analysis/{id}/start`, `POST /analysis/{id}/cancel`, `GET /analysis/{id}/status` | Durable job lifecycle, described above. | +| `POST /documentation/generate` | Structural documentation from the sealed snapshot. | +| `POST /export` | One JSON/Markdown/HTML/PDF pipeline over output that already exists; it never re-analyses. | + +## Repository Intelligence query API + +`/intelligence/v1/snapshots/{snapshot_id}` is the versioned read API over a +sealed snapshot, addressed by snapshot rather than by repository, and +owner-scoped like every other route. It is the interface a consumer should +build on rather than reaching into `ri_*` tables directly — storage and indexes +are implementation details, the API is the contract. + +| Endpoint | Returns | +| --- | --- | +| `GET /{snapshot_id}` | Snapshot metadata: revision identity, schema version, producer version set, config hash, canonical graph hash. | +| `GET /{snapshot_id}/symbols` | Paginated nodes. | +| `GET /{snapshot_id}/neighbours` | Adjacent nodes across resolved edges. | +| `GET /{snapshot_id}/references` | Where a node is referenced. | +| `GET /{snapshot_id}/paths` | Resolved paths between nodes. | +| `GET /{snapshot_id}/impact` | Directional traversal over resolved import and dependency edges. It does **not** compare revisions or calculate churn — this is reachability within one snapshot, not change impact over time. | +| `GET /{snapshot_id}/assertions` | Inferred assertions, kept separate from observed facts, each with its derivation chain. | +| `GET /{snapshot_id}/evidence` | Stored evidence for a fact, with its exact span in the stored revision. | diff --git a/apps/backend/alembic.ini b/apps/backend/alembic.ini index 87070c56..ebf9135b 100644 --- a/apps/backend/alembic.ini +++ b/apps/backend/alembic.ini @@ -1,6 +1,11 @@ [alembic] script_location = alembic prepend_sys_path = . +# Alembic 1.16+ warns when this is unset and falls back to legacy splitting on +# spaces, commas and colons. `os` uses the platform path separator, which is +# the correct reading of the single "." entry above and keeps the behaviour +# stable when the legacy fallback is eventually removed. +path_separator = os sqlalchemy.url = sqlite:///./.local/partha.db [loggers] diff --git a/apps/backend/alembic/env.py b/apps/backend/alembic/env.py index c4c78841..61de3f30 100644 --- a/apps/backend/alembic/env.py +++ b/apps/backend/alembic/env.py @@ -6,11 +6,10 @@ from app.core.config import get_settings from app.models.base import Base -from app.models.repository import RepositoryRecord config = context.config settings = get_settings() -config.set_main_option("sqlalchemy.url", settings.database_url) +config.set_main_option("sqlalchemy.url", settings.database_url.replace("%", "%%")) if settings.database_url.startswith("sqlite"): database_path = settings.database_url.replace("sqlite:///", "") if database_path and database_path != ":memory:": @@ -42,6 +41,27 @@ def run_migrations_online() -> None: ) with connectable.connect() as connection: + if connection.dialect.name == "sqlite": + # SQLite refuses to toggle `PRAGMA foreign_keys` mid-transaction + # (a documented no-op once a transaction is open), and Alembic's + # own per-migration transaction is already open by the time a + # revision's upgrade() runs. A migration that uses batch mode to + # add a constraint to a table that another table already has a + # deferred foreign key pointing at (e.g. #299's cyclic + # repository_lineages <-> repositories relationship) drops and + # recreates that table; SQLite's deferred-FK bookkeeping does not + # correctly reconcile that recreation against the still-open + # transaction, and a fully self-consistent final state still + # fails at COMMIT with a generic "FOREIGN KEY constraint failed" + # (verified: `PRAGMA foreign_key_check` reports no violation + # immediately beforehand). Disabling enforcement here, before any + # transaction opens, avoids this without weakening runtime + # enforcement: every real application connection still gets + # `PRAGMA foreign_keys=ON` via app.core.database's own + # connect-event listener; this affects only the connection + # Alembic itself uses while migrating. + connection.exec_driver_sql("PRAGMA foreign_keys=OFF") + connection.commit() context.configure(connection=connection, target_metadata=target_metadata) with context.begin_transaction(): diff --git a/apps/backend/alembic/versions/0002_users_and_repo_owner.py b/apps/backend/alembic/versions/0002_users_and_repo_owner.py new file mode 100644 index 00000000..078219d2 --- /dev/null +++ b/apps/backend/alembic/versions/0002_users_and_repo_owner.py @@ -0,0 +1,75 @@ +"""add users table and repository owner + +Revision ID: 0002_users_and_repo_owner +Revises: 0001_initial +Create Date: 2026-07-11 + +The revision id is kept under 32 characters because Alembic's default +alembic_version.version_num column is VARCHAR(32), which PostgreSQL enforces. +""" + +from datetime import UTC, datetime + +from alembic import op +import sqlalchemy as sa + +revision = "0002_users_and_repo_owner" +down_revision = "0001_initial" +branch_labels = None +depends_on = None + +# Kept in sync with app.models.user.SEED_USER_ID / SEED_USER_EMAIL. Existing +# repositories predate authentication, so they are backfilled to this owner. +SEED_USER_ID = "00000000-0000-0000-0000-000000000000" +SEED_USER_EMAIL = "system@partha.local" + + +def upgrade() -> None: + op.create_table( + "users", + sa.Column("id", sa.String(length=36), primary_key=True), + sa.Column("email", sa.String(length=320), nullable=False), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + ) + op.create_index("ix_users_email", "users", ["email"], unique=True) + + now = datetime.now(UTC) + users_table = sa.table( + "users", + sa.column("id", sa.String), + sa.column("email", sa.String), + sa.column("is_active", sa.Boolean), + sa.column("created_at", sa.DateTime), + sa.column("updated_at", sa.DateTime), + ) + op.bulk_insert( + users_table, + [{"id": SEED_USER_ID, "email": SEED_USER_EMAIL, "is_active": True, "created_at": now, "updated_at": now}], + ) + + # Add owner_id with a server_default so existing rows backfill to the seed + # user, then drop the default in a second step so new rows must set it + # explicitly (the service always does). Split across two batch blocks because + # SQLite recreates the table per block and the default must exist while the + # data copy runs. + with op.batch_alter_table("repositories") as batch: + batch.add_column( + sa.Column("owner_id", sa.String(length=36), nullable=False, server_default=SEED_USER_ID) + ) + batch.create_index("ix_repositories_owner_id", ["owner_id"], unique=False) + batch.create_foreign_key("fk_repositories_owner_id_users", "users", ["owner_id"], ["id"]) + + with op.batch_alter_table("repositories") as batch: + batch.alter_column("owner_id", server_default=None) + + +def downgrade() -> None: + with op.batch_alter_table("repositories") as batch: + batch.drop_constraint("fk_repositories_owner_id_users", type_="foreignkey") + batch.drop_index("ix_repositories_owner_id") + batch.drop_column("owner_id") + + op.drop_index("ix_users_email", table_name="users") + op.drop_table("users") diff --git a/apps/backend/alembic/versions/0003_auth_credentials.py b/apps/backend/alembic/versions/0003_auth_credentials.py new file mode 100644 index 00000000..46493835 --- /dev/null +++ b/apps/backend/alembic/versions/0003_auth_credentials.py @@ -0,0 +1,49 @@ +"""add password hash and refresh tokens + +Revision ID: 0003_auth_credentials +Revises: 0002_users_and_repo_owner +Create Date: 2026-07-11 + +Revision ids stay under 32 characters: alembic_version.version_num is +VARCHAR(32) and PostgreSQL enforces it. +""" + +from alembic import op +import sqlalchemy as sa + +revision = "0003_auth_credentials" +down_revision = "0002_users_and_repo_owner" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Nullable on purpose: pre-auth rows (including the seed user) have no + # credential and must never become logins by accident. + with op.batch_alter_table("users") as batch: + batch.add_column(sa.Column("password_hash", sa.String(length=255), nullable=True)) + + op.create_table( + "refresh_tokens", + sa.Column("id", sa.String(length=36), primary_key=True), + sa.Column("user_id", sa.String(length=36), sa.ForeignKey("users.id"), nullable=False), + sa.Column("token_hash", sa.String(length=64), nullable=False), + sa.Column("family_id", sa.String(length=36), nullable=False), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("used_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + ) + op.create_index("ix_refresh_tokens_user_id", "refresh_tokens", ["user_id"]) + op.create_index("ix_refresh_tokens_token_hash", "refresh_tokens", ["token_hash"], unique=True) + op.create_index("ix_refresh_tokens_family_id", "refresh_tokens", ["family_id"]) + + +def downgrade() -> None: + op.drop_index("ix_refresh_tokens_family_id", table_name="refresh_tokens") + op.drop_index("ix_refresh_tokens_token_hash", table_name="refresh_tokens") + op.drop_index("ix_refresh_tokens_user_id", table_name="refresh_tokens") + op.drop_table("refresh_tokens") + + with op.batch_alter_table("users") as batch: + batch.drop_column("password_hash") diff --git a/apps/backend/alembic/versions/0004_ai_provider_configs.py b/apps/backend/alembic/versions/0004_ai_provider_configs.py new file mode 100644 index 00000000..c6ccb3ef --- /dev/null +++ b/apps/backend/alembic/versions/0004_ai_provider_configs.py @@ -0,0 +1,46 @@ +"""add per-user encrypted ai provider configs + +Revision ID: 0004_ai_provider_configs +Revises: 0003_auth_credentials +Create Date: 2026-07-15 + +Revision ids stay under 32 characters: alembic_version.version_num is +VARCHAR(32) and PostgreSQL enforces it. + +Replaces the single global ``ai-provider.json`` file with a per-user table. +The API key is stored only as Fernet ciphertext (``encrypted_api_key``); the +plaintext key is never persisted. There is intentionally no data migration: +the old global file held one deployment-wide key with no owner, so it cannot be +attributed to a user and is dropped rather than silently re-homed onto someone. +""" + +from alembic import op +import sqlalchemy as sa + +revision = "0004_ai_provider_configs" +down_revision = "0003_auth_credentials" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "ai_provider_configs", + sa.Column("id", sa.String(length=36), primary_key=True), + sa.Column("owner_id", sa.String(length=36), sa.ForeignKey("users.id"), nullable=False), + sa.Column("provider", sa.String(length=32), nullable=False), + sa.Column("encrypted_api_key", sa.Text(), nullable=True), + sa.Column("api_key_last4", sa.String(length=4), nullable=True), + sa.Column("model", sa.String(length=255), nullable=True), + sa.Column("base_url", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + ) + # Unique so each user has exactly one active provider configuration; the + # index also serves the owner-scoped lookup on every AI request. + op.create_index("ix_ai_provider_configs_owner_id", "ai_provider_configs", ["owner_id"], unique=True) + + +def downgrade() -> None: + op.drop_index("ix_ai_provider_configs_owner_id", table_name="ai_provider_configs") + op.drop_table("ai_provider_configs") diff --git a/apps/backend/alembic/versions/0005_revision_snapshots.py b/apps/backend/alembic/versions/0005_revision_snapshots.py new file mode 100644 index 00000000..e1110a5a --- /dev/null +++ b/apps/backend/alembic/versions/0005_revision_snapshots.py @@ -0,0 +1,503 @@ +"""add revision identity and immutable intelligence snapshots + +Revision ID: 0005_revision_snapshots +Revises: 0004_ai_provider_configs +Create Date: 2026-07-16 + +This migration implements issues #87 and #88 as one revision-keyed persistence +boundary. It transforms exact legacy ``repo_metadata['commitSha']`` values into +typed repository columns but deliberately does not copy legacy regex +``repo_metadata['intelligence']`` facts into ``ri.v1`` tables: those facts lack +RFC-valid spans and producer provenance. + +Downgrade drops all snapshot data created under this revision, while preserving +every repository column and JSON value that existed before the upgrade. +""" + +from __future__ import annotations + +import re + +from alembic import op +import sqlalchemy as sa + +revision = "0005_revision_snapshots" +down_revision = "0004_ai_provider_configs" +branch_labels = None +depends_on = None + +_GIT_SHA_RE = re.compile(r"^[0-9a-f]{40}$") +_UPLOAD_SHA_RE = re.compile(r"^sha256:[0-9a-f]{64}$") +_REF_BODY_RE = re.compile(r"^[A-Za-z0-9._/-]+$") + + +def _hex_only_sql(expression: str) -> str: + for character in "0123456789abcdef": + expression = f"replace({expression}, '{character}', '')" + return f"{expression} = ''" + + +def _resolved_ref(branch: object) -> str | None: + """Derive a ref only from deterministic, explicit legacy branch metadata.""" + + if not isinstance(branch, str) or not branch: + return None + if branch.startswith(("refs/heads/", "refs/tags/")): + body = branch.split("/", 2)[2] + return branch if _REF_BODY_RE.fullmatch(body) and ".." not in body else None + if ( + not _REF_BODY_RE.fullmatch(branch) + or branch.startswith(("/", "-")) + or branch.endswith(("/", ".")) + or ".." in branch + ): + return None + return f"refs/heads/{branch}" + + +def _add_repository_revision_columns() -> None: + with op.batch_alter_table("repositories") as batch: + batch.add_column(sa.Column("revision_kind", sa.String(length=16), nullable=True)) + batch.add_column(sa.Column("revision_value", sa.String(length=80), nullable=True)) + batch.add_column(sa.Column("revision_ref", sa.String(length=255), nullable=True)) + batch.create_unique_constraint( + "uq_repositories_id_revision", + ["id", "revision_kind", "revision_value"], + ) + batch.create_check_constraint( + "ck_repositories_revision_kind", + "revision_kind IS NULL OR revision_kind IN ('git', 'upload')", + ) + batch.create_check_constraint( + "ck_repositories_revision_complete", + "(revision_kind IS NULL AND revision_value IS NULL AND revision_ref IS NULL) OR " + "(revision_kind IS NOT NULL AND revision_value IS NOT NULL)", + ) + batch.create_check_constraint( + "ck_repositories_upload_revision", + "revision_kind <> 'upload' OR " + f"(revision_ref IS NULL AND length(revision_value) = 71 AND " + f"substr(revision_value, 1, 7) = 'sha256:' AND {_hex_only_sql('substr(revision_value, 8)')})", + ) + batch.create_check_constraint( + "ck_repositories_git_revision", + "revision_kind <> 'git' OR " + f"(length(revision_value) = 40 AND {_hex_only_sql('revision_value')} AND " + "(revision_ref IS NULL OR revision_ref LIKE 'refs/%'))", + ) + batch.create_index("ix_repositories_revision_value", ["revision_value"], unique=False) + + +def _backfill_repository_revisions() -> None: + connection = op.get_bind() + repositories = sa.table( + "repositories", + sa.column("id", sa.String()), + sa.column("branch", sa.String()), + sa.column("repo_metadata", sa.JSON()), + sa.column("revision_kind", sa.String()), + sa.column("revision_value", sa.String()), + sa.column("revision_ref", sa.String()), + ) + rows = connection.execute( + sa.select(repositories.c.id, repositories.c.branch, repositories.c.repo_metadata) + ).mappings() + for row in rows: + metadata = row["repo_metadata"] + legacy_value = metadata.get("commitSha") if isinstance(metadata, dict) else None + values: dict[str, object] + if isinstance(legacy_value, str) and _UPLOAD_SHA_RE.fullmatch(legacy_value): + values = {"revision_kind": "upload", "revision_value": legacy_value, "revision_ref": None} + elif isinstance(legacy_value, str) and _GIT_SHA_RE.fullmatch(legacy_value): + values = { + "revision_kind": "git", + "revision_value": legacy_value, + "revision_ref": _resolved_ref(row["branch"]), + } + else: + # Invalid or absent legacy values remain explicitly unidentified. + # No SHA or ref is fabricated from a URL, filename, timestamp, or + # current working tree. + values = {"revision_kind": None, "revision_value": None, "revision_ref": None} + connection.execute( + repositories.update().where(repositories.c.id == row["id"]).values(**values) + ) + + +def _create_snapshot_tables() -> None: + op.create_table( + "ri_snapshots", + sa.Column("snapshot_id", sa.String(length=48), primary_key=True), + sa.Column("repository_id", sa.String(length=36), nullable=False), + sa.Column("revision_kind", sa.String(length=16), nullable=False), + sa.Column("revision_value", sa.String(length=80), nullable=False), + sa.Column("revision_ref", sa.String(length=255), nullable=True), + sa.Column("schema_version", sa.String(length=16), nullable=False), + sa.Column("producer_version_set", sa.JSON(), nullable=False), + sa.Column("producer_set_hash", sa.String(length=80), nullable=False), + sa.Column("config_hash", sa.String(length=80), nullable=False), + sa.Column("state", sa.String(length=16), nullable=False), + sa.Column("canonical_graph_hash", sa.String(length=80), nullable=True), + sa.Column("actual_producers", sa.JSON(), nullable=True), + sa.Column("failure_code", sa.String(length=64), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("sealed_at", sa.DateTime(timezone=True), nullable=True), + sa.CheckConstraint("state IN ('building', 'completed', 'failed')", name="ck_ri_snapshots_state"), + sa.CheckConstraint("revision_kind IN ('git', 'upload')", name="ck_ri_snapshots_revision_kind"), + sa.CheckConstraint( + "revision_kind <> 'upload' OR " + f"(revision_ref IS NULL AND length(revision_value) = 71 AND " + f"substr(revision_value, 1, 7) = 'sha256:' AND {_hex_only_sql('substr(revision_value, 8)')})", + name="ck_ri_snapshots_upload_revision", + ), + sa.CheckConstraint( + "revision_kind <> 'git' OR " + f"(length(revision_value) = 40 AND {_hex_only_sql('revision_value')} AND " + "(revision_ref IS NULL OR revision_ref LIKE 'refs/%'))", + name="ck_ri_snapshots_git_revision", + ), + sa.CheckConstraint( + f"length(producer_set_hash) = 71 AND substr(producer_set_hash, 1, 7) = 'sha256:' AND " + f"{_hex_only_sql('substr(producer_set_hash, 8)')}", + name="ck_ri_snapshots_producer_hash", + ), + sa.CheckConstraint( + f"length(config_hash) = 71 AND substr(config_hash, 1, 7) = 'sha256:' AND " + f"{_hex_only_sql('substr(config_hash, 8)')}", + name="ck_ri_snapshots_config_hash", + ), + sa.CheckConstraint( + "(state = 'completed' AND canonical_graph_hash IS NOT NULL AND sealed_at IS NOT NULL) OR " + "(state <> 'completed' AND canonical_graph_hash IS NULL AND sealed_at IS NULL)", + name="ck_ri_snapshots_seal_fields", + ), + sa.CheckConstraint( + "canonical_graph_hash IS NULL OR " + f"(length(canonical_graph_hash) = 71 AND substr(canonical_graph_hash, 1, 7) = 'sha256:' AND " + f"{_hex_only_sql('substr(canonical_graph_hash, 8)')})", + name="ck_ri_snapshots_canonical_hash", + ), + sa.ForeignKeyConstraint( + ["repository_id", "revision_kind", "revision_value"], + ["repositories.id", "repositories.revision_kind", "repositories.revision_value"], + name="fk_ri_snapshots_repository_revision", + ondelete="CASCADE", + ), + ) + op.create_index("ix_ri_snapshots_repository_id", "ri_snapshots", ["repository_id"]) + op.create_index( + "ix_ri_snapshots_repository_revision", + "ri_snapshots", + ["repository_id", "revision_value"], + ) + op.create_index( + "uq_ri_snapshots_completed_identity", + "ri_snapshots", + ["repository_id", "revision_value", "schema_version", "producer_set_hash", "config_hash"], + unique=True, + sqlite_where=sa.text("state = 'completed'"), + postgresql_where=sa.text("state = 'completed'"), + ) + + op.create_table( + "ri_nodes", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column( + "snapshot_id", + sa.String(length=48), + sa.ForeignKey("ri_snapshots.snapshot_id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("stable_key", sa.String(length=1024), nullable=False), + sa.Column("node_kind", sa.String(length=16), nullable=False), + sa.Column("name", sa.String(length=512), nullable=True), + sa.Column("language", sa.String(length=64), nullable=True), + sa.Column("truth_class", sa.String(length=16), nullable=False), + sa.Column("properties", sa.JSON(), nullable=True), + sa.UniqueConstraint("snapshot_id", "stable_key", name="uq_ri_nodes_snapshot_stable_key"), + sa.UniqueConstraint("snapshot_id", "id", name="uq_ri_nodes_snapshot_row"), + sa.CheckConstraint("truth_class = 'observed'", name="ck_ri_nodes_truth_class"), + ) + op.create_index("ix_ri_nodes_snapshot_id", "ri_nodes", ["snapshot_id"]) + op.create_index( + "uq_ri_nodes_single_root", + "ri_nodes", + ["snapshot_id"], + unique=True, + sqlite_where=sa.text("node_kind = 'repository'"), + postgresql_where=sa.text("node_kind = 'repository'"), + ) + + op.create_table( + "ri_edges", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column( + "snapshot_id", + sa.String(length=48), + sa.ForeignKey("ri_snapshots.snapshot_id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("edge_id", sa.String(length=96), nullable=False), + sa.Column("subject_kind", sa.String(length=16), nullable=False), + sa.Column("subject_key", sa.String(length=1024), nullable=False), + sa.Column("predicate", sa.String(length=32), nullable=False), + sa.Column("object_kind", sa.String(length=16), nullable=False), + sa.Column("object_key", sa.String(length=1024), nullable=False), + sa.Column("truth_class", sa.String(length=16), nullable=False), + sa.Column("producer", sa.String(length=128), nullable=False), + sa.Column("producer_version", sa.String(length=64), nullable=False), + sa.UniqueConstraint("snapshot_id", "edge_id", name="uq_ri_edges_snapshot_edge_id"), + sa.UniqueConstraint( + "snapshot_id", "subject_key", "predicate", "object_key", name="uq_ri_edges_snapshot_triple" + ), + sa.UniqueConstraint("snapshot_id", "id", name="uq_ri_edges_snapshot_row"), + sa.CheckConstraint("truth_class = 'resolved'", name="ck_ri_edges_truth_class"), + sa.ForeignKeyConstraint( + ["snapshot_id", "subject_key"], + ["ri_nodes.snapshot_id", "ri_nodes.stable_key"], + name="fk_ri_edges_subject_node", + ondelete="CASCADE", + ), + sa.ForeignKeyConstraint( + ["snapshot_id", "object_key"], + ["ri_nodes.snapshot_id", "ri_nodes.stable_key"], + name="fk_ri_edges_object_node", + ondelete="CASCADE", + ), + ) + op.create_index("ix_ri_edges_snapshot_id", "ri_edges", ["snapshot_id"]) + + op.create_table( + "ri_assertions", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column( + "snapshot_id", + sa.String(length=48), + sa.ForeignKey("ri_snapshots.snapshot_id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("assertion_id", sa.String(length=96), nullable=False), + sa.Column("subject_kind", sa.String(length=16), nullable=False), + sa.Column("subject_key", sa.String(length=1024), nullable=False), + sa.Column("predicate", sa.String(length=32), nullable=False), + sa.Column("value", sa.JSON(), nullable=False), + sa.Column("truth_class", sa.String(length=16), nullable=False), + sa.Column("producer", sa.String(length=128), nullable=False), + sa.Column("producer_version", sa.String(length=64), nullable=False), + sa.UniqueConstraint("snapshot_id", "assertion_id", name="uq_ri_assertions_snapshot_assertion_id"), + sa.UniqueConstraint("snapshot_id", "id", name="uq_ri_assertions_snapshot_row"), + sa.CheckConstraint("truth_class = 'inferred'", name="ck_ri_assertions_truth_class"), + sa.ForeignKeyConstraint( + ["snapshot_id", "subject_key"], + ["ri_nodes.snapshot_id", "ri_nodes.stable_key"], + name="fk_ri_assertions_subject_node", + ondelete="CASCADE", + ), + ) + op.create_index("ix_ri_assertions_snapshot_id", "ri_assertions", ["snapshot_id"]) + + op.create_table( + "ri_observations", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column( + "snapshot_id", + sa.String(length=48), + sa.ForeignKey("ri_snapshots.snapshot_id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("observation_id", sa.String(length=96), nullable=False), + sa.Column("observed_kind", sa.String(length=16), nullable=False), + sa.Column("subject_kind", sa.String(length=16), nullable=False), + sa.Column("subject_key", sa.String(length=1024), nullable=False), + sa.Column("referent_text", sa.Text(), nullable=True), + sa.Column("ordinal", sa.Integer(), nullable=False), + sa.UniqueConstraint("snapshot_id", "observation_id", name="uq_ri_observations_snapshot_obs_id"), + sa.UniqueConstraint("snapshot_id", "id", name="uq_ri_observations_snapshot_row"), + sa.CheckConstraint("ordinal >= 1", name="ck_ri_observations_ordinal"), + sa.ForeignKeyConstraint( + ["snapshot_id", "subject_key"], + ["ri_nodes.snapshot_id", "ri_nodes.stable_key"], + name="fk_ri_observations_subject_node", + ondelete="CASCADE", + ), + ) + op.create_index("ix_ri_observations_snapshot_id", "ri_observations", ["snapshot_id"]) + + op.create_table( + "ri_evidence", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column( + "snapshot_id", + sa.String(length=48), + sa.ForeignKey("ri_snapshots.snapshot_id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("node_ref", sa.Integer(), nullable=True), + sa.Column("edge_ref", sa.Integer(), nullable=True), + sa.Column("observation_ref", sa.Integer(), nullable=True), + sa.Column("path", sa.String(length=1024), nullable=False), + sa.Column("start_line", sa.Integer(), nullable=False), + sa.Column("end_line", sa.Integer(), nullable=False), + sa.Column("logical_line_count", sa.Integer(), nullable=False), + sa.Column("granularity", sa.String(length=16), nullable=False), + sa.Column("extractor", sa.String(length=128), nullable=False), + sa.Column("extractor_version", sa.String(length=64), nullable=False), + sa.CheckConstraint( + "(CASE WHEN node_ref IS NOT NULL THEN 1 ELSE 0 END + " + "CASE WHEN edge_ref IS NOT NULL THEN 1 ELSE 0 END + " + "CASE WHEN observation_ref IS NOT NULL THEN 1 ELSE 0 END) = 1", + name="ck_ri_evidence_single_parent", + ), + sa.CheckConstraint( + "start_line >= 1 AND end_line >= start_line AND " + "logical_line_count >= 1 AND end_line <= logical_line_count", + name="ck_ri_evidence_span", + ), + sa.CheckConstraint("granularity IN ('span', 'file')", name="ck_ri_evidence_granularity"), + sa.CheckConstraint("path NOT LIKE '/%'", name="ck_ri_evidence_relative_path"), + sa.ForeignKeyConstraint( + ["snapshot_id", "node_ref"], + ["ri_nodes.snapshot_id", "ri_nodes.id"], + name="fk_ri_evidence_node", + ondelete="CASCADE", + ), + sa.ForeignKeyConstraint( + ["snapshot_id", "edge_ref"], + ["ri_edges.snapshot_id", "ri_edges.id"], + name="fk_ri_evidence_edge", + ondelete="CASCADE", + ), + sa.ForeignKeyConstraint( + ["snapshot_id", "observation_ref"], + ["ri_observations.snapshot_id", "ri_observations.id"], + name="fk_ri_evidence_observation", + ondelete="CASCADE", + ), + ) + op.create_index("ix_ri_evidence_snapshot_id", "ri_evidence", ["snapshot_id"]) + op.create_index("ix_ri_evidence_node_ref", "ri_evidence", ["snapshot_id", "node_ref"]) + op.create_index("ix_ri_evidence_edge_ref", "ri_evidence", ["snapshot_id", "edge_ref"]) + op.create_index("ix_ri_evidence_observation_ref", "ri_evidence", ["snapshot_id", "observation_ref"]) + evidence_fields = ["path", "start_line", "end_line", "granularity", "extractor", "extractor_version"] + for parent in ("node", "edge", "observation"): + op.create_index( + f"uq_ri_evidence_{parent}_fact", + "ri_evidence", + ["snapshot_id", f"{parent}_ref", *evidence_fields], + unique=True, + sqlite_where=sa.text(f"{parent}_ref IS NOT NULL"), + postgresql_where=sa.text(f"{parent}_ref IS NOT NULL"), + ) + + op.create_table( + "ri_derivations", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column( + "snapshot_id", + sa.String(length=48), + sa.ForeignKey("ri_snapshots.snapshot_id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("edge_ref", sa.Integer(), nullable=True), + sa.Column("assertion_ref", sa.Integer(), nullable=True), + sa.Column("ref_kind", sa.String(length=16), nullable=False), + sa.Column("ref_identity", sa.String(length=1024), nullable=False), + sa.CheckConstraint( + "(CASE WHEN edge_ref IS NOT NULL THEN 1 ELSE 0 END + " + "CASE WHEN assertion_ref IS NOT NULL THEN 1 ELSE 0 END) = 1", + name="ck_ri_derivations_single_parent", + ), + sa.CheckConstraint( + "ref_kind IN ('observation', 'node', 'edge', 'assertion')", + name="ck_ri_derivations_ref_kind", + ), + sa.ForeignKeyConstraint( + ["snapshot_id", "edge_ref"], + ["ri_edges.snapshot_id", "ri_edges.id"], + name="fk_ri_derivations_edge", + ondelete="CASCADE", + ), + sa.ForeignKeyConstraint( + ["snapshot_id", "assertion_ref"], + ["ri_assertions.snapshot_id", "ri_assertions.id"], + name="fk_ri_derivations_assertion", + ondelete="CASCADE", + ), + ) + op.create_index("ix_ri_derivations_snapshot_id", "ri_derivations", ["snapshot_id"]) + for parent in ("edge", "assertion"): + op.create_index( + f"uq_ri_derivations_{parent}_fact", + "ri_derivations", + ["snapshot_id", f"{parent}_ref", "ref_kind", "ref_identity"], + unique=True, + sqlite_where=sa.text(f"{parent}_ref IS NOT NULL"), + postgresql_where=sa.text(f"{parent}_ref IS NOT NULL"), + ) + + op.create_table( + "ri_diagnostics", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column( + "snapshot_id", + sa.String(length=48), + sa.ForeignKey("ri_snapshots.snapshot_id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("code", sa.String(length=64), nullable=False), + sa.Column("category", sa.String(length=64), nullable=False), + sa.Column("severity", sa.String(length=16), nullable=False), + sa.Column("message", sa.Text(), nullable=False), + sa.Column("path", sa.String(length=1024), nullable=True), + sa.Column("span_start_line", sa.Integer(), nullable=True), + sa.Column("span_end_line", sa.Integer(), nullable=True), + sa.Column("producer", sa.String(length=160), nullable=False), + sa.Column("subject_key", sa.String(length=1024), nullable=True), + sa.Column("object_key", sa.String(length=1024), nullable=True), + sa.Column("details", sa.JSON(), nullable=True), + sa.CheckConstraint( + "severity IN ('fatal', 'error', 'warning', 'info')", + name="ck_ri_diagnostics_severity", + ), + sa.CheckConstraint( + "(span_start_line IS NULL AND span_end_line IS NULL) OR " + "(span_start_line >= 1 AND span_end_line >= span_start_line)", + name="ck_ri_diagnostics_span", + ), + sa.CheckConstraint("path IS NULL OR path NOT LIKE '/%'", name="ck_ri_diagnostics_relative_path"), + ) + op.create_index("ix_ri_diagnostics_snapshot_id", "ri_diagnostics", ["snapshot_id"]) + + +def upgrade() -> None: + _add_repository_revision_columns() + _backfill_repository_revisions() + _create_snapshot_tables() + + +def downgrade() -> None: + # Snapshot rows are intentionally discarded. Repository data that predates + # this migration, including repo_metadata['commitSha'], is untouched. + for table in ( + "ri_diagnostics", + "ri_derivations", + "ri_evidence", + "ri_observations", + "ri_assertions", + "ri_edges", + "ri_nodes", + "ri_snapshots", + ): + op.drop_table(table) + + with op.batch_alter_table("repositories") as batch: + batch.drop_index("ix_repositories_revision_value") + batch.drop_constraint("ck_repositories_git_revision", type_="check") + batch.drop_constraint("ck_repositories_upload_revision", type_="check") + batch.drop_constraint("ck_repositories_revision_complete", type_="check") + batch.drop_constraint("ck_repositories_revision_kind", type_="check") + batch.drop_constraint("uq_repositories_id_revision", type_="unique") + batch.drop_column("revision_ref") + batch.drop_column("revision_value") + batch.drop_column("revision_kind") diff --git a/apps/backend/alembic/versions/0006_analysis_jobs.py b/apps/backend/alembic/versions/0006_analysis_jobs.py new file mode 100644 index 00000000..3c059207 --- /dev/null +++ b/apps/backend/alembic/versions/0006_analysis_jobs.py @@ -0,0 +1,91 @@ +"""add durable analysis_jobs table for job state management + +Revision ID: 0006_analysis_jobs +Revises: 0005_revision_snapshots +Create Date: 2026-07-21 + +This migration adds the analysis_jobs table to track durable job state across +worker claims and retries. The table uses a partial unique index to prevent +duplicate effective work (at most one queued/running/completed job per +identity), while allowing failed/cancelled attempts to be retried. A separate +unique index prevents one sealed snapshot being associated with multiple jobs. +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + +revision = "0006_analysis_jobs" +down_revision = "0005_revision_snapshots" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "analysis_jobs", + sa.Column("id", sa.String(length=36), primary_key=True), + sa.Column("repository_id", sa.String(length=36), nullable=False), + sa.Column("owner_id", sa.String(length=36), nullable=False), + sa.Column("revision_kind", sa.String(length=16), nullable=False), + sa.Column("revision_value", sa.String(length=80), nullable=False), + sa.Column("config_hash", sa.String(length=80), nullable=False), + sa.Column("status", sa.String(length=16), nullable=False), + sa.Column("stage", sa.String(length=64), nullable=True), + sa.Column("progress", sa.Integer(), nullable=False), + sa.Column("attempt", sa.Integer(), nullable=False), + sa.Column("max_attempts", sa.Integer(), nullable=False), + sa.Column("cancel_requested", sa.Boolean(), nullable=False), + sa.Column("worker_id", sa.String(length=64), nullable=True), + sa.Column("lease_expires_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("next_attempt_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("snapshot_id", sa.String(length=48), nullable=True), + sa.Column("error_code", sa.String(length=64), nullable=True), + sa.Column("error_message", sa.String(length=1024), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("started_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True), + sa.CheckConstraint( + "status IN ('queued','running','completed','failed','cancelled')", + name="ck_analysis_jobs_status", + ), + sa.CheckConstraint("revision_kind IN ('git','upload')", name="ck_analysis_jobs_revision_kind"), + sa.CheckConstraint("progress >= 0 AND progress <= 100", name="ck_analysis_jobs_progress_range"), + sa.CheckConstraint("attempt >= 0", name="ck_analysis_jobs_attempt_nonneg"), + sa.CheckConstraint("max_attempts >= 1", name="ck_analysis_jobs_max_attempts_positive"), + sa.ForeignKeyConstraint( + ["repository_id", "revision_kind", "revision_value"], + ["repositories.id", "repositories.revision_kind", "repositories.revision_value"], + name="fk_analysis_jobs_repository_revision", + ondelete="CASCADE", + ), + sa.ForeignKeyConstraint(["owner_id"], ["users.id"], name="fk_analysis_jobs_owner", ondelete="CASCADE"), + sa.ForeignKeyConstraint( + ["snapshot_id"], ["ri_snapshots.snapshot_id"], name="fk_analysis_jobs_snapshot", ondelete="SET NULL" + ), + ) + op.create_index("ix_analysis_jobs_repository_id", "analysis_jobs", ["repository_id"]) + op.create_index("ix_analysis_jobs_owner_id", "analysis_jobs", ["owner_id"]) + op.create_index("ix_analysis_jobs_status_lease", "analysis_jobs", ["status", "lease_expires_at"]) + op.create_index( + "uq_analysis_jobs_snapshot_id", + "analysis_jobs", + ["snapshot_id"], + unique=True, + sqlite_where=sa.text("snapshot_id IS NOT NULL"), + postgresql_where=sa.text("snapshot_id IS NOT NULL"), + ) + op.create_index( + "uq_analysis_jobs_effective_identity", + "analysis_jobs", + ["repository_id", "revision_value", "config_hash"], + unique=True, + sqlite_where=sa.text("status IN ('queued','running','completed')"), + postgresql_where=sa.text("status IN ('queued','running','completed')"), + ) + + +def downgrade() -> None: + op.drop_table("analysis_jobs") diff --git a/apps/backend/alembic/versions/0007_remove_data_source.py b/apps/backend/alembic/versions/0007_remove_data_source.py new file mode 100644 index 00000000..fe75b98d --- /dev/null +++ b/apps/backend/alembic/versions/0007_remove_data_source.py @@ -0,0 +1,32 @@ +"""remove the always-"real" data_source placeholder column + +Revision ID: 0007_remove_data_source +Revises: 0006_analysis_jobs +Create Date: 2026-07-25 + +``data_source`` was written as the literal "real" on every repository, never +derived from any actual distinction (#96). Downgrade restores the column with +its original server default so existing rows remain valid if reversed. +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + +revision = "0007_remove_data_source" +down_revision = "0006_analysis_jobs" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + with op.batch_alter_table("repositories") as batch: + batch.drop_column("data_source") + + +def downgrade() -> None: + with op.batch_alter_table("repositories") as batch: + batch.add_column( + sa.Column("data_source", sa.String(length=32), nullable=False, server_default="real") + ) diff --git a/apps/backend/alembic/versions/0008_impact_query_edge_indexes.py b/apps/backend/alembic/versions/0008_impact_query_edge_indexes.py new file mode 100644 index 00000000..ce938170 --- /dev/null +++ b/apps/backend/alembic/versions/0008_impact_query_edge_indexes.py @@ -0,0 +1,38 @@ +"""add directional snapshot-edge indexes for impact traversal + +Revision ID: 0008_impact_query_edge_indexes +Revises: 0007_remove_data_source +Create Date: 2026-07-28 + +The sealed-snapshot impact query filters resolved edges by snapshot, one +endpoint direction, and predicate. These complementary composite indexes keep +both outgoing dependency and incoming dependent lookups bounded as snapshots +grow. +""" + +from __future__ import annotations + +from alembic import op + +revision = "0008_impact_query_edge_indexes" +down_revision = "0007_remove_data_source" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_index( + "ix_ri_edges_snapshot_subject_predicate", + "ri_edges", + ["snapshot_id", "subject_key", "predicate"], + ) + op.create_index( + "ix_ri_edges_snapshot_object_predicate", + "ri_edges", + ["snapshot_id", "object_key", "predicate"], + ) + + +def downgrade() -> None: + op.drop_index("ix_ri_edges_snapshot_object_predicate", table_name="ri_edges") + op.drop_index("ix_ri_edges_snapshot_subject_predicate", table_name="ri_edges") diff --git a/apps/backend/alembic/versions/0009_ai_conversation_messages.py b/apps/backend/alembic/versions/0009_ai_conversation_messages.py new file mode 100644 index 00000000..cf234338 --- /dev/null +++ b/apps/backend/alembic/versions/0009_ai_conversation_messages.py @@ -0,0 +1,57 @@ +"""add ai conversation message persistence + +Revision ID: 0009_ai_conversation_messages +Revises: 0008_impact_query_edge_indexes +Create Date: 2026-07-28 + +The AI Workspace previously lost its conversation thread on every navigation +away from the page because nothing was ever persisted server-side (#231). +This adds one row per turn (user or assistant), scoped by owner and +repository, ordered within a thread by ``sequence`` rather than timestamp +precision. + +The ``repository_id`` foreign key cascades, so deleting a repository removes +its conversation turns instead of raising a foreign-key violation. The +``(owner_id, repository_id, sequence)`` unique constraint is the concurrency +guard: two simultaneous ``MAX(sequence) + 1`` allocations collide here instead +of silently interleaving user/assistant pairs. The unique constraint also +provides the composite index that serves the owner+repository scoping check +and the in-thread ordering, so no separate non-unique index is needed. +""" + +from alembic import op +import sqlalchemy as sa + +revision = "0009_ai_conversation_messages" +down_revision = "0008_impact_query_edge_indexes" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "ai_conversation_messages", + sa.Column("id", sa.String(length=36), primary_key=True), + sa.Column("owner_id", sa.String(length=36), sa.ForeignKey("users.id"), nullable=False), + sa.Column( + "repository_id", + sa.String(length=36), + sa.ForeignKey("repositories.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("sequence", sa.Integer(), nullable=False), + sa.Column("role", sa.String(length=16), nullable=False), + sa.Column("content", sa.Text(), nullable=False), + sa.Column("citations", sa.JSON(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.UniqueConstraint( + "owner_id", + "repository_id", + "sequence", + name="uq_ai_conversation_owner_repo_sequence", + ), + ) + + +def downgrade() -> None: + op.drop_table("ai_conversation_messages") diff --git a/apps/backend/alembic/versions/0010_account_deletion.py b/apps/backend/alembic/versions/0010_account_deletion.py new file mode 100644 index 00000000..8ecba255 --- /dev/null +++ b/apps/backend/alembic/versions/0010_account_deletion.py @@ -0,0 +1,130 @@ +"""add account-deletion cascades and audit trail + +Revision ID: 0010_account_deletion +Revises: 0009_ai_conversation_messages +Create Date: 2026-08-13 + +Issue #290: verified account deletion. Several owner foreign keys did not +declare database-level cascade deletion, so deleting a ``users`` row would +raise a foreign-key violation unless every owned row was deleted in exactly +the right order first. This adds ``ON DELETE CASCADE`` as a second line of +defense behind the application-level deletion in ``AccountDeletionService``, +and adds a minimal, non-PII audit trail that survives the user row itself +being deleted. + +``refresh_tokens.user_id``, ``ai_provider_configs.owner_id``, and +``ai_conversation_messages.owner_id`` were all created inline +(``sa.ForeignKey("users.id")``) with no explicit constraint name, so +PostgreSQL and SQLite ended up with different anonymous identities for the +"same" constraint: + +- PostgreSQL auto-names an unnamed single-column foreign key + ``__fkey`` (verified against a live instance -- + ``refresh_tokens_user_id_fkey`` etc -- this is Postgres's own naming, not + SQLAlchemy's, so it is stable to rely on here). +- SQLite does not name inline ``REFERENCES`` constraints at all; reflection + reports ``name=None``, and batch mode can only drop a *named* constraint. + The SQLite branch below supplies its own ``naming_convention`` so batch + mode can synthesize a deterministic name for the anonymous constraint + before dropping it. Verified end-to-end against a live SQLite file with + ``PRAGMA foreign_keys=ON``: the resulting constraint reports + ``ON DELETE CASCADE`` in ``PRAGMA foreign_key_list`` and a parent-row + delete actually removes the child row. + +``repositories.owner_id`` already has an explicit name from migration 0002 +(``fk_repositories_owner_id_users``) on both dialects, but SQLite still needs +the same anonymous-constraint workaround as the other three: SQLite never +persists a foreign key's constraint name (``PRAGMA foreign_key_list`` has no +name column), so reflection reports ``name=None`` regardless of how the +constraint was originally created, and batch mode can only drop a *named* +constraint. It is folded into ``_OWNER_FKS`` below rather than handled as a +one-off so both dialects go through the same branch. +""" + +from alembic import op +import sqlalchemy as sa + +revision = "0010_account_deletion" +down_revision = "0009_ai_conversation_messages" +branch_labels = None +depends_on = None + +_SQLITE_ANON_FK_NAMING_CONVENTION = {"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s"} + +# (table, column, new constraint name, existing Postgres auto-generated name) +_OWNER_FKS = [ + ( + "refresh_tokens", + "user_id", + "fk_refresh_tokens_user_id_users", + "refresh_tokens_user_id_fkey", + ), + ( + "ai_provider_configs", + "owner_id", + "fk_ai_provider_configs_owner_id_users", + "ai_provider_configs_owner_id_fkey", + ), + ( + "ai_conversation_messages", + "owner_id", + "fk_ai_conversation_messages_owner_id_users", + "ai_conversation_messages_owner_id_fkey", + ), + ( + "repositories", + "owner_id", + "fk_repositories_owner_id_users", + "fk_repositories_owner_id_users", + ), +] + + +def upgrade() -> None: + is_sqlite = op.get_bind().dialect.name == "sqlite" + + for table, column, new_name, pg_existing_name in _OWNER_FKS: + if is_sqlite: + with op.batch_alter_table(table, naming_convention=_SQLITE_ANON_FK_NAMING_CONVENTION) as batch_op: + batch_op.drop_constraint(new_name, type_="foreignkey") + batch_op.create_foreign_key(new_name, "users", [column], ["id"], ondelete="CASCADE") + else: + with op.batch_alter_table(table) as batch_op: + batch_op.drop_constraint(pg_existing_name, type_="foreignkey") + batch_op.create_foreign_key(new_name, "users", [column], ["id"], ondelete="CASCADE") + + op.create_table( + "account_deletion_audits", + sa.Column("id", sa.String(length=36), primary_key=True), + # Deliberately not a foreign key: the referenced user row is gone by + # the time a row here reaches "completed", and the point of an audit + # trail is that it outlives the thing it records. No email or other + # personal data is stored -- only the id that was deleted. + sa.Column("deleted_user_id", sa.String(length=36), nullable=False), + sa.Column("status", sa.String(length=16), nullable=False), + sa.Column("requested_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("failure_reason", sa.String(length=255), nullable=True), + sa.CheckConstraint( + "status IN ('in_progress','completed','failed')", + name="ck_account_deletion_audits_status", + ), + ) + op.create_index("ix_account_deletion_audits_deleted_user_id", "account_deletion_audits", ["deleted_user_id"]) + + +def downgrade() -> None: + op.drop_index("ix_account_deletion_audits_deleted_user_id", table_name="account_deletion_audits") + op.drop_table("account_deletion_audits") + + is_sqlite = op.get_bind().dialect.name == "sqlite" + + for table, column, new_name, _pg_existing_name in reversed(_OWNER_FKS): + if is_sqlite: + with op.batch_alter_table(table, naming_convention=_SQLITE_ANON_FK_NAMING_CONVENTION) as batch_op: + batch_op.drop_constraint(new_name, type_="foreignkey") + batch_op.create_foreign_key(new_name, "users", [column], ["id"]) + else: + with op.batch_alter_table(table) as batch_op: + batch_op.drop_constraint(new_name, type_="foreignkey") + batch_op.create_foreign_key(new_name, "users", [column], ["id"]) diff --git a/apps/backend/alembic/versions/0011_invite_tokens.py b/apps/backend/alembic/versions/0011_invite_tokens.py new file mode 100644 index 00000000..17d72293 --- /dev/null +++ b/apps/backend/alembic/versions/0011_invite_tokens.py @@ -0,0 +1,43 @@ +"""add invite_tokens for invite-gated registration + +Revision ID: 0011_invite_tokens +Revises: 0010_account_deletion +Create Date: 2026-08-23 + +Issue #341: registration requires a single-use invite code issued by the +owner, rather than being open to anyone who reaches /register. Only the +sha256 hash of the raw code is stored (same construction as +refresh_tokens.token_hash) -- a database read alone must never hand out a +working invite. +""" + +from alembic import op +import sqlalchemy as sa + +revision = "0011_invite_tokens" +down_revision = "0010_account_deletion" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "invite_tokens", + sa.Column("id", sa.String(length=36), primary_key=True), + sa.Column("code_hash", sa.String(length=64), nullable=False), + sa.Column("note", sa.String(length=255), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("redeemed_at", sa.DateTime(timezone=True), nullable=True), + sa.Column( + "redeemed_by_user_id", + sa.String(length=36), + sa.ForeignKey("users.id", ondelete="SET NULL", name="fk_invite_tokens_redeemed_by_user_id_users"), + nullable=True, + ), + ) + op.create_index("ix_invite_tokens_code_hash", "invite_tokens", ["code_hash"], unique=True) + + +def downgrade() -> None: + op.drop_index("ix_invite_tokens_code_hash", table_name="invite_tokens") + op.drop_table("invite_tokens") diff --git a/apps/backend/alembic/versions/0012_waitlist_entries.py b/apps/backend/alembic/versions/0012_waitlist_entries.py new file mode 100644 index 00000000..d1bd063c --- /dev/null +++ b/apps/backend/alembic/versions/0012_waitlist_entries.py @@ -0,0 +1,34 @@ +"""add waitlist_entries for the public landing-page waitlist + +Revision ID: 0012_waitlist_entries +Revises: 0011_invite_tokens +Create Date: 2026-08-23 + +Issue #334: the public landing page collects email/name signups for the +owner to review and invite manually, rather than open self-serve +registration. Deliberately not an account or an invite -- just a queue. +""" + +from alembic import op +import sqlalchemy as sa + +revision = "0012_waitlist_entries" +down_revision = "0011_invite_tokens" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "waitlist_entries", + sa.Column("id", sa.String(length=36), primary_key=True), + sa.Column("email", sa.String(length=320), nullable=False), + sa.Column("name", sa.String(length=200), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + ) + op.create_index("ix_waitlist_entries_email", "waitlist_entries", ["email"], unique=True) + + +def downgrade() -> None: + op.drop_index("ix_waitlist_entries_email", table_name="waitlist_entries") + op.drop_table("waitlist_entries") diff --git a/apps/backend/alembic/versions/0013_lineage_expand.py b/apps/backend/alembic/versions/0013_lineage_expand.py new file mode 100644 index 00000000..5944da22 --- /dev/null +++ b/apps/backend/alembic/versions/0013_lineage_expand.py @@ -0,0 +1,382 @@ +"""add repository_lineages, expand repositories, and backfill (1 of 2) + +Revision ID: 0013_lineage_expand +Revises: 0012_waitlist_entries +Create Date: 2026-08-27 + +Issue #299 (RFC-0002), authorized for implementation per +docs/architecture/REPOSITORY_LINEAGE_MIGRATION_PLAN.md. Adds the durable +owner-scoped logical grouping above repository revisions: a new +``repository_lineages`` table, nullable ``repositories.lineage_id`` / +``repositories.sequence`` columns, and a strict, deterministic backfill for +resolvable historical GitHub commits. + +This is deliberately split into two revisions (plan §7). This one creates +every constraint that does *not* require the second table to already exist, +runs the backfill, verifies it, then closes every constraint that only needs +one side of the eventual cyclic integrity boundary. The genuinely cyclic +constraint -- the lineage's latest-member pointer proving it names a +repository in that exact lineage -- is Revision B +(0014_lineage_constraints), so a failure here leaves this revision's state +additive, backward-compatible, and inspectable before retrying B. + +Backfill scope (plan §6): only ``source='github'`` rows with a valid 40-hex +commit SHA, a resolved ``refs/heads/...``/``refs/tags/...`` ref, and a +``source_url`` matching one of the exact accepted historical GitHub URL +forms are grouped into a lineage. Uploads, unresolved-ref rows, and anything +outside that strict grammar stay unlineaged standalone imports -- this +migration never guesses a grouping from a name, path, or network call. + +Imports must be quiesced while this migration runs; there is no dual-write +compatibility for a concurrent insert racing the backfill. +""" + +from __future__ import annotations + +import re +import uuid +from typing import Any + +from alembic import op +import sqlalchemy as sa + +revision = "0013_lineage_expand" +down_revision = "0012_waitlist_entries" +branch_labels = None +depends_on = None + +_GIT_SHA_RE = re.compile(r"^[0-9a-f]{40}$") +_REF_RE = re.compile(r"^refs/(?:heads|tags)/[A-Za-z0-9._/-]+$") +# The host is matched case-insensitively (scoped to just that group) because +# the host itself is one of the two things plan §6.1 step 3 requires +# case-folding -- a historical pre-hardening URL may have used "GitHub.com". +# The scheme/userinfo-marker literals stay exact-case; only the host is +# case-variable here. +_HTTPS_GITHUB_RE = re.compile(r"^https://(?i:github\.com)/([^/]+)/([^/]+?)(?:\.git)?/?$") +_SSH_GITHUB_RE = re.compile(r"^git@(?i:github\.com):([^/]+)/([^/]+?)(?:\.git)?$") +_SAFE_COMPONENT_RE = re.compile(r"^[A-Za-z0-9._-]+$") + +_CANONICAL_PARTIAL_WHERE = sa.text("canonical_source_key IS NOT NULL AND canonical_branch IS NOT NULL") + +# Fixed, migration-local UUIDv5 namespace for deterministic backfill lineage +# IDs (plan §6.2). Frozen forever once this revision ships, exactly like +# every other literal in an applied migration -- never imported from, or +# shared with, live application code, which is free to evolve independently. +_BACKFILL_UUID_NAMESPACE = uuid.UUID("ac9ac65f-f0c1-4f7f-8147-9dc3509b4eaa") + + +def _safe_component(value: str) -> bool: + return bool(_SAFE_COMPONENT_RE.fullmatch(value)) and ".." not in value + + +def _canonical_github_source(source_url: str | None) -> str | None: + """Strict, conservative GitHub URL canonicalization, backfill-only (plan + §6.1). Returns ``None`` for anything outside the exact accepted forms -- + the row then stays an unlineaged standalone import, never a guess. + + Deliberately not shared with ``app.services.repository_service``'s live + parser (which only needs to handle its own validator's already-narrow + output): a future change to that live code must never silently change + what this frozen, already-applied migration replays. + """ + if not source_url: + return None + trimmed = source_url.strip() + match = _HTTPS_GITHUB_RE.match(trimmed) or _SSH_GITHUB_RE.match(trimmed) + if not match: + return None + owner, repo = match.group(1), match.group(2) + if not (_safe_component(owner) and _safe_component(repo)): + return None + return f"github.com/{owner.lower()}/{repo.lower()}" + + +def _lineage_id_for(owner_id: str, canonical_source_key: str, canonical_branch: str) -> str: + """Deterministic, length-delimited encoding (plan §6.2) -- avoids the + ambiguity a plain concatenation would have (e.g. two different + owner/key/branch triples producing the same joined string).""" + encoded = ( + f"{len(owner_id)}:{owner_id}|" + f"{len(canonical_source_key)}:{canonical_source_key}|" + f"{len(canonical_branch)}:{canonical_branch}" + ) + return str(uuid.uuid5(_BACKFILL_UUID_NAMESPACE, encoded)) + + +def _create_lineage_table() -> None: + op.create_table( + "repository_lineages", + sa.Column("id", sa.String(length=36), primary_key=True), + sa.Column("owner_id", sa.String(length=36), nullable=False), + sa.Column("canonical_source_key", sa.Text(), nullable=True), + sa.Column("canonical_branch", sa.Text(), nullable=True), + sa.Column("display_name", sa.Text(), nullable=False), + # The cyclic half of this column's FK (proving the pointer names a + # repository in *this* lineage) is Revision B; it is a plain nullable + # column here. + sa.Column("latest_repository_id", sa.String(length=36), nullable=True), + sa.Column("next_sequence", sa.Integer(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.UniqueConstraint("id", "owner_id", name="uq_repository_lineages_id_owner"), + sa.CheckConstraint( + "(canonical_source_key IS NULL AND canonical_branch IS NULL) OR " + "(canonical_source_key IS NOT NULL AND canonical_branch IS NOT NULL)", + name="ck_repository_lineages_canonical_pair", + ), + sa.CheckConstraint("next_sequence >= 1", name="ck_repository_lineages_next_sequence_positive"), + sa.ForeignKeyConstraint( + ["owner_id"], + ["users.id"], + name="fk_repository_lineages_owner_id_users", + ondelete="CASCADE", + ), + ) + op.create_index("ix_repository_lineages_owner_id", "repository_lineages", ["owner_id"]) + op.create_index( + "uq_repository_lineages_owner_source_branch", + "repository_lineages", + ["owner_id", "canonical_source_key", "canonical_branch"], + unique=True, + sqlite_where=_CANONICAL_PARTIAL_WHERE, + postgresql_where=_CANONICAL_PARTIAL_WHERE, + ) + + +def _add_repository_lineage_columns() -> None: + # Both additions in one batch block (plan §7 step 2): avoids a second + # SQLite table copy for what is otherwise the same operation. + with op.batch_alter_table("repositories") as batch: + batch.add_column(sa.Column("lineage_id", sa.String(length=36), nullable=True)) + batch.add_column(sa.Column("sequence", sa.Integer(), nullable=True)) + + +def _repositories_core_table() -> sa.TableClause: + return sa.table( + "repositories", + sa.column("id", sa.String()), + sa.column("owner_id", sa.String()), + sa.column("name", sa.String()), + sa.column("source", sa.String()), + sa.column("source_url", sa.Text()), + sa.column("revision_kind", sa.String()), + sa.column("revision_value", sa.String()), + sa.column("revision_ref", sa.String()), + sa.column("created_at", sa.DateTime()), + sa.column("lineage_id", sa.String()), + sa.column("sequence", sa.Integer()), + ) + + +def _lineages_core_table() -> sa.TableClause: + return sa.table( + "repository_lineages", + sa.column("id", sa.String()), + sa.column("owner_id", sa.String()), + sa.column("canonical_source_key", sa.Text()), + sa.column("canonical_branch", sa.Text()), + sa.column("display_name", sa.Text()), + sa.column("latest_repository_id", sa.String()), + sa.column("next_sequence", sa.Integer()), + sa.column("created_at", sa.DateTime()), + ) + + +def _eligible_groups(connection: sa.Connection, repositories: sa.TableClause) -> dict[tuple[str, str, str], list[dict]]: + """Group resolvable historical GitHub rows by (owner, canonical source, + canonical branch), sorted deterministically within each group (plan + §6.1/§6.2). Everything else (uploads, unresolved refs, malformed/foreign + source URLs) is excluded and stays standalone.""" + rows = connection.execute( + sa.select( + repositories.c.id, + repositories.c.owner_id, + repositories.c.name, + repositories.c.source, + repositories.c.source_url, + repositories.c.revision_kind, + repositories.c.revision_value, + repositories.c.revision_ref, + repositories.c.created_at, + ) + ).mappings() + + groups: dict[tuple[str, str, str], list[dict[str, Any]]] = {} + for row in rows: + if row["source"] != "github" or row["revision_kind"] != "git": + continue + revision_value = row["revision_value"] + if not revision_value or not _GIT_SHA_RE.fullmatch(revision_value): + continue + revision_ref = row["revision_ref"] + if not revision_ref or not _REF_RE.fullmatch(revision_ref): + continue + canonical_source_key = _canonical_github_source(row["source_url"]) + if canonical_source_key is None: + continue + key = (row["owner_id"], canonical_source_key, revision_ref) + groups.setdefault(key, []).append(dict(row)) + + for members in groups.values(): + members.sort(key=lambda member: (member["created_at"], member["id"])) + return groups + + +def _backfill_lineages() -> dict[tuple[str, str, str], list[dict]]: + connection = op.get_bind() + repositories = _repositories_core_table() + lineages = _lineages_core_table() + + groups = _eligible_groups(connection, repositories) + + for (owner_id, canonical_source_key, canonical_branch), members in groups.items(): + lineage_id = _lineage_id_for(owner_id, canonical_source_key, canonical_branch) + first, last = members[0], members[-1] + next_sequence = len(members) + 1 + + already_present = connection.execute(sa.select(lineages.c.id).where(lineages.c.id == lineage_id)).first() + if already_present is None: + connection.execute( + lineages.insert().values( + id=lineage_id, + owner_id=owner_id, + canonical_source_key=canonical_source_key, + canonical_branch=canonical_branch, + display_name=first["name"], + latest_repository_id=last["id"], + next_sequence=next_sequence, + created_at=first["created_at"], + ) + ) + else: + # A prior interrupted run already created this row (plan §6.2/§7 + # "interruption and rerun"): reconcile its counter and latest + # pointer to this deterministic grouping rather than trusting + # whatever a partial run left behind. + connection.execute( + lineages.update() + .where(lineages.c.id == lineage_id) + .values(latest_repository_id=last["id"], next_sequence=next_sequence) + ) + + for index, member in enumerate(members, start=1): + connection.execute( + repositories.update() + .where(repositories.c.id == member["id"]) + .values(lineage_id=lineage_id, sequence=index) + ) + + return groups + + +def _verify_backfill(groups: dict[tuple[str, str, str], list[dict]]) -> None: + """Abort the migration unless every §6.4 invariant holds. Reuses the same + grouping the write phase just used (rather than re-deriving the + eligibility predicate a second time in SQL), so this proves the write + actually took effect as intended, not just that a second, possibly + independently-buggy, predicate agrees with the first.""" + connection = op.get_bind() + repositories = _repositories_core_table() + lineages = _lineages_core_table() + + touched = 0 + for (owner_id, canonical_source_key, canonical_branch), members in groups.items(): + lineage_id = _lineage_id_for(owner_id, canonical_source_key, canonical_branch) + lineage_row = ( + connection.execute( + sa.select(lineages.c.owner_id, lineages.c.latest_repository_id, lineages.c.next_sequence).where( + lineages.c.id == lineage_id + ) + ) + .mappings() + .first() + ) + if lineage_row is None: + raise RuntimeError(f"Lineage backfill verification failed: {lineage_id} is missing after backfill.") + if lineage_row["owner_id"] != owner_id: + raise RuntimeError(f"Lineage backfill verification failed: {lineage_id} has the wrong owner.") + if lineage_row["latest_repository_id"] != members[-1]["id"]: + raise RuntimeError(f"Lineage backfill verification failed: {lineage_id} latest pointer is wrong.") + if lineage_row["next_sequence"] != len(members) + 1: + raise RuntimeError(f"Lineage backfill verification failed: {lineage_id} next_sequence is wrong.") + + seen_sequences: set[int] = set() + for index, member in enumerate(members, start=1): + repo_row = ( + connection.execute( + sa.select(repositories.c.lineage_id, repositories.c.sequence, repositories.c.owner_id).where( + repositories.c.id == member["id"] + ) + ) + .mappings() + .first() + ) + if repo_row is None or repo_row["lineage_id"] != lineage_id or repo_row["sequence"] != index: + raise RuntimeError( + f"Lineage backfill verification failed: repository {member['id']} is not " + f"correctly attached to lineage {lineage_id}." + ) + if repo_row["owner_id"] != owner_id: + raise RuntimeError(f"Lineage backfill verification failed: repository {member['id']} owner mismatch.") + if repo_row["sequence"] in seen_sequences: + raise RuntimeError(f"Lineage backfill verification failed: duplicate sequence in lineage {lineage_id}.") + seen_sequences.add(repo_row["sequence"]) + touched += 1 + + stray = connection.execute( + sa.select(sa.func.count()) + .select_from(repositories) + .where(sa.or_(repositories.c.lineage_id.isnot(None), repositories.c.sequence.isnot(None))) + ).scalar() + if stray != touched: + raise RuntimeError( + f"Lineage backfill verification failed: {stray} repositories carry a lineage " + f"attachment, expected exactly {touched} from the eligible groups." + ) + + +def _add_repository_lineage_constraints() -> None: + with op.batch_alter_table("repositories") as batch: + batch.create_check_constraint( + "ck_repositories_lineage_sequence_pair", + "(lineage_id IS NULL AND sequence IS NULL) OR " + "(lineage_id IS NOT NULL AND sequence IS NOT NULL AND sequence >= 1)", + ) + # Every NULL is distinct under SQL uniqueness, so standalone rows + # (both null) never collide here (plan §4.2). + batch.create_unique_constraint("uq_repositories_lineage_sequence", ["lineage_id", "sequence"]) + # Composite target proving a lineage's latest-member pointer names a + # repository that actually belongs to that exact lineage (used by + # Revision B's FK). + batch.create_unique_constraint("uq_repositories_id_lineage", ["id", "lineage_id"]) + batch.create_foreign_key( + "fk_repositories_lineage_owner", + "repository_lineages", + ["lineage_id", "owner_id"], + ["id", "owner_id"], + deferrable=True, + initially="DEFERRED", + ) + + +def upgrade() -> None: + _create_lineage_table() + _add_repository_lineage_columns() + groups = _backfill_lineages() + _verify_backfill(groups) + _add_repository_lineage_constraints() + + +def downgrade() -> None: + # New grouping/counter data is lost; every pre-existing repository + # column and value is preserved (plan §7/§11). + with op.batch_alter_table("repositories") as batch: + batch.drop_constraint("fk_repositories_lineage_owner", type_="foreignkey") + batch.drop_constraint("uq_repositories_id_lineage", type_="unique") + batch.drop_constraint("uq_repositories_lineage_sequence", type_="unique") + batch.drop_constraint("ck_repositories_lineage_sequence_pair", type_="check") + batch.drop_column("sequence") + batch.drop_column("lineage_id") + + op.drop_index("uq_repository_lineages_owner_source_branch", table_name="repository_lineages") + op.drop_index("ix_repository_lineages_owner_id", table_name="repository_lineages") + op.drop_table("repository_lineages") diff --git a/apps/backend/alembic/versions/0014_lineage_constraints.py b/apps/backend/alembic/versions/0014_lineage_constraints.py new file mode 100644 index 00000000..30b52042 --- /dev/null +++ b/apps/backend/alembic/versions/0014_lineage_constraints.py @@ -0,0 +1,44 @@ +"""close the repository-lineage cyclic integrity boundary (2 of 2) + +Revision ID: 0014_lineage_constraints +Revises: 0013_lineage_expand +Create Date: 2026-08-27 + +Issue #299 (RFC-0002), part 2 of 2. Adds the one constraint that genuinely +needs both sides of the lineage/repository relationship to already exist: +``repository_lineages.latest_repository_id`` (together with ``id``) must +name a row in ``repositories`` whose own ``lineage_id`` points back at this +exact lineage -- a latest pointer can never name a repository in a +different lineage, or a different owner's repository, even if application +code is wrong. + +Split from Revision A specifically so a failure here leaves A's state +additive, backward-compatible, and inspectable/fixable before rerunning +this one (plan §7). The new application must not start unless Alembic is at +this revision or later -- do not start code that assumes the latest-member +invariant against a database still only at Revision A. +""" + +from alembic import op + +revision = "0014_lineage_constraints" +down_revision = "0013_lineage_expand" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + with op.batch_alter_table("repository_lineages") as batch: + batch.create_foreign_key( + "fk_repository_lineages_latest_member", + "repositories", + ["latest_repository_id", "id"], + ["id", "lineage_id"], + deferrable=True, + initially="DEFERRED", + ) + + +def downgrade() -> None: + with op.batch_alter_table("repository_lineages") as batch: + batch.drop_constraint("fk_repository_lineages_latest_member", type_="foreignkey") diff --git a/apps/backend/alembic/versions/0015_oauth_identities.py b/apps/backend/alembic/versions/0015_oauth_identities.py new file mode 100644 index 00000000..39ed229d --- /dev/null +++ b/apps/backend/alembic/versions/0015_oauth_identities.py @@ -0,0 +1,92 @@ +"""add oauth_identities, oauth_flow_states, oauth_pending_links + +Revision ID: 0015_oauth_identities +Revises: 0014_lineage_constraints +Create Date: 2026-08-29 + +Issue #288: Google and GitHub sign-in, built with credentials deferred (no +real OAuth application is registered yet -- see the issue comment for what +still needs the owner before this can go live). Three new tables, no +changes to any existing one: + +- ``oauth_identities``: durable, one row per linked (provider, PARTHA user) + pair. A given external identity can only ever belong to one PARTHA + account (unique on provider+subject); a user can link at most one + identity per provider (unique on provider+user). Cascades on user + deletion. +- ``oauth_flow_states``: one row per in-flight authorization request + (CSRF state, PKCE verifier, OIDC nonce). Single-use and short-lived -- + the service deletes each row the moment its callback is consumed. +- ``oauth_pending_links``: one row per verified external identity whose + email matched an existing account during a login attempt, awaiting the + account owner's explicit password confirmation before the two identities + are connected (never auto-linked by email match alone). +""" + +from alembic import op +import sqlalchemy as sa + +revision = "0015_oauth_identities" +down_revision = "0014_lineage_constraints" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "oauth_identities", + sa.Column("id", sa.String(length=36), primary_key=True), + sa.Column( + "user_id", + sa.String(length=36), + sa.ForeignKey("users.id", ondelete="CASCADE", name="fk_oauth_identities_user_id_users"), + nullable=False, + ), + sa.Column("provider", sa.String(length=32), nullable=False), + sa.Column("provider_subject", sa.String(length=255), nullable=False), + sa.Column("email", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.UniqueConstraint("provider", "provider_subject", name="uq_oauth_identities_provider_subject"), + sa.UniqueConstraint("provider", "user_id", name="uq_oauth_identities_provider_user"), + ) + op.create_index("ix_oauth_identities_user_id", "oauth_identities", ["user_id"]) + + op.create_table( + "oauth_flow_states", + sa.Column("id", sa.String(length=36), primary_key=True), + sa.Column("state_hash", sa.String(length=64), nullable=False), + sa.Column("provider", sa.String(length=32), nullable=False), + sa.Column("code_verifier", sa.String(length=128), nullable=True), + sa.Column("nonce", sa.String(length=64), nullable=True), + sa.Column("intent", sa.String(length=16), nullable=False), + sa.Column( + "link_user_id", + sa.String(length=36), + sa.ForeignKey("users.id", ondelete="CASCADE", name="fk_oauth_flow_states_link_user_id_users"), + nullable=True, + ), + sa.Column("frontend_redirect_base", sa.Text(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), + sa.CheckConstraint("intent IN ('login', 'link')", name="ck_oauth_flow_states_intent"), + ) + op.create_index("ix_oauth_flow_states_state_hash", "oauth_flow_states", ["state_hash"], unique=True) + + op.create_table( + "oauth_pending_links", + sa.Column("id", sa.String(length=36), primary_key=True), + sa.Column("provider", sa.String(length=32), nullable=False), + sa.Column("provider_subject", sa.String(length=255), nullable=False), + sa.Column("email", sa.Text(), nullable=False), + sa.Column("display_name", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), + ) + + +def downgrade() -> None: + op.drop_table("oauth_pending_links") + op.drop_index("ix_oauth_flow_states_state_hash", table_name="oauth_flow_states") + op.drop_table("oauth_flow_states") + op.drop_index("ix_oauth_identities_user_id", table_name="oauth_identities") + op.drop_table("oauth_identities") diff --git a/apps/backend/alembic/versions/0016_approved_emails.py b/apps/backend/alembic/versions/0016_approved_emails.py new file mode 100644 index 00000000..496c7f0c --- /dev/null +++ b/apps/backend/alembic/versions/0016_approved_emails.py @@ -0,0 +1,82 @@ +"""add approved_emails, seed the product owner's address + +Revision ID: 0016_approved_emails +Revises: 0015_oauth_identities +Create Date: 2026-08-29 + +Issue #374: replaces single-use invite codes (#341) with an admin-managed +approved-email allowlist as the registration gate. `invite_tokens` is left +in place as a historical audit record -- nothing drops it -- but nothing in +the live registration path consults it after this migration; only +``approved_emails`` does. + +Seeds exactly one row: the product owner's own address, so this migration +can never lock him out of the very system it's gating. No other real +account email could be identified anywhere in this codebase to also seed -- +the only pre-existing seed user (``users.id == +'00000000-0000-0000-0000-000000000000'``) is an explicit non-login system +placeholder (``password_hash`` is null, and both the password-login and +OAuth-login paths already refuse to authenticate it), not a real owner +account, so it is deliberately not approved here. +""" + +from datetime import UTC, datetime + +import sqlalchemy as sa +from alembic import op + +revision = "0016_approved_emails" +down_revision = "0015_oauth_identities" +branch_labels = None +depends_on = None + +# Kept in one place so upgrade() and downgrade() can never disagree on which +# row this migration is responsible for. +_SEEDED_EMAIL = "parthrohit60@gmail.com" + +approved_emails = sa.table( + "approved_emails", + sa.column("id", sa.String), + sa.column("email", sa.String), + sa.column("note", sa.String), + sa.column("added_by", sa.String), + sa.column("created_at", sa.DateTime), +) + + +def upgrade() -> None: + op.create_table( + "approved_emails", + sa.Column("id", sa.String(length=36), primary_key=True), + sa.Column("email", sa.String(length=320), nullable=False), + sa.Column("note", sa.String(length=255), nullable=True), + sa.Column("added_by", sa.String(length=255), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("used_at", sa.DateTime(timezone=True), nullable=True), + sa.Column( + "used_by_user_id", + sa.String(length=36), + sa.ForeignKey("users.id", ondelete="SET NULL", name="fk_approved_emails_used_by_user_id_users"), + nullable=True, + ), + sa.UniqueConstraint("email", name="uq_approved_emails_email"), + ) + op.create_index("ix_approved_emails_email", "approved_emails", ["email"], unique=True) + + op.bulk_insert( + approved_emails, + [ + { + "id": "00000000-0000-0000-0000-000000000001", + "email": _SEEDED_EMAIL, + "note": "Pre-approved: product owner, seeded by migration 0016 so this change can never lock him out.", + "added_by": "migration:0016_approved_emails", + "created_at": datetime.now(UTC), + } + ], + ) + + +def downgrade() -> None: + op.drop_index("ix_approved_emails_email", table_name="approved_emails") + op.drop_table("approved_emails") diff --git a/apps/backend/app/ai/__init__.py b/apps/backend/app/ai/__init__.py index 76308b0b..b4d68819 100644 --- a/apps/backend/app/ai/__init__.py +++ b/apps/backend/app/ai/__init__.py @@ -1,5 +1,12 @@ -from app.ai.orchestrator import AiOrchestrator, AiProviderConfigStore +from app.ai.orchestrator import AiOrchestrator from app.ai.prompt_builder import PromptBuilder +from app.ai.providers.config_store import EncryptedProviderConfigStore, ProviderConfigStore from app.ai.repository_context import RepositoryContextBuilder -__all__ = ["AiOrchestrator", "AiProviderConfigStore", "PromptBuilder", "RepositoryContextBuilder"] +__all__ = [ + "AiOrchestrator", + "EncryptedProviderConfigStore", + "ProviderConfigStore", + "PromptBuilder", + "RepositoryContextBuilder", +] diff --git a/apps/backend/app/ai/orchestrator.py b/apps/backend/app/ai/orchestrator.py index 46181596..40f24651 100644 --- a/apps/backend/app/ai/orchestrator.py +++ b/apps/backend/app/ai/orchestrator.py @@ -1,15 +1,17 @@ -import os from datetime import UTC, datetime from app.ai.prompt_builder import PromptBuilder +from app.ai.providers.capabilities import capability_for +from app.ai.providers.config_store import ProviderConfigStore from app.ai.providers.factory import ProviderFactory from app.ai.repository_context import RepositoryContextBuilder -from app.ai.types import DEFAULT_MODELS, AiProviderConfig, PromptBundle -from app.core.config import Settings +from app.ai.types import PromptBundle from app.core.exceptions import NotFoundError, ValidationServiceError +from app.repositories.ai_conversation_repository import AiConversationRepository from app.repositories.repository_repository import RepositoryRepository from app.schemas.ai import ( AiMessage, + AiProviderConfig, AiProviderPublicConfig, AiProviderTestRequest, AiProviderTestResponse, @@ -18,70 +20,24 @@ ) -class AiProviderConfigStore: - def __init__(self, settings: Settings) -> None: - self.path = settings.storage_path / "ai-provider.json" - - def get_public_config(self) -> AiProviderPublicConfig: - config = self.read_config() - if not config: - return AiProviderPublicConfig() - return AiProviderPublicConfig( - provider=config.provider, - model=config.model, - base_url=config.base_url, - has_api_key=bool(config.api_key), - ) - - def save_config(self, config: AiProviderConfig) -> AiProviderPublicConfig: - if config.provider != "ollama" and not config.api_key: - previous = self.read_config() - if previous and previous.provider == config.provider and previous.api_key: - config.api_key = previous.api_key - else: - raise ValidationServiceError("API key is required for this provider.") - - config.model = config.model or DEFAULT_MODELS[config.provider] - self.path.parent.mkdir(parents=True, exist_ok=True) - self.path.write_text(config.model_dump_json(), encoding="utf-8") - os.chmod(self.path, 0o600) - return self.get_public_config() - - def read_config(self) -> AiProviderConfig | None: - if not self.path.exists(): - return None - try: - return AiProviderConfig.model_validate_json(self.path.read_text(encoding="utf-8")) - except (ValueError, OSError): - return None - - def config_for_test(self, request: AiProviderTestRequest) -> AiProviderConfig: - saved = self.read_config() - provider = request.provider or saved.provider if saved else request.provider - if not provider: - raise ValidationServiceError("Choose an AI provider before testing.") - return AiProviderConfig( - provider=provider, - api_key=request.api_key or (saved.api_key if saved and saved.provider == provider else None), - model=request.model or (saved.model if saved and saved.provider == provider else None) or DEFAULT_MODELS[provider], - base_url=request.base_url or (saved.base_url if saved and saved.provider == provider else None), - ) - - class AiOrchestrator: def __init__( self, repository: RepositoryRepository, - config_store: AiProviderConfigStore, + config_store: ProviderConfigStore, context_builder: RepositoryContextBuilder, prompt_builder: PromptBuilder, provider_factory: ProviderFactory, + conversation_repository: AiConversationRepository, + owner_id: str, ) -> None: self.repository = repository self.config_store = config_store self.context_builder = context_builder self.prompt_builder = prompt_builder self.provider_factory = provider_factory + self.conversation_repository = conversation_repository + self.owner_id = owner_id def get_config(self) -> AiProviderPublicConfig: return self.config_store.get_public_config() @@ -94,31 +50,71 @@ async def test_connection(self, request: AiProviderTestRequest) -> AiProviderTes provider = self.provider_factory.resolve(config) prompt = PromptBundle(system_prompt="Reply with the single word: ok", user_prompt="Connection test.") await provider.complete(config, prompt) - return AiProviderTestResponse(ok=True, message=f"{config.provider} connection succeeded.", checked_at=datetime.now(UTC)) + return AiProviderTestResponse( + ok=True, message=f"{config.provider} connection succeeded.", checked_at=datetime.now(UTC) + ) + + def list_conversation(self, repository_id: str) -> list[AiMessage]: + # Same owner-scoping as query(): a non-owned repository id is + # indistinguishable from a missing one. + record = self.repository.get_for_owner(repository_id, self.owner_id) + if not record: + raise NotFoundError("Repository not found.", {"repositoryId": repository_id}) + return self.conversation_repository.list_conversation(repository_id, self.owner_id) async def query(self, request: AiQueryRequest) -> AiQueryResponse: - record = self.repository.get(request.repository_id) + # Owner-scoped: another user's repository id resolves to None and gets + # the same 404 as a missing one, and the provider config read below is + # this user's own key — so a query can never run against, or be billed + # to, someone else's repository or provider. + record = self.repository.get_for_owner(request.repository_id, self.owner_id) if not record: raise NotFoundError("Repository not found.", {"repositoryId": request.repository_id}) + selected_file = request.context.selected_file if request.context else None + # Resolve the owner-scoped current-revision snapshot before provider + # configuration or invocation. Missing/stale analysis is a 404, not a + # misleading provider error. + repository_context = self.context_builder.build(record, selected_file) config = self.config_store.read_config() - if not config: + if config is None: raise ValidationServiceError("AI provider is not configured. Open Settings and save a provider first.") + if capability_for(config.provider).requires_api_key and not config.api_key: + raise ValidationServiceError( + "AI provider API key is missing. Open Settings and save your provider API key." + ) - selected_file = request.context.selected_file if request.context else None - repository_context = self.context_builder.build(record, selected_file) + user_turn_at = datetime.now(UTC) prompt = self.prompt_builder.build(repository_context, request.query) provider = self.provider_factory.resolve(config) provider_response = await provider.complete(config, prompt) + assistant_turn_at = datetime.now(UTC) + citations = [citation.to_schema() for citation in repository_context.citations] or None + + # Persisted only once the provider has actually answered, so a failed + # call never leaves a stored question without its reply. + self.conversation_repository.append_turns( + repository_id=request.repository_id, + owner_id=self.owner_id, + turns=[ + ("user", request.query, None, user_turn_at), + ( + "assistant", + provider_response.content, + [citation.model_dump() for citation in citations] if citations else None, + assistant_turn_at, + ), + ], + ) + return AiQueryResponse( message=AiMessage( role="assistant", content=provider_response.content, - timestamp=datetime.now(UTC), - citations=[citation.to_schema() for citation in repository_context.citations], + timestamp=assistant_turn_at, + citations=citations, ), - suggestions=[ - "Explain the main architecture boundaries.", - "What files should I read first?", - "What are the highest-risk engineering issues?", - ], + # Suggestions are not computed from the provider response or the + # repository context yet. Return an explicit empty list instead of + # presenting generic prompts as analysis-derived recommendations. + suggestions=[], ) diff --git a/apps/backend/app/ai/prompt_builder.py b/apps/backend/app/ai/prompt_builder.py index a6c2af0f..fbd9a349 100644 --- a/apps/backend/app/ai/prompt_builder.py +++ b/apps/backend/app/ai/prompt_builder.py @@ -8,8 +8,13 @@ def build(self, repository_context: RepositoryContext, question: str) -> PromptB return PromptBundle( system_prompt=( f"You are PARTHA's repository assistant for {repository_name}. " - "Answer only from the provided repository context when possible. " - "Mention relevant file paths explicitly and say when evidence is missing.\n\n" + "The context below describes the repository's structure and metadata " + "only (languages, frameworks, modules, dependencies, file paths). Module " + "roles are explicitly heuristic classifications. It does " + "NOT include source-file contents or line numbers. " + "Answer from this context when possible, refer to files by path, and do " + "not claim specific line numbers or quote code you were not given. " + "State clearly when the context is insufficient to answer.\n\n" f"Repository context:\n{context}" ), user_prompt=question, @@ -19,20 +24,38 @@ def build(self, repository_context: RepositoryContext, question: str) -> PromptB def render_repository_context(self, repository_context: RepositoryContext) -> str: architecture = repository_context.architecture context_lines = [ + f"Snapshot: {repository_context.snapshot.id} ({repository_context.snapshot.schema_version})", + ( + "Repository revision: " + f"{repository_context.snapshot.revision_kind}:{repository_context.snapshot.revision_value}" + ), f"Primary language: {architecture.primary_language}", f"Frameworks: {', '.join(architecture.frameworks) if architecture.frameworks else 'Not detected'}", f"Entry points: {', '.join(architecture.entry_points) if architecture.entry_points else 'Not found'}", - "Modules:", - *[ - f"- {module.name} ({module.role}, {module.file_count} files)" - for module in architecture.modules - ], + "Modules (roles are heuristic classifications):", + *[f"- {module.name} ({module.role}, {module.file_count} files)" for module in architecture.modules], "Dependencies:", *[ - f"- {dependency.name} {dependency.version}" + self._render_dependency( + dependency.name, dependency.version, dependency.declared_versions, dependency.has_version_conflict + ) for dependency in repository_context.dependencies ], "Files:", *[f"- {file.path}" for file in repository_context.selected_files], ] return "\n".join(context_lines) + + @staticmethod + def _render_dependency( + name: str, + version: str | None, + declared_versions: tuple[str | None, ...], + has_version_conflict: bool, + ) -> str: + if has_version_conflict: + versions = ", ".join(item if item is not None else "no specifier" for item in declared_versions) + return f"- {name} (conflicting declared versions: {versions})" + if version is None: + return f"- {name} (no version/specifier declared)" + return f"- {name} {version}" diff --git a/apps/backend/app/ai/providers/__init__.py b/apps/backend/app/ai/providers/__init__.py index 40061bfd..c4bef404 100644 --- a/apps/backend/app/ai/providers/__init__.py +++ b/apps/backend/app/ai/providers/__init__.py @@ -2,7 +2,6 @@ from app.ai.providers.base import AiProvider from app.ai.providers.factory import ProviderFactory from app.ai.providers.gemini import GeminiProvider -from app.ai.providers.legacy import LegacyProvider from app.ai.providers.ollama import OllamaProvider from app.ai.providers.openai import OpenAIProvider from app.ai.providers.openrouter import OpenRouterProvider @@ -13,7 +12,6 @@ "AnthropicProvider", "GeminiProvider", "ProviderFactory", - "LegacyProvider", "OllamaProvider", "OpenAIProvider", "OpenRouterProvider", diff --git a/apps/backend/app/ai/providers/anthropic.py b/apps/backend/app/ai/providers/anthropic.py index 764c38f0..c54b5537 100644 --- a/apps/backend/app/ai/providers/anthropic.py +++ b/apps/backend/app/ai/providers/anthropic.py @@ -1,9 +1,12 @@ -from app.ai.providers.http import post, require_api_key +from app.ai.providers.http import ProviderHttpSender, post, require_api_key from app.ai.types import DEFAULT_MODELS, AiProviderConfig, AiProviderResponse, PromptBundle from app.core.exceptions import ExternalServiceError class AnthropicProvider: + def __init__(self, sender: ProviderHttpSender | None = None) -> None: + self.sender = sender + async def complete(self, config: AiProviderConfig, prompt: PromptBundle) -> AiProviderResponse: require_api_key(config) payload = { @@ -15,6 +18,7 @@ async def complete(self, config: AiProviderConfig, prompt: PromptBundle) -> AiPr response = await post( config, "https://api.anthropic.com/v1/messages", + sender=self.sender, headers={"x-api-key": config.api_key or "", "anthropic-version": "2023-06-01"}, json=payload, ) diff --git a/apps/backend/app/ai/providers/base.py b/apps/backend/app/ai/providers/base.py index 702a3b79..2ca5d024 100644 --- a/apps/backend/app/ai/providers/base.py +++ b/apps/backend/app/ai/providers/base.py @@ -4,5 +4,4 @@ class AiProvider(Protocol): - async def complete(self, config: AiProviderConfig, prompt: PromptBundle) -> AiProviderResponse: - ... + async def complete(self, config: AiProviderConfig, prompt: PromptBundle) -> AiProviderResponse: ... diff --git a/apps/backend/app/ai/providers/capabilities.py b/apps/backend/app/ai/providers/capabilities.py new file mode 100644 index 00000000..f9c6ea7c --- /dev/null +++ b/apps/backend/app/ai/providers/capabilities.py @@ -0,0 +1,109 @@ +"""Static, non-secret setup metadata for each supported AI provider (#291). + +This is the one place provider-specific setup facts live. Config validation +(config_store.py), the query-time missing-key guard (orchestrator.py), and +the public capability endpoint the frontend renders its setup guidance from +all read the same PROVIDER_CAPABILITIES entries, so there is exactly one +place to update if a provider's requirements ever change -- never a second +hardcoded provider matrix to keep in sync. + +Nothing here is a secret: no API key, provider token, environment value, or +private endpoint. `setup_url` points at each provider's own official, +publicly documented setup/key page -- it is not consulted by, and does not +widen, the server-side egress allowlist in app/core/ai_egress.py; it exists +only for the browser to open in a new tab. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from app.ai.types import DEFAULT_MODELS +from app.schemas.ai import AiProvider + + +@dataclass(frozen=True) +class ProviderCapability: + provider: AiProvider + display_name: str + requires_api_key: bool + requires_base_url: bool + default_model: str + setup_url: str + get_started_hint: str + support_state: str = "supported" + + def setup_steps(self) -> list[str]: + """Compose the (at most 4) short setup steps from this provider's facts. + + Only `get_started_hint` is per-provider prose; the remaining steps + are generated from requires_api_key/requires_base_url/default_model + so the actual step wording can't drift out of sync with what the + save/test flow actually requires. + """ + + steps = [self.get_started_hint] + if self.requires_base_url: + steps.append("Enter the base URL where it's running.") + if self.requires_api_key: + steps.append("Paste in the API key.") + steps.append(f"Confirm the model ID (default: {self.default_model}).") + steps.append("Test the connection, then save.") + return steps + + +PROVIDER_CAPABILITIES: dict[AiProvider, ProviderCapability] = { + "openai": ProviderCapability( + provider="openai", + display_name="OpenAI", + requires_api_key=True, + requires_base_url=False, + default_model=DEFAULT_MODELS["openai"], + setup_url="https://platform.openai.com/api-keys", + get_started_hint="Create an OpenAI account and generate an API key.", + ), + "anthropic": ProviderCapability( + provider="anthropic", + display_name="Anthropic", + requires_api_key=True, + requires_base_url=False, + default_model=DEFAULT_MODELS["anthropic"], + setup_url="https://console.anthropic.com/settings/keys", + get_started_hint="Create an Anthropic account and generate an API key.", + ), + "gemini": ProviderCapability( + provider="gemini", + display_name="Google Gemini", + requires_api_key=True, + requires_base_url=False, + default_model=DEFAULT_MODELS["gemini"], + setup_url="https://aistudio.google.com/apikey", + get_started_hint="Create a Google AI Studio API key.", + ), + "openrouter": ProviderCapability( + provider="openrouter", + display_name="OpenRouter", + requires_api_key=True, + requires_base_url=False, + default_model=DEFAULT_MODELS["openrouter"], + setup_url="https://openrouter.ai/keys", + get_started_hint="Create an OpenRouter account and generate an API key.", + ), + "ollama": ProviderCapability( + provider="ollama", + display_name="Ollama", + requires_api_key=False, + requires_base_url=True, + default_model=DEFAULT_MODELS["ollama"], + setup_url="https://ollama.com/download", + get_started_hint="Install and start Ollama, either locally or on a server you control.", + ), +} + + +def capability_for(provider: AiProvider) -> ProviderCapability: + return PROVIDER_CAPABILITIES[provider] + + +def all_capabilities() -> list[ProviderCapability]: + return list(PROVIDER_CAPABILITIES.values()) diff --git a/apps/backend/app/ai/providers/config_store.py b/apps/backend/app/ai/providers/config_store.py new file mode 100644 index 00000000..662a515a --- /dev/null +++ b/apps/backend/app/ai/providers/config_store.py @@ -0,0 +1,169 @@ +"""Owner-scoped, encrypted-at-rest storage for AI provider configuration. + +Every read and write is scoped to a single ``owner_id``, so one user can never +observe or spend another user's provider key (E1.5 / #65, and the credential +half of the ``ai/*`` scoping in #63). API keys are encrypted with +:class:`ProviderKeyCipher` before they touch the database and are decrypted only +in-process, at request time, to talk to the provider — they are never returned +to the client in full (the public config exposes only ``last4``). +""" + +from datetime import UTC, datetime +from typing import Protocol +from uuid import uuid4 + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.core.ai_egress import DestinationPolicyError, ProviderEgressPolicy +from app.ai.providers.capabilities import capability_for +from app.ai.types import DEFAULT_MODELS, AiProviderConfig +from app.core.crypto import InvalidToken, ProviderKeyCipher +from app.core.exceptions import ValidationServiceError +from app.models.ai_provider_config import AiProviderConfigRecord +from app.schemas.ai import AiProviderPublicConfig, AiProviderTestRequest + + +class ProviderConfigStore(Protocol): + """The surface the orchestrator depends on, independent of storage.""" + + def get_public_config(self) -> AiProviderPublicConfig: + raise NotImplementedError + + def save_config(self, config: AiProviderConfig) -> AiProviderPublicConfig: + raise NotImplementedError + + def read_config(self) -> AiProviderConfig | None: + raise NotImplementedError + + def config_for_test(self, request: AiProviderTestRequest) -> AiProviderConfig: + raise NotImplementedError + + +class EncryptedProviderConfigStore: + """Persists one provider configuration per user, key encrypted at rest.""" + + def __init__( + self, + db: Session, + cipher: ProviderKeyCipher, + owner_id: str, + egress_policy: ProviderEgressPolicy | None = None, + ) -> None: + self.db = db + self.cipher = cipher + self.owner_id = owner_id + if egress_policy is None: + from app.core.config import get_settings + + egress_policy = ProviderEgressPolicy.from_settings(get_settings()) + self.egress_policy = egress_policy + + def _record(self) -> AiProviderConfigRecord | None: + statement = select(AiProviderConfigRecord).where(AiProviderConfigRecord.owner_id == self.owner_id) + return self.db.scalars(statement).first() + + def _decrypt_key(self, record: AiProviderConfigRecord) -> str | None: + if not record.encrypted_api_key: + return None + try: + return self.cipher.decrypt(record.encrypted_api_key) + except InvalidToken: + # Ciphertext that no longer decrypts (e.g. the encryption key was + # rotated without re-encrypting) is treated as "no usable key" so + # the caller surfaces the clear missing-key error rather than a 500. + return None + + def get_public_config(self) -> AiProviderPublicConfig: + record = self._record() + if record is None: + return AiProviderPublicConfig() + return AiProviderPublicConfig( + provider=record.provider, + model=record.model, + base_url=record.base_url, + has_api_key=bool(record.encrypted_api_key), + api_key_last4=record.api_key_last4, + ) + + def read_config(self) -> AiProviderConfig | None: + record = self._record() + if record is None: + return None + return AiProviderConfig( + provider=record.provider, + api_key=self._decrypt_key(record), + model=record.model, + base_url=record.base_url, + ) + + def save_config(self, config: AiProviderConfig) -> AiProviderPublicConfig: + # Validate the complete destination policy before touching a record or + # constructing ciphertext. A denied save therefore cannot create or + # partially mutate a provider configuration. + try: + self.egress_policy.validate_config(config) + except DestinationPolicyError as exc: + raise ValidationServiceError("AI provider destination is not permitted.") from exc + record = self._record() + encrypted, last4 = self._resolve_key(config, record) + model = config.model or DEFAULT_MODELS[config.provider] + now = datetime.now(UTC) + + if record is None: + record = AiProviderConfigRecord( + id=str(uuid4()), + owner_id=self.owner_id, + provider=config.provider, + encrypted_api_key=encrypted, + api_key_last4=last4, + model=model, + base_url=config.base_url, + created_at=now, + updated_at=now, + ) + self.db.add(record) + else: + record.provider = config.provider + record.encrypted_api_key = encrypted + record.api_key_last4 = last4 + record.model = model + record.base_url = config.base_url + record.updated_at = now + + self.db.commit() + self.db.refresh(record) + return self.get_public_config() + + def _resolve_key( + self, config: AiProviderConfig, record: AiProviderConfigRecord | None + ) -> tuple[str | None, str | None]: + """Determine the ciphertext + last-4 to persist for this save. + + A new key is encrypted fresh. An omitted key carries the previously + stored ciphertext forward (so re-saving model/base_url does not require + re-entering the key), and only a provider whose capability entry says + it doesn't require one (currently just ollama) may be saved with no + key at all. + """ + requires_api_key = capability_for(config.provider).requires_api_key + if config.api_key: + return self.cipher.encrypt(config.api_key), config.api_key[-4:] + if requires_api_key and record is not None and record.provider == config.provider and record.encrypted_api_key: + return record.encrypted_api_key, record.api_key_last4 + if not requires_api_key: + return None, None + raise ValidationServiceError("API key is required for this provider.") + + def config_for_test(self, request: AiProviderTestRequest) -> AiProviderConfig: + saved = self.read_config() + provider = request.provider or (saved.provider if saved else None) + if not provider: + raise ValidationServiceError("Choose an AI provider before testing.") + same_provider = saved is not None and saved.provider == provider + return AiProviderConfig( + provider=provider, + api_key=request.api_key or (saved.api_key if same_provider else None), + model=request.model or (saved.model if same_provider else None) or DEFAULT_MODELS[provider], + base_url=request.base_url or (saved.base_url if same_provider else None), + ) diff --git a/apps/backend/app/ai/providers/gemini.py b/apps/backend/app/ai/providers/gemini.py index 3145df3d..b06d9737 100644 --- a/apps/backend/app/ai/providers/gemini.py +++ b/apps/backend/app/ai/providers/gemini.py @@ -1,14 +1,25 @@ -from app.ai.providers.http import post, require_api_key +from app.ai.providers.http import ProviderHttpSender, post, require_api_key from app.ai.types import DEFAULT_MODELS, AiProviderConfig, AiProviderResponse, PromptBundle from app.core.exceptions import ExternalServiceError class GeminiProvider: + def __init__(self, sender: ProviderHttpSender | None = None) -> None: + self.sender = sender + async def complete(self, config: AiProviderConfig, prompt: PromptBundle) -> AiProviderResponse: require_api_key(config) url = f"https://generativelanguage.googleapis.com/v1beta/models/{config.model or DEFAULT_MODELS['gemini']}:generateContent" payload = {"contents": [{"parts": [{"text": f"{prompt.system_prompt}\n\nQuestion: {prompt.user_prompt}"}]}]} - response = await post(config, url, params={"key": config.api_key}, json=payload) + # Keep credentials out of the URL. HTTP clients and intermediaries + # commonly log request URLs, while Gemini supports the API key header. + response = await post( + config, + url, + sender=self.sender, + headers={"x-goog-api-key": config.api_key or ""}, + json=payload, + ) try: parts = response.json()["candidates"][0]["content"]["parts"] return AiProviderResponse(content="\n".join(part.get("text", "") for part in parts).strip()) diff --git a/apps/backend/app/ai/providers/http.py b/apps/backend/app/ai/providers/http.py index e6357eee..29997446 100644 --- a/apps/backend/app/ai/providers/http.py +++ b/apps/backend/app/ai/providers/http.py @@ -1,7 +1,96 @@ +"""Pinned HTTP sender shared by every AI provider implementation.""" + +from __future__ import annotations + +from typing import Protocol + +import anyio import httpx +from app.core.ai_egress import DestinationPolicyError, ProviderEgressPolicy from app.ai.types import AiProviderConfig -from app.core.exceptions import ExternalServiceError, ValidationServiceError +from app.core.exceptions import ExternalServiceError, TimeoutServiceError, ValidationServiceError + + +class ProviderHttpSender(Protocol): + async def post( + self, + config: AiProviderConfig, + url: str, + *, + timeout: httpx.Timeout | float | None = None, + **kwargs: object, + ) -> httpx.Response: + raise NotImplementedError + + +class RedirectDeniedError(Exception): + """Signals a redirect without ever evaluating its Location header.""" + + +class SecureProviderHttpSender: + """Send one provider request through the central policy and a pinned IP. + + The policy resolves and validates the original hostname immediately before + this method creates a request. The request URL uses the resulting literal + IP, while ``Host`` and HTTPS SNI retain the normalized original hostname. + ``trust_env=False`` prevents environment proxy settings from sending the + request around the policy. + """ + + def __init__(self, policy: ProviderEgressPolicy, *, transport: httpx.AsyncBaseTransport | None = None) -> None: + self.policy = policy + self.transport = transport + + async def post( + self, + config: AiProviderConfig, + url: str, + *, + timeout: httpx.Timeout | float | None = None, + **kwargs: object, + ) -> httpx.Response: + # Policy preparation performs DNS resolution. Keep that blocking call + # off the event loop used by the async AI routes. + pinned = await anyio.to_thread.run_sync(self.policy.prepare_request, config, url) + request_headers = httpx.Headers(kwargs.pop("headers", None)) + # The original authority, never the pinned IP, is what the provider and + # virtual host expect. Overwrite rather than trust a caller-provided + # Host header so there is a single validated destination identity. + request_headers["Host"] = pinned.destination.host_header + # Make the production retry policy explicit. A connection retry must + # never trigger another hostname resolution inside the HTTP stack; the + # application instead fails and revalidates DNS on the next request. + transport = self.transport or httpx.AsyncHTTPTransport(verify=True, retries=0) + + # 60s suits a hosted provider's request/response. A caller that knows + # its endpoint behaves differently -- a local Ollama, whose first call + # loads the model and whose generation runs on the user's own CPU -- + # passes an explicit timeout instead of being cut off mid-completion. + async with httpx.AsyncClient( + timeout=timeout if timeout is not None else 60, + verify=True, + trust_env=False, + follow_redirects=False, + transport=transport, + ) as client: + request = client.build_request("POST", pinned.connection_url, headers=request_headers, **kwargs) + # httpcore's documented request extension preserves TLS SNI (and + # therefore hostname verification) when connecting to a literal IP. + request.extensions["sni_hostname"] = pinned.destination.host + response = await client.send(request) + + if 300 <= response.status_code < 400: + raise RedirectDeniedError() + return response + + +def _default_sender() -> SecureProviderHttpSender: + # Keep the import lazy to avoid a config -> provider import cycle and to + # make the production resolver easy to replace in direct unit tests. + from app.core.config import get_settings + + return SecureProviderHttpSender(ProviderEgressPolicy.from_settings(get_settings())) def require_api_key(config: AiProviderConfig) -> None: @@ -9,11 +98,77 @@ def require_api_key(config: AiProviderConfig) -> None: raise ValidationServiceError("API key is required for the selected AI provider.") -async def post(config: AiProviderConfig, url: str, **kwargs) -> httpx.Response: +def _plain_language_status_error(config: AiProviderConfig, exc: httpx.HTTPStatusError) -> Exception: + """Translate a provider's HTTP status into one of #291's named plain- + language failure modes, using only the status code -- never the response + body, whose shape differs per provider and could itself carry sensitive + detail worth not repeating verbatim to the client.""" + + status = exc.response.status_code + if status in (401, 403): + return ValidationServiceError( + "AI provider rejected the API key. Confirm the key is correct and still active, then save it again.", + {"provider": config.provider}, + ) + if status == 429: + return ExternalServiceError( + "AI provider rate limit reached. Wait a moment and try again.", + {"provider": config.provider}, + ) + if status in (400, 404, 422): + return ValidationServiceError( + "AI provider rejected the request, most likely because of an unsupported model ID. " + "Confirm the model ID and try again.", + {"provider": config.provider}, + ) + return ExternalServiceError("AI provider request failed.", {"provider": config.provider}) + + +async def post( + config: AiProviderConfig, + url: str, + *, + sender: ProviderHttpSender | None = None, + timeout: httpx.Timeout | float | None = None, + **kwargs: object, +) -> httpx.Response: + """Apply normalized provider errors around the central outbound sender. + + ``timeout`` overrides the sender's default (60s) for one request -- used by + the Ollama provider, whose local, CPU-bound generation legitimately runs + far longer than any hosted API call. + """ + + active_sender = sender or _default_sender() try: - async with httpx.AsyncClient(timeout=60) as client: - response = await client.post(url, **kwargs) - response.raise_for_status() - return response - except (httpx.HTTPError, KeyError, IndexError, TypeError) as exc: + response = await active_sender.post(config, url, timeout=timeout, **kwargs) + response.raise_for_status() + return response + except DestinationPolicyError as exc: + raise ValidationServiceError("AI provider destination is not permitted.") from exc + except RedirectDeniedError as exc: + raise ExternalServiceError("AI provider request failed.", {"provider": config.provider}) from exc + except httpx.HTTPStatusError as exc: + raise _plain_language_status_error(config, exc) from exc + except (httpx.ConnectError, httpx.ConnectTimeout) as exc: + # httpx.ConnectTimeout does NOT inherit from httpx.ConnectError in + # this version (both are separate httpx.TransportError subclasses), + # so it needs its own explicit clause here, ahead of the broader + # httpx.TimeoutException below -- a timeout during the connect phase + # itself (as opposed to a slow response after connecting fine) reads + # the same as "unreachable" here. The most common cause in practice + # is a local/self-hosted provider (Ollama) that isn't running, or a + # base URL that's wrong -- both surface identically at this layer, + # so the message covers both rather than guessing which one it was. + raise ExternalServiceError( + "Could not reach the AI provider. If it's self-hosted (e.g. Ollama), confirm it's running and " + "the base URL is correct.", + {"provider": config.provider}, + ) from exc + except httpx.TimeoutException as exc: + raise TimeoutServiceError( + "AI provider did not respond in time. Try again in a moment.", + {"provider": config.provider}, + ) from exc + except (httpx.HTTPError, KeyError, IndexError, TypeError, ValueError) as exc: raise ExternalServiceError("AI provider request failed.", {"provider": config.provider}) from exc diff --git a/apps/backend/app/ai/providers/legacy.py b/apps/backend/app/ai/providers/legacy.py deleted file mode 100644 index 186e159a..00000000 --- a/apps/backend/app/ai/providers/legacy.py +++ /dev/null @@ -1,82 +0,0 @@ -import httpx - -from app.ai.types import DEFAULT_MODELS, AiProviderConfig, AiProviderResponse, PromptBundle -from app.core.exceptions import ExternalServiceError, ValidationServiceError - - -class LegacyProvider: - async def complete(self, config: AiProviderConfig, prompt: PromptBundle) -> AiProviderResponse: - if config.provider != "ollama" and not config.api_key: - raise ValidationServiceError("API key is required for the selected AI provider.") - - try: - async with httpx.AsyncClient(timeout=60) as client: - if config.provider == "openai": - payload = { - "model": config.model or DEFAULT_MODELS["openai"], - "messages": [ - {"role": "system", "content": prompt.system_prompt}, - {"role": "user", "content": prompt.user_prompt}, - ], - } - response = await client.post( - "https://api.openai.com/v1/chat/completions", - headers={"Authorization": f"Bearer {config.api_key}"}, - json=payload, - ) - response.raise_for_status() - return AiProviderResponse(content=response.json()["choices"][0]["message"]["content"]) - if config.provider == "anthropic": - payload = { - "model": config.model or DEFAULT_MODELS["anthropic"], - "max_tokens": 1200, - "system": prompt.system_prompt, - "messages": [{"role": "user", "content": prompt.user_prompt}], - } - response = await client.post( - "https://api.anthropic.com/v1/messages", - headers={"x-api-key": config.api_key or "", "anthropic-version": "2023-06-01"}, - json=payload, - ) - response.raise_for_status() - parts = response.json().get("content", []) - return AiProviderResponse( - content="\n".join(part.get("text", "") for part in parts if part.get("type") == "text").strip() - ) - if config.provider == "gemini": - url = f"https://generativelanguage.googleapis.com/v1beta/models/{config.model or DEFAULT_MODELS['gemini']}:generateContent" - payload = {"contents": [{"parts": [{"text": f"{prompt.system_prompt}\n\nQuestion: {prompt.user_prompt}"}]}]} - response = await client.post(url, params={"key": config.api_key}, json=payload) - response.raise_for_status() - parts = response.json()["candidates"][0]["content"]["parts"] - return AiProviderResponse(content="\n".join(part.get("text", "") for part in parts).strip()) - if config.provider == "openrouter": - payload = { - "model": config.model or DEFAULT_MODELS["openrouter"], - "messages": [ - {"role": "system", "content": prompt.system_prompt}, - {"role": "user", "content": prompt.user_prompt}, - ], - } - response = await client.post( - "https://openrouter.ai/api/v1/chat/completions", - headers={"Authorization": f"Bearer {config.api_key}"}, - json=payload, - ) - response.raise_for_status() - return AiProviderResponse(content=response.json()["choices"][0]["message"]["content"]) - - base_url = (config.base_url or "http://localhost:11434").rstrip("/") - payload = { - "model": config.model or DEFAULT_MODELS["ollama"], - "stream": False, - "messages": [ - {"role": "system", "content": prompt.system_prompt}, - {"role": "user", "content": prompt.user_prompt}, - ], - } - response = await client.post(f"{base_url}/api/chat", json=payload) - response.raise_for_status() - return AiProviderResponse(content=response.json()["message"]["content"]) - except (httpx.HTTPError, KeyError, IndexError, TypeError) as exc: - raise ExternalServiceError("AI provider request failed.", {"provider": config.provider}) from exc diff --git a/apps/backend/app/ai/providers/ollama.py b/apps/backend/app/ai/providers/ollama.py index a6f33071..10deeadf 100644 --- a/apps/backend/app/ai/providers/ollama.py +++ b/apps/backend/app/ai/providers/ollama.py @@ -1,11 +1,62 @@ -from app.ai.providers.http import post +import anyio +import httpx + +from app.ai.providers.http import ProviderHttpSender, post from app.ai.types import DEFAULT_MODELS, AiProviderConfig, AiProviderResponse, PromptBundle from app.core.exceptions import ExternalServiceError +# Ollama runs inference on the same machine PARTHA itself runs on -- unlike a +# hosted provider (OpenAI, Anthropic, Gemini, OpenRouter), a concurrent +# request here competes with the user's own machine for CPU and memory, not +# with someone else's infrastructure, so this limit stays specific to this +# provider rather than living in the shared post() transport (#414). +# +# Measured directly against a real Ollama instance: its own backend already +# serializes actual token generation to one request at a time by default, so +# letting more requests through here doesn't get anything done faster -- it +# only means more requests are simultaneously held open, each consuming its +# own memory while it waits its turn, and the elevated CPU/memory load runs +# as one long unbroken stretch instead of several shorter ones with recovery +# gaps between them. A single local machine has no spare parallel headroom +# to give this, and Ollama isn't going to use extra concurrency for extra +# throughput anyway, so this is capped to exactly 1. A request beyond the +# limit queues and waits -- it never fails or gets rejected; this is +# resource management, not a rate limit. +# +# anyio.Semaphore rather than asyncio.Semaphore: a module-level +# asyncio.Semaphore permanently binds itself to whichever event loop first +# contends it, which is a non-issue for the one persistent loop a real +# server runs on, but breaks the moment two separate loops ever contend it +# (a real hazard for a shared, importable module-level object). anyio's +# version is the one already used elsewhere in this provider layer +# (app/ai/providers/http.py) and doesn't have that failure mode. +_MAX_CONCURRENT_REQUESTS = 1 +_concurrency_limit = anyio.Semaphore(_MAX_CONCURRENT_REQUESTS) + +# The shared sender's 60s default is sized for a hosted API's request/response. +# Ollama is neither: the first call after startup (or after another model was +# loaded) pulls the model into memory before a single token is generated, and +# the generation itself runs on whatever CPU/GPU the user's own machine has. +# A review-sized completion routinely exceeds 60s that way -- entirely +# healthy, not a stuck request -- and cutting it off there is the "it hangs +# then fails" symptom this replaces. So: +# * connect: 10s. Ollama is either up on the LAN/loopback (connects fast) or +# it isn't; a wrong/unreachable base URL should fail quickly, not after a +# long minute, so this stays tight while the read budget is loosened. +# * read: 10 minutes. Bounds a genuinely wedged server without amputating a +# legitimately slow local generation. +# * write/pool: modest; the request body is small and the pool is unshared. +_LOCAL_INFERENCE_TIMEOUT = httpx.Timeout(connect=10.0, read=600.0, write=30.0, pool=10.0) + class OllamaProvider: + def __init__(self, sender: ProviderHttpSender | None = None) -> None: + self.sender = sender + async def complete(self, config: AiProviderConfig, prompt: PromptBundle) -> AiProviderResponse: - base_url = (config.base_url or "http://localhost:11434").rstrip("/") + # There is intentionally no localhost fallback. A local endpoint is a + # deployment-owned decision that must be explicitly approved by policy. + base_url = (config.base_url or "").rstrip("/") payload = { "model": config.model or DEFAULT_MODELS["ollama"], "stream": False, @@ -14,7 +65,14 @@ async def complete(self, config: AiProviderConfig, prompt: PromptBundle) -> AiPr {"role": "user", "content": prompt.user_prompt}, ], } - response = await post(config, f"{base_url}/api/chat", json=payload) + async with _concurrency_limit: + response = await post( + config, + f"{base_url}/api/chat", + sender=self.sender, + timeout=_LOCAL_INFERENCE_TIMEOUT, + json=payload, + ) try: return AiProviderResponse(content=response.json()["message"]["content"]) except (KeyError, IndexError, TypeError) as exc: diff --git a/apps/backend/app/ai/providers/openai.py b/apps/backend/app/ai/providers/openai.py index d30ddd77..0a94a291 100644 --- a/apps/backend/app/ai/providers/openai.py +++ b/apps/backend/app/ai/providers/openai.py @@ -1,9 +1,12 @@ -from app.ai.providers.http import post, require_api_key +from app.ai.providers.http import ProviderHttpSender, post, require_api_key from app.ai.types import DEFAULT_MODELS, AiProviderConfig, AiProviderResponse, PromptBundle from app.core.exceptions import ExternalServiceError class OpenAIProvider: + def __init__(self, sender: ProviderHttpSender | None = None) -> None: + self.sender = sender + async def complete(self, config: AiProviderConfig, prompt: PromptBundle) -> AiProviderResponse: require_api_key(config) payload = { @@ -16,6 +19,7 @@ async def complete(self, config: AiProviderConfig, prompt: PromptBundle) -> AiPr response = await post( config, "https://api.openai.com/v1/chat/completions", + sender=self.sender, headers={"Authorization": f"Bearer {config.api_key}"}, json=payload, ) diff --git a/apps/backend/app/ai/providers/openrouter.py b/apps/backend/app/ai/providers/openrouter.py index a6833ce5..4349fda6 100644 --- a/apps/backend/app/ai/providers/openrouter.py +++ b/apps/backend/app/ai/providers/openrouter.py @@ -1,9 +1,12 @@ -from app.ai.providers.http import post, require_api_key +from app.ai.providers.http import ProviderHttpSender, post, require_api_key from app.ai.types import DEFAULT_MODELS, AiProviderConfig, AiProviderResponse, PromptBundle from app.core.exceptions import ExternalServiceError class OpenRouterProvider: + def __init__(self, sender: ProviderHttpSender | None = None) -> None: + self.sender = sender + async def complete(self, config: AiProviderConfig, prompt: PromptBundle) -> AiProviderResponse: require_api_key(config) payload = { @@ -16,6 +19,7 @@ async def complete(self, config: AiProviderConfig, prompt: PromptBundle) -> AiPr response = await post( config, "https://openrouter.ai/api/v1/chat/completions", + sender=self.sender, headers={"Authorization": f"Bearer {config.api_key}"}, json=payload, ) diff --git a/apps/backend/app/ai/repository_context.py b/apps/backend/app/ai/repository_context.py index 77a49591..8f5d12b6 100644 --- a/apps/backend/app/ai/repository_context.py +++ b/apps/backend/app/ai/repository_context.py @@ -1,6 +1,5 @@ from app.ai.types import ( ArchitectureContext, - Citation, DependencyContext, DocumentationContext, EngineeringReviewContext, @@ -8,52 +7,67 @@ RepositoryContext, RepositoryIdentity, SelectedFileContext, + SnapshotIdentity, ) -from app.intelligence.engine import RepositoryIntelligenceEngine +from app.core.exceptions import ValidationServiceError +from app.intelligence.query_service import SnapshotQueryService from app.models.repository import RepositoryRecord class RepositoryContextBuilder: - def __init__(self, intelligence: RepositoryIntelligenceEngine) -> None: - self.intelligence = intelligence + def __init__(self, snapshots: SnapshotQueryService) -> None: + self.snapshots = snapshots def build(self, record: RepositoryRecord, selected_file: str | None = None) -> RepositoryContext: - repository_intelligence = self.intelligence.from_record(record) - files = [file.path for file in repository_intelligence.files] - selected = [path for path in files if selected_file and path == selected_file] + projection = self.snapshots.product_projection(record.id) + files = [file.path for file in projection.files] + if selected_file is not None and selected_file not in files: + raise ValidationServiceError( + "The selected file is not present in the sealed snapshot.", + {"selectedFile": selected_file, "snapshotId": projection.snapshot_id}, + ) + selected = [selected_file] if selected_file is not None else [] highlighted = selected or files[:40] modules = tuple( ModuleContext( name=module.name, role=module.role, - file_count=len(module.files), + file_count=len(module.paths), ) - for module in repository_intelligence.modules[:10] - ) - dependencies = tuple( - DependencyContext(name=dependency.name, version=dependency.version) - for dependency in repository_intelligence.dependencies[:20] + for module in projection.modules[:10] ) - citations = tuple( - Citation( - file=path, - start_line=1, - end_line=1, - content="Repository file path included in analysis context.", + dependencies: list[DependencyContext] = [] + for dependency in projection.dependencies[:20]: + declared_versions = tuple(dict.fromkeys(declaration.version for declaration in dependency.declarations)) + has_conflict = len(declared_versions) > 1 + dependencies.append( + DependencyContext( + name=dependency.name, + version=declared_versions[0] if len(declared_versions) == 1 else None, + declared_versions=declared_versions, + has_version_conflict=has_conflict, + ) ) - for path in highlighted[:5] - ) + # The prompt contains structural facts, not source bytes. Even though + # some snapshot facts have evidence spans, a provider answer cannot be + # deterministically mapped back to them, so citations remain empty. return RepositoryContext( repository=RepositoryIdentity(id=record.id, name=record.name), + snapshot=SnapshotIdentity( + id=projection.snapshot_id, + schema_version=projection.schema_version, + revision_kind=projection.revision_kind, + revision_value=projection.revision_value, + ), architecture=ArchitectureContext( - primary_language=repository_intelligence.discovery.primary_language, - frameworks=tuple(repository_intelligence.discovery.frameworks), - entry_points=tuple(repository_intelligence.discovery.entry_points), + primary_language=projection.primary_language, + frameworks=projection.frameworks, + entry_points=projection.entry_points, modules=modules, ), - dependencies=dependencies, + dependencies=tuple(dependencies), documentation=DocumentationContext(files=tuple(highlighted)), engineering_review=EngineeringReviewContext(), selected_files=tuple(SelectedFileContext(path=path) for path in highlighted), - citations=citations, + citations=(), ) diff --git a/apps/backend/app/ai/types.py b/apps/backend/app/ai/types.py index 056dfcd5..95a6a845 100644 --- a/apps/backend/app/ai/types.py +++ b/apps/backend/app/ai/types.py @@ -36,6 +36,14 @@ class RepositoryIdentity: name: str +@dataclass(frozen=True) +class SnapshotIdentity: + id: str + schema_version: str + revision_kind: str + revision_value: str + + @dataclass(frozen=True) class ModuleContext: name: str @@ -54,7 +62,9 @@ class ArchitectureContext: @dataclass(frozen=True) class DependencyContext: name: str - version: str + version: str | None + declared_versions: tuple[str | None, ...] + has_version_conflict: bool @dataclass(frozen=True) @@ -75,6 +85,7 @@ class SelectedFileContext: @dataclass(frozen=True) class RepositoryContext: repository: RepositoryIdentity + snapshot: SnapshotIdentity architecture: ArchitectureContext dependencies: tuple[DependencyContext, ...] documentation: DocumentationContext diff --git a/apps/backend/app/analysis/architecture.py b/apps/backend/app/analysis/architecture.py index dbab1f9d..60402ddc 100644 --- a/apps/backend/app/analysis/architecture.py +++ b/apps/backend/app/analysis/architecture.py @@ -1,11 +1,30 @@ -from app.intelligence.engine import RepositoryIntelligenceEngine +import posixpath +from collections import Counter, defaultdict + +from app.extraction.lockfiles import SUPPORTED_LOCKFILE_FILENAMES +from app.extraction.manifests import SUPPORTED_MANIFEST_FILENAMES +from app.intelligence.classification import LAYER_ORDER, layer_for_role +from app.intelligence.query_service import ( + ARCHITECTURE_DIAGNOSTIC_CODES, + ARCHITECTURE_RELATIONSHIP_EDGE_TYPES, + ArchitectureSnapshotFacts, + SnapshotQueryService, +) +from app.insights.relationship_diagnostics import ( + UnresolvedRelationshipContext, + is_external_unresolved, + load_unresolved_relationship_context, +) from app.intelligence.models import RepositoryModule from app.models.repository import RepositoryRecord +from app.models.snapshot import RiDiagnostic, RiEvidence, RiNode from app.schemas.architecture import ( ArchEdge, + ArchEvidence, ArchLayer, ArchModule, ArchitectureResponse, + ArchitectureDiagnostic, ArchitectureSummary, ArchNode, RequestFlowStep, @@ -13,7 +32,7 @@ ROLE_TO_NODE_TYPE = { - "entrypoint": "frontend", + "entrypoint": "entrypoint", "controller": "controller", "route": "route", "service": "service", @@ -25,31 +44,55 @@ "utility": "utilities", "configuration": "configuration", "test": "utilities", + "middleware": "middleware", "documentation": "shared-library", "unknown": "shared-library", } +_FRAMEWORK_BY_DEPENDENCY_NAME = { + "react": "React", + "next": "Next.js", + "vue": "Vue", + "fastapi": "FastAPI", + "django": "Django", + "flask": "Flask", +} + class ArchitectureAnalyzer: - def __init__(self, intelligence: RepositoryIntelligenceEngine | None = None) -> None: - self.intelligence = intelligence or RepositoryIntelligenceEngine() + """Builds the Architecture read model exclusively from sealed ri.v1 snapshots. + + No filesystem read, no working-tree fallback, and no legacy + ``repo_metadata['intelligence']`` or ``record.file_tree`` read: every field + is derived from :class:`SnapshotQueryService` facts. A repository with no + sealed snapshot for its current revision raises ``NotFoundError`` (#217), + the same 404 contract Dependencies, Review and Insights already use — + never a fallback graph built from unsealed repository metadata. + """ + + def __init__(self, snapshots: SnapshotQueryService | None = None) -> None: + self.snapshots = snapshots def build_architecture(self, record: RepositoryRecord) -> ArchitectureResponse: - repository_intelligence = self.intelligence.from_record(record) - modules = repository_intelligence.modules or [ - RepositoryModule( - id="module:repository", - name="Repository", - role="unknown", - layer="shared", - path_prefix="/", - files=[file.path for file in repository_intelligence.files[:25]], - symbols=[], - dependencies=[], - ) - ] + if self.snapshots is not None: + self.snapshots.require_sealed_snapshot_for_current_revision(record.id) + facts = self.snapshots.architecture_facts(record.id) if self.snapshots is not None else None + modules = self._modules_from_facts(facts) + frameworks = self._frameworks_from_facts(facts) + primary_language = self._primary_language_from_facts(facts) + entry_points = self._entry_points_from_facts(facts) nodes = self._nodes_for_modules(modules) - edges = self._edges_for_modules(modules, nodes) + nodes.extend(self._dependency_nodes(facts)) + edges, diagnostics, unresolved_node_ids, covered_paths = self._edges_for_modules(modules, nodes, facts) + edge_endpoint_ids = {node_id for edge in edges for node_id in (edge.source, edge.target)} + nodes = [node for node in nodes if node.layer != "external" or node.id in edge_endpoint_ids] + remaining_node_ids = {node.id for node in nodes} + for diagnostic in diagnostics: + if diagnostic.node_ids is not None: + diagnostic.node_ids = [ + node_id for node_id in diagnostic.node_ids if node_id in remaining_node_ids + ] or None + self._set_relationship_states(modules, nodes, edges, unresolved_node_ids, covered_paths, facts is not None) layers = self._layers_for_nodes(nodes) arch_modules = [ ArchModule( @@ -65,20 +108,22 @@ def build_architecture(self, record: RepositoryRecord) -> ArchitectureResponse: return ArchitectureResponse( repository_id=record.id, repository_name=record.name, - architecture_type=self._architecture_type(repository_intelligence.discovery.frameworks), + architecture_type=self._architecture_type(frameworks), detected_layers=layers, nodes=nodes, edges=edges, modules=arch_modules, request_flow=self._request_flow(modules), summary=ArchitectureSummary( - language=repository_intelligence.discovery.primary_language, - framework=repository_intelligence.discovery.frameworks[0] if repository_intelligence.discovery.frameworks else "Unknown", + language=primary_language, + framework=frameworks[0] if frameworks else "Unknown", total_modules=len(arch_modules), total_nodes=len(nodes), - entry_point=repository_intelligence.discovery.entry_points[0] if repository_intelligence.discovery.entry_points else "/", - architecture_pattern=self._architecture_type(repository_intelligence.discovery.frameworks), + entry_point=entry_points[0] if entry_points else "/", + architecture_pattern=self._architecture_type(frameworks), ), + relationship_snapshot_id=facts.snapshot.snapshot_id if facts is not None else None, + diagnostics=diagnostics, ) def _nodes_for_modules(self, modules: list[RepositoryModule]) -> list[ArchNode]: @@ -95,48 +140,534 @@ def _nodes_for_modules(self, modules: list[RepositoryModule]) -> list[ArchNode]: files=module.files[:25], dependencies=[], dependents=[], - estimated_complexity="high" if len(module.files) > 30 else "medium" if len(module.files) > 10 else "low", - estimated_lines=max(len(module.files) * 80, 20), + # No producer measures size/complexity (#217): file count is + # not a line count or a complexity metric, so it is never + # used to synthesize one. + estimated_complexity="not_computed", + estimated_lines="not_computed", tags=[module.layer, module.role, module.id.replace("module:", "")], layer=module.layer, ) ) return nodes - def _edges_for_modules(self, modules: list[RepositoryModule], nodes: list[ArchNode]) -> list[ArchEdge]: + def _empty_module(self, files: list[str]) -> list[RepositoryModule]: + return [ + RepositoryModule( + id="module:repository", + name="Repository", + role="unknown", + layer="shared", + path_prefix="/", + files=files, + symbols=[], + dependencies=[], + ) + ] + + def _file_roles(self, facts: ArchitectureSnapshotFacts) -> dict[str, str]: + """Map file path -> role-classifier classification (#95), if any. + + A file with no ``classified_as`` assertion has no entry: absence here + means "not classified", never a fabricated "unknown" guess. + """ + + roles: dict[str, str] = {} + for assertion in facts.assertions: + if assertion.predicate != "classified_as" or assertion.subject_kind != "file": + continue + classification = str((assertion.value or {}).get("classification", "")) + if not classification: + continue + roles[assertion.subject_key.removeprefix("file:")] = classification + return roles + + def _modules_from_facts(self, facts: ArchitectureSnapshotFacts | None) -> list[RepositoryModule]: + if facts is None: + # Defensive only: build_architecture requires a sealed snapshot + # before calling this whenever self.snapshots is configured, so a + # production caller never reaches this branch with real facts + # unresolved. It stays as an honest, empty module set rather than + # ever reading `record.file_tree` (unsealed repository metadata). + return self._empty_module([]) + # Dependency-manifest and lockfile paths already surface as + # dependency evidence (Dependency Graph) -- grouping them into an + # architecture module too misrepresents `package.json`/ + # `pyproject.toml` as a piece of the system's own structure. + _non_module_filenames = SUPPORTED_MANIFEST_FILENAMES + SUPPORTED_LOCKFILE_FILENAMES + file_paths = sorted( + node.stable_key.removeprefix("file:") + for node in facts.nodes + if node.node_kind == "file" + and node.stable_key.startswith("file:") + and posixpath.basename(node.stable_key.removeprefix("file:")) not in _non_module_filenames + ) + if not file_paths: + return self._empty_module([]) + role_by_path = self._file_roles(facts) + grouped: dict[str, list[str]] = defaultdict(list) + for path in file_paths: + grouped[self._module_id(path, role_by_path.get(path))].append(path) + modules: list[RepositoryModule] = [] + for module_id, paths in grouped.items(): + candidate_roles = [ + role_by_path[path] + for path in paths + if role_by_path.get(path) not in (None, "unknown", "documentation", "test") + ] + dominant = ( + Counter(candidate_roles).most_common(1)[0][0] + if candidate_roles + else (role_by_path.get(paths[0]) or "unknown") + ) + modules.append( + RepositoryModule( + id=module_id, + name=self._module_display_name(module_id), + role=dominant, # type: ignore[arg-type] + layer=layer_for_role(dominant), + path_prefix=self._path_prefix(paths), + files=sorted(paths), + symbols=[], + dependencies=[], + ) + ) + return sorted(modules, key=lambda module: module.id) + + @staticmethod + def _module_id(path: str, role: str | None) -> str: + parts = [part for part in path.strip("/").split("/") if part] + if role in {"controller", "route"}: + return "module:api" + if role == "service": + return "module:services" + if role == "repository": + return "module:repositories" + if role in {"model", "dto", "interface", "enum"}: + return "module:domain" + if role == "middleware": + return "module:middleware" + if role == "configuration": + return "module:configuration" + if role == "test": + return "module:tests" + if role == "documentation": + return "module:documentation" + if parts and parts[0] in {"app", "src", "backend", "frontend", "apps"} and len(parts) > 1: + return f"module:{parts[1].lower()}" + return f"module:{parts[0].lower() if parts else 'repository'}" + + @staticmethod + def _module_display_name(module_id: str) -> str: + raw = module_id.removeprefix("module:") + if "." in raw: + # `_module_id`'s fallback groups a top-level file with no + # directory nesting by its own filename (e.g. "app.py") -- that + # is a real filename, not a word slug, and Title Case corrupts + # its extension ("app.py" -> "App.Py"). Render it verbatim. + return raw + return raw.replace("-", " ").title() + + @staticmethod + def _path_prefix(paths: list[str]) -> str: + if not paths: + return "/" + parts = [path.strip("/").split("/") for path in paths] + prefix: list[str] = [] + for columns in zip(*parts): + if len(set(columns)) == 1: + prefix.append(columns[0]) + else: + break + return "/" + "/".join(prefix) if prefix else "/" + + def _frameworks_from_facts(self, facts: ArchitectureSnapshotFacts | None) -> list[str]: + if facts is None: + return [] + names = {node.name.lower() for node in facts.nodes if node.node_kind == "dependency" and node.name} + return sorted( + { + framework + for dependency_name, framework in _FRAMEWORK_BY_DEPENDENCY_NAME.items() + if dependency_name in names + } + ) + + def _primary_language_from_facts(self, facts: ArchitectureSnapshotFacts | None) -> str: + if facts is None: + return "Unknown" + # Count persisted file facts, not symbols. Symbol counts make a file + # with many declarations (and synthetic route symbols) outweigh other + # files, and report "Unknown" for a valid script containing only + # top-level statements. + counts = Counter(node.language for node in facts.nodes if node.node_kind == "file" and node.language) + if not counts: + return "Unknown" + dominant = counts.most_common(1)[0][0] + return {"python": "Python", "typescript": "TypeScript"}.get(dominant, dominant.title()) + + def _entry_points_from_facts(self, facts: ArchitectureSnapshotFacts | None) -> list[str]: + if facts is None: + return [] + role_by_path = self._file_roles(facts) + return sorted(path for path, role in role_by_path.items() if role == "entrypoint") + + def _dependency_nodes(self, facts: ArchitectureSnapshotFacts | None) -> list[ArchNode]: + if facts is None: + return [] + relationship_keys = { + key + for edge in facts.edges + if edge.predicate in ARCHITECTURE_RELATIONSHIP_EDGE_TYPES + for key in (edge.subject_key, edge.object_key) + } + result: list[ArchNode] = [] + for item in facts.nodes: + if item.node_kind != "dependency" or item.stable_key not in relationship_keys: + continue + evidence = facts.node_evidence.get(item.id, []) + result.append( + ArchNode( + id=item.stable_key, + name=item.name or item.stable_key, + type="shared-library", + description="External dependency from resolved repository evidence.", + responsibilities=["Provides an externally declared or imported capability"], + files=sorted({entry.path for entry in evidence}), + dependencies=[], + dependents=[], + estimated_complexity="not_computed", + estimated_lines="not_computed", + tags=["external", "dependency"], + layer="external", + ) + ) + return result + + def _edges_for_modules( + self, + modules: list[RepositoryModule], + nodes: list[ArchNode], + facts: ArchitectureSnapshotFacts | None, + ) -> tuple[list[ArchEdge], list[ArchitectureDiagnostic], set[str], set[str]]: + if facts is None: + return ( + [], + [ + ArchitectureDiagnostic( + code="ARCH-REL-NOT-EXTRACTED", + category="relationship extraction", + severity="info", + message="No sealed repository-intelligence snapshot is available for relationship analysis.", + ) + ], + set(), + set(), + ) + + module_by_id = {module.id: module for module in modules} + modules_by_file: dict[str, list[str]] = {} + for module in modules: + for path in module.files: + modules_by_file.setdefault(self._normalize_path(path), []).append(module.id) + snapshot_node_by_key = {item.stable_key: item for item in facts.nodes} node_ids = {node.id for node in nodes} - module_by_role = {module.role: module.id for module in modules} - candidates = [ - ("entrypoint", "route"), - ("entrypoint", "controller"), - ("route", "service"), - ("controller", "service"), - ("service", "repository"), - ("service", "model"), - ("repository", "model"), - ("test", "service"), + + # An RI-RES-UNRESOLVED whose target is a third-party dependency or the + # language platform is not an unmapped *architecture* relationship -- it + # should neither flag a module red nor crowd the diagnostics list. + # Genuine in-repo gaps and every RI-RES-AMBIGUOUS still count. Same + # #412 judgment Repository Insights uses; the raw resolver diagnostics + # stay available via the intelligence evidence API. + unresolved_ctx = ( + load_unresolved_relationship_context(self.snapshots.db, facts.snapshot.snapshot_id) + if self.snapshots is not None + else UnresolvedRelationshipContext.empty() + ) + + def _is_architecture_relevant(item: RiDiagnostic) -> bool: + if item.code not in ARCHITECTURE_DIAGNOSTIC_CODES: + return False + if item.code != "RI-RES-UNRESOLVED": + return True + return not is_external_unresolved(item.path, (item.details or {}).get("observation_id"), unresolved_ctx) + + architecture_diagnostic_items = [item for item in facts.diagnostics if _is_architecture_relevant(item)] + diagnostics = [ + self._architecture_diagnostic(item, modules_by_file, node_ids) for item in architecture_diagnostic_items ] + unresolved_node_ids: set[str] = set() + # Inventory-only file nodes prove that a path exists, not that a + # relationship-capable extractor ran. Count only evidence emitted by a + # syntax/manifest producer so unsupported files cannot look isolated. + covered_paths = facts.covered_paths + + for item in architecture_diagnostic_items: + if item.path: + unresolved_node_ids.update(modules_by_file.get(self._normalize_path(item.path), [])) + for key in (item.subject_key, item.object_key): + if key in node_ids: + unresolved_node_ids.add(key) + edges: list[ArchEdge] = [] - for source_role, target_role in candidates: - source = module_by_role.get(source_role) - target = module_by_role.get(target_role) - if source in node_ids and target in node_ids and source != target: - edges.append(ArchEdge(id=f"{source}->{target}", source=source, target=target, type="dependency", label="uses")) + for fact in facts.edges: + if fact.predicate not in ARCHITECTURE_RELATIONSHIP_EDGE_TYPES: + continue + evidence_rows = facts.edge_evidence.get(fact.id, []) + source_ids = self._architecture_endpoint_ids( + fact.subject_kind, + fact.subject_key, + evidence_rows, + modules_by_file, + module_by_id, + snapshot_node_by_key, + facts, + is_subject=True, + ) + target_ids = self._architecture_endpoint_ids( + fact.object_kind, + fact.object_key, + evidence_rows, + modules_by_file, + module_by_id, + snapshot_node_by_key, + facts, + is_subject=False, + ) + root_scope_evidence = [ + item + for item in evidence_rows + if fact.predicate == "depends_on" + and fact.subject_kind == "repository" + and not posixpath.dirname(self._normalize_path(item.path)) + ] + if root_scope_evidence: + diagnostics.append( + ArchitectureDiagnostic( + code="ARCH-REL-REPO-SCOPED", + category="relationship mapping", + severity="info", + message="A repository-root dependency declaration is kept repository-scoped and is not attributed to modules.", + path=root_scope_evidence[0].path, + start_line=root_scope_evidence[0].start_line, + end_line=root_scope_evidence[0].end_line, + subject_key=fact.subject_key, + object_key=fact.object_key, + details={"factId": fact.edge_id, "predicate": fact.predicate}, + node_ids=[fact.object_key] if fact.object_key in node_ids else None, + ) + ) + if len(root_scope_evidence) == len(evidence_rows): + continue + pairs = sorted( + (source, target) + for source in source_ids + for target in target_ids + if source in node_ids and target in node_ids + ) + non_self_pairs = [(source, target) for source, target in pairs if source != target] + if pairs and not non_self_pairs: + # A resolved fact wholly inside one architecture module remains + # extraction evidence, but it is not a module-to-module edge. + continue + if not non_self_pairs: + diagnostics.append( + ArchitectureDiagnostic( + code="ARCH-REL-ENDPOINT-UNMAPPED", + category="relationship mapping", + severity="warning", + message="A resolved relationship could not be mapped to architecture nodes without guessing.", + path=evidence_rows[0].path if evidence_rows else None, + start_line=evidence_rows[0].start_line if evidence_rows else None, + end_line=evidence_rows[0].end_line if evidence_rows else None, + subject_key=fact.subject_key, + object_key=fact.object_key, + details={"factId": fact.edge_id, "predicate": fact.predicate}, + node_ids=sorted(source_ids | target_ids) or None, + ) + ) + unresolved_node_ids.update(source_ids | target_ids) + continue + citations = [ + ArchEvidence( + snapshot_id=facts.snapshot.snapshot_id, + fact_id=fact.edge_id, + path=item.path, + start_line=item.start_line, + end_line=item.end_line, + ) + for item in evidence_rows + ] + for index, (source, target) in enumerate(non_self_pairs, start=1): + edge_id = fact.edge_id if len(non_self_pairs) == 1 else f"{fact.edge_id}:{index}" + edges.append( + ArchEdge( + id=edge_id, + source=source, + target=target, + type=ARCHITECTURE_RELATIONSHIP_EDGE_TYPES[fact.predicate], # type: ignore[arg-type] + label=fact.predicate.replace("_", " "), + predicate=fact.predicate, + truth_class="inferred", + evidence=citations, + ) + ) + + node_by_id = {node.id: node for node in nodes} for edge in edges: - source = next(node for node in nodes if node.id == edge.source) - target = next(node for node in nodes if node.id == edge.target) - source.dependencies.append(target.id) - target.dependents.append(source.id) - return edges + source = node_by_id[edge.source] + target = node_by_id[edge.target] + if target.id not in source.dependencies: + source.dependencies.append(target.id) + if source.id not in target.dependents: + target.dependents.append(source.id) + for node in nodes: + node.dependencies.sort() + node.dependents.sort() + return edges, diagnostics, unresolved_node_ids, covered_paths + + def _architecture_endpoint_ids( + self, + node_kind: str, + stable_key: str, + edge_evidence: list[RiEvidence], + modules_by_file: dict[str, list[str]], + module_by_id: dict[str, RepositoryModule], + snapshot_node_by_key: dict[str, RiNode], + facts: ArchitectureSnapshotFacts, + *, + is_subject: bool, + ) -> set[str]: + if node_kind == "dependency": + return {stable_key} + if node_kind == "repository": + return self._modules_for_evidence_scope(edge_evidence, module_by_id) + + path = self._path_for_stable_key(node_kind, stable_key) + if path is not None and node_kind != "module": + return set(modules_by_file.get(path, [])) + + evidence = edge_evidence if is_subject else [] + node = snapshot_node_by_key.get(stable_key) + if node is not None and not evidence: + evidence = facts.node_evidence.get(node.id, []) + exact = { + module_id for item in evidence for module_id in modules_by_file.get(self._normalize_path(item.path), []) + } + if exact: + return exact + if path is not None: + return { + module.id + for module in module_by_id.values() + if any(self._path_is_within(self._normalize_path(file_path), path) for file_path in module.files) + } + return set() + + def _modules_for_evidence_scope( + self, + evidence: list[RiEvidence], + module_by_id: dict[str, RepositoryModule], + ) -> set[str]: + result: set[str] = set() + for item in evidence: + path = self._normalize_path(item.path) + directory = posixpath.dirname(path) + if not directory: + continue + for module in module_by_id.values(): + if any(self._path_is_within(self._normalize_path(file_path), directory) for file_path in module.files): + result.add(module.id) + return result + + def _set_relationship_states( + self, + modules: list[RepositoryModule], + nodes: list[ArchNode], + edges: list[ArchEdge], + unresolved_node_ids: set[str], + covered_paths: set[str], + snapshot_available: bool, + ) -> None: + connected = {node_id for edge in edges for node_id in (edge.source, edge.target)} + module_by_id = {module.id: module for module in modules} + for node in nodes: + if node.id in connected: + node.relationship_state = "connected" + elif node.id in unresolved_node_ids: + node.relationship_state = "unresolved" + elif ( + node.id in module_by_id + and snapshot_available + and module_by_id[node.id].files + and all(self._normalize_path(path) in covered_paths for path in module_by_id[node.id].files) + ): + node.relationship_state = "no-observed-relationships" + else: + node.relationship_state = "not-extracted" + + def _architecture_diagnostic( + self, + item: RiDiagnostic, + modules_by_file: dict[str, list[str]], + node_ids: set[str], + ) -> ArchitectureDiagnostic: + attributed_node_ids: set[str] = set() + if item.path: + attributed_node_ids.update(modules_by_file.get(self._normalize_path(item.path), [])) + for key in (item.subject_key, item.object_key): + if key is None: + continue + if key in node_ids: + attributed_node_ids.add(key) + continue + path = self._path_for_stable_key("file" if key.startswith("file:") else "symbol", key) + if path is not None: + attributed_node_ids.update(modules_by_file.get(path, [])) + return ArchitectureDiagnostic( + code=item.code, + category=item.category, + severity=item.severity, + message=item.message, + path=item.path, + start_line=item.span_start_line, + end_line=item.span_end_line, + subject_key=item.subject_key, + object_key=item.object_key, + details=dict(item.details) if item.details is not None else None, + node_ids=sorted(attributed_node_ids) or None, + ) + + @staticmethod + def _normalize_path(path: str) -> str: + return path.replace("\\", "/").lstrip("/") + + @classmethod + def _path_for_stable_key(cls, node_kind: str, stable_key: str) -> str | None: + if node_kind == "file" and stable_key.startswith("file:"): + return cls._normalize_path(stable_key.removeprefix("file:")) + if node_kind == "symbol" and "::" in stable_key: + return cls._normalize_path(stable_key.split("::", 1)[0]) + if node_kind == "module" and stable_key.startswith("mod:"): + return cls._normalize_path(stable_key.removeprefix("mod:")) + return None + + @staticmethod + def _path_is_within(path: str, directory: str) -> bool: + return not directory or path == directory or path.startswith(f"{directory}/") def _layers_for_nodes(self, nodes: list[ArchNode]) -> list[ArchLayer]: - order = {"presentation": 0, "business-logic": 1, "domain": 2, "infrastructure": 3, "shared": 4} layers: dict[str, list[str]] = {} for node in nodes: layers.setdefault(node.layer, []).append(node.id) return [ - ArchLayer(id=layer, name=layer.replace("-", " ").title(), order=order.get(layer, 99), nodes=node_ids) - for layer, node_ids in sorted(layers.items(), key=lambda item: order.get(item[0], 99)) + ArchLayer( + id=layer, + name=layer.replace("-", " ").title(), + order=LAYER_ORDER.get(layer, 99), + nodes=node_ids, + ) + for layer, node_ids in sorted(layers.items(), key=lambda item: LAYER_ORDER.get(item[0], 99)) ] def _architecture_type(self, frameworks: list[str]) -> str: @@ -149,12 +680,45 @@ def _architecture_type(self, frameworks: list[str]) -> str: def _request_flow(self, modules: list[RepositoryModule]) -> list[RequestFlowStep]: module_roles = {module.role for module in modules} steps = [ - RequestFlowStep(id="client", name="Client", type="frontend", description="Request enters the system.", details=["Browser or API client sends a request."]), + RequestFlowStep( + id="client", + name="Client", + type="frontend", + description="Request enters the system.", + details=["Browser or API client sends a request."], + ), ] if "route" in module_roles or "controller" in module_roles: - steps.append(RequestFlowStep(id="api", name="API Layer", type="controller", description="Route/controller handles input.", details=["Validate request", "Call service"])) + steps.append( + RequestFlowStep( + id="api", + name="API Layer", + type="controller", + description="Route/controller handles input.", + details=["Validate request", "Call service"], + ) + ) if "service" in module_roles: - steps.append(RequestFlowStep(id="service", name="Service Layer", type="service", description="Business logic executes.", details=["Coordinate repository intelligence consumers", "Transform data"])) + steps.append( + RequestFlowStep( + id="service", + name="Service Layer", + type="service", + description="Business logic executes.", + details=[ + "Coordinate repository intelligence consumers", + "Transform data", + ], + ) + ) if "repository" in module_roles: - steps.append(RequestFlowStep(id="repository", name="Repository Layer", type="repository", description="Persistence or source files are accessed.", details=["Read or write data"] )) + steps.append( + RequestFlowStep( + id="repository", + name="Repository Layer", + type="repository", + description="Persistence or source files are accessed.", + details=["Read or write data"], + ) + ) return steps diff --git a/apps/backend/app/analysis/authentication.py b/apps/backend/app/analysis/authentication.py new file mode 100644 index 00000000..40b9d42d --- /dev/null +++ b/apps/backend/app/analysis/authentication.py @@ -0,0 +1,384 @@ +"""Evidence-backed authentication explanation (#95). + +Reads exclusively through :class:`SnapshotQueryService`: no filesystem read, +no working-tree fallback, no legacy ``repo_metadata['intelligence']`` read, +and no second parser or consumer-specific fact store. Every displayed claim +resolves to a real evidence span stored against the exact sealed snapshot +named in the response; a claim with no resolvable evidence is dropped rather +than shown as fact. + +Authentication relevance comes from graph connectivity, never from a global +role-name match. A route, service, or model is only ever included when it is +reachable from a resolved ``routes_to`` edge through a resolved ``injects`` +edge into a symbol the role classifier has explicitly marked +``auth_dependency`` (#95's guard signal), and — for services/models — through +a chain of resolved ``calls`` edges from that guard. An unrelated route (for +example ``/health``), an unrelated ``*Service``/``*Model`` symbol, or a +generic injected dependency (for example ``Depends(get_database)``) is never +part of that subgraph, so it is never claimed as authentication regardless of +its name or classification. +""" + +from __future__ import annotations + +from collections import defaultdict, deque + +from app.intelligence.query_service import ArchitectureSnapshotFacts, SnapshotQueryService +from app.models.repository import RepositoryRecord +from app.models.snapshot import RiEdge, RiNode +from app.schemas.authentication import ( + AuthChain, + AuthClaim, + AuthClaimKind, + AuthConfidence, + AuthenticationDiagnostic, + AuthenticationExplanationResponse, + AuthEvidenceRef, + AuthRelationship, + AuthRelationshipNodeKind, +) + +_GUARD_CLASSIFICATION = "auth_dependency" +_SERVICE_CLASSIFICATION = "service" +_MODEL_CLASSIFICATION = "model" +_TEST_FILE_ROLE = "test" +_DIAGNOSTIC_CODES = frozenset({"RI-RES-UNRESOLVED", "RI-RES-AMBIGUOUS", "RI-EXT-UNSUPPORTED", "RI-SRC-MALFORMED"}) +_MAX_DIAGNOSTICS = 50 + + +def _display_name(node: RiNode) -> str: + if node.node_kind == "symbol" and node.properties and "route_path" in node.properties: + return str(node.properties["route_path"]) + return node.name or node.stable_key + + +class AuthenticationExplanationService: + """Build the authentication explanation for one owner-scoped repository.""" + + def __init__(self, snapshots: SnapshotQueryService) -> None: + self.snapshots = snapshots + + def explain(self, record: RepositoryRecord) -> AuthenticationExplanationResponse: + facts = self.snapshots.architecture_facts(record.id) + if facts is None: + return AuthenticationExplanationResponse( + schema_version="auth-explanation.v1", + repository_id=record.id, + repository_name=record.name, + revision_kind=record.revision_kind, + revision_value=record.revision_value, + snapshot_id=None, + status="missing_snapshot", + summary=( + "No sealed repository intelligence snapshot is available for this " + "repository yet. Run analysis to produce one before requesting an " + "authentication explanation." + ), + diagnostics=[ + AuthenticationDiagnostic( + code="AUTH-NO-SNAPSHOT", + category="snapshot", + severity="info", + message="No sealed snapshot exists for this repository's current revision.", + ) + ], + ) + return _AuthenticationSubgraphBuilder(record, facts).build() + + +class _AuthenticationSubgraphBuilder: + """Select and render the evidence-backed authentication subgraph once. + + One instance is used per request: it walks resolved ``routes_to`` -> + ``injects`` -> ``calls`` edges starting from every route, keeps only the + routes that reach an explicit ``auth_dependency`` guard, and — from each + guard — keeps only the ``calls`` targets that lie on a path to a symbol + the role classifier marked ``service``/``model``. Nothing reachable is + assumed relevant; only nodes on a proven path to a classified target (or + the guard itself) are ever surfaced. + """ + + def __init__(self, record: RepositoryRecord, facts: ArchitectureSnapshotFacts) -> None: + self.record = record + self.facts = facts + self.nodes_by_key: dict[str, RiNode] = {node.stable_key: node for node in facts.nodes} + + self.classifications: dict[str, set[str]] = defaultdict(set) + # Path -> role classifier's file-level classification (e.g. "test"), + # keyed the same way architecture.py's _file_roles reads the same + # classified_as assertions -- kept separate from the symbol-level + # dict above (subject_kind differs) rather than merged into it. + self.file_roles: dict[str, str] = {} + for assertion in facts.assertions: + if assertion.predicate != "classified_as": + continue + classification = str((assertion.value or {}).get("classification", "")) + if not classification: + continue + if assertion.subject_kind == "symbol": + self.classifications[assertion.subject_key].add(classification) + elif assertion.subject_kind == "file": + self.file_roles[assertion.subject_key.removeprefix("file:")] = classification + + self.injects_by_subject: dict[str, list[RiEdge]] = defaultdict(list) + self.calls_by_subject: dict[str, list[RiEdge]] = defaultdict(list) + for edge in facts.edges: + if edge.predicate == "injects": + self.injects_by_subject[edge.subject_key].append(edge) + elif edge.predicate == "calls": + self.calls_by_subject[edge.subject_key].append(edge) + + self.claims: list[AuthClaim] = [] + self._seen_claim_keys: set[tuple[str, str]] = set() + self.relationships: list[AuthRelationship] = [] + # Interns one AuthRelationship per (subject, predicate, object): the + # flat list below only ever gets one entry per key, but every chain + # that shares the hop still receives the same, complete object -- + # chain completeness never depends on whether another route already + # claimed this hop first. + self._relationship_by_key: dict[tuple[str, str, str], AuthRelationship] = {} + self.chains: list[AuthChain] = [] + + def build(self) -> AuthenticationExplanationResponse: + for route_edge in self._sorted(self.facts.edges, "routes_to"): + self._process_route(route_edge) + + diagnostics = [ + AuthenticationDiagnostic( + code=diagnostic.code, + category=diagnostic.category, + severity=diagnostic.severity, + message=diagnostic.message, + path=diagnostic.path, + start_line=diagnostic.span_start_line, + end_line=diagnostic.span_end_line, + ) + for diagnostic in self.facts.diagnostics + if diagnostic.code in _DIAGNOSTIC_CODES + ][:_MAX_DIAGNOSTICS] + + counts = { + kind: sum(1 for claim in self.claims if claim.kind == kind) + for kind in ("route", "middleware", "service", "model", "dependency") + } + summary = ( + f"Found {counts['route']} authentication-relevant route(s), {counts['middleware']} " + f"guard dependency(ies), {counts['service']} service(s), and {counts['model']} model(s) " + "reached from them, each backed by a citation to its exact stored source span." + ) + + return AuthenticationExplanationResponse( + schema_version="auth-explanation.v1", + repository_id=self.record.id, + repository_name=self.record.name, + revision_kind=self.facts.snapshot.revision_kind, + revision_value=self.facts.snapshot.revision_value, + snapshot_id=self.facts.snapshot.snapshot_id, + status="ready", + summary=summary, + claims=self.claims, + relationships=self.relationships, + chains=self.chains, + diagnostics=diagnostics, + ) + + def _process_route(self, route_edge: RiEdge) -> None: + route_node = self.nodes_by_key.get(route_edge.subject_key) + handler_node = self.nodes_by_key.get(route_edge.object_key) + if route_node is None or handler_node is None: + return + if self._defined_in_test_file(route_node) or self._defined_in_test_file(handler_node): + # A route only ever exists here because a benchmark/test fixture + # happens to define something that structurally looks like a + # guarded route -- real repository code, not the fixture, is + # what a user asked to understand (#337). The underlying facts + # are untouched; this only excludes them from this explanation. + return + route_evidence = self._edge_evidence(route_edge) + if not route_evidence: + return + + guard_edges = [ + edge + for edge in self._sorted(self.injects_by_subject.get(handler_node.stable_key, ()), "injects") + if _GUARD_CLASSIFICATION in self.classifications.get(edge.object_key, ()) + ] + + for guard_edge in guard_edges: + self._process_guard(route_node, handler_node, route_edge, guard_edge) + + def _process_guard( + self, + route_node: RiNode, + handler_node: RiNode, + route_edge: RiEdge, + guard_edge: RiEdge, + ) -> None: + guard_node = self.nodes_by_key.get(guard_edge.object_key) + if guard_node is None: + return + guard_evidence = self._edge_evidence(guard_edge) + if not guard_evidence: + return + + used_edges = self._reachable_service_model_edges(guard_node) + + # This route genuinely participates in a supported, evidence-backed + # authentication path: the route claim and its two anchor hops are + # only ever added here, never unconditionally for every route. + self._add_claim("route", route_node, "observed") + self._add_claim("middleware", guard_node, "heuristic") + + hops: list[AuthRelationship] = [] + route_hop = self._add_relationship(route_node, "route", "routes_to", handler_node, "handler", route_edge) + if route_hop is not None: + hops.append(route_hop) + guard_hop = self._add_relationship(handler_node, "handler", "injects", guard_node, "middleware", guard_edge) + if guard_hop is not None: + hops.append(guard_hop) + + for subject_key, object_key, edge in used_edges: + subject = self.nodes_by_key.get(subject_key) + obj = self.nodes_by_key.get(object_key) + if subject is None or obj is None: + continue + object_kind = self._role_of(object_key) + self._add_claim(object_kind, obj, "heuristic") + hop = self._add_relationship(subject, self._role_of(subject_key), "calls", obj, object_kind, edge) + if hop is not None: + hops.append(hop) + + if hops: + self.chains.append(AuthChain(route=_display_name(route_node), hops=hops)) + + def _reachable_service_model_edges(self, guard_node: RiNode) -> list[tuple[str, str, RiEdge]]: + """BFS the resolved ``calls`` graph from ``guard_node``. + + Returns only the edges that lie on a path from the guard to a symbol + classified ``service`` or ``model`` — an unrelated call the guard + happens to make that never reaches a classified target is pruned, not + surfaced as a guess. + """ + + parent: dict[str, tuple[str, RiEdge]] = {} + visited: set[str] = {guard_node.stable_key} + discovered_edges: list[RiEdge] = [] + classified_targets: list[str] = [] + + queue: deque[str] = deque([guard_node.stable_key]) + while queue: + current = queue.popleft() + for edge in self._sorted(self.calls_by_subject.get(current, ()), "calls"): + object_key = edge.object_key + if object_key in visited: + continue + if not self._edge_evidence(edge): + continue + visited.add(object_key) + parent[object_key] = (current, edge) + discovered_edges.append(edge) + queue.append(object_key) + roles = self.classifications.get(object_key, ()) + if _SERVICE_CLASSIFICATION in roles or _MODEL_CLASSIFICATION in roles: + classified_targets.append(object_key) + + used_nodes: set[str] = set() + for target_key in classified_targets: + walk_key = target_key + while walk_key in parent: + used_nodes.add(walk_key) + walk_key = parent[walk_key][0] + + return [(edge.subject_key, edge.object_key, edge) for edge in discovered_edges if edge.object_key in used_nodes] + + def _role_of(self, stable_key: str) -> AuthRelationshipNodeKind: + roles = self.classifications.get(stable_key, ()) + if _GUARD_CLASSIFICATION in roles: + return "middleware" + if _SERVICE_CLASSIFICATION in roles: + return "service" + if _MODEL_CLASSIFICATION in roles: + return "model" + return "dependency" + + @staticmethod + def _claim_kind_for_role(role: AuthRelationshipNodeKind) -> AuthClaimKind: + if role == "handler": + raise ValueError("'handler' is never a claim kind") + return role + + def _add_claim(self, role: AuthRelationshipNodeKind, node: RiNode, confidence: AuthConfidence) -> None: + kind = self._claim_kind_for_role(role) + key = (kind, node.stable_key) + if key in self._seen_claim_keys: + return + evidence = self._node_evidence(node) + if not evidence: + # A claim with no resolvable evidence is never displayed as fact. + return + self._seen_claim_keys.add(key) + self.claims.append(AuthClaim(kind=kind, name=_display_name(node), confidence=confidence, evidence=evidence)) + + def _add_relationship( + self, + subject: RiNode, + subject_kind: AuthRelationshipNodeKind, + predicate: str, + obj: RiNode, + object_kind: AuthRelationshipNodeKind, + edge: RiEdge, + ) -> AuthRelationship | None: + evidence = self._edge_evidence(edge) + if not evidence: + return None + key = (subject.stable_key, predicate, obj.stable_key) + existing = self._relationship_by_key.get(key) + if existing is not None: + return existing + relationship = AuthRelationship( + subject=_display_name(subject), + subject_kind=subject_kind, + predicate=predicate, + object=_display_name(obj), + object_kind=object_kind, + evidence=evidence, + ) + self._relationship_by_key[key] = relationship + self.relationships.append(relationship) + return relationship + + def _defined_in_test_file(self, node: RiNode) -> bool: + return any( + self.file_roles.get(item.path) == _TEST_FILE_ROLE for item in self.facts.node_evidence.get(node.id, []) + ) + + def _node_evidence(self, node: RiNode) -> list[AuthEvidenceRef]: + return [ + AuthEvidenceRef( + snapshot_id=self.facts.snapshot.snapshot_id, + fact_id=node.stable_key, + path=item.path, + start_line=item.start_line, + end_line=item.end_line, + ) + for item in self.facts.node_evidence.get(node.id, []) + ] + + def _edge_evidence(self, edge: RiEdge) -> list[AuthEvidenceRef]: + return [ + AuthEvidenceRef( + snapshot_id=self.facts.snapshot.snapshot_id, + fact_id=edge.edge_id, + path=item.path, + start_line=item.start_line, + end_line=item.end_line, + ) + for item in self.facts.edge_evidence.get(edge.id, []) + ] + + @staticmethod + def _sorted(edges, predicate: str) -> list[RiEdge]: + return sorted( + (edge for edge in edges if edge.predicate == predicate), + key=lambda edge: (edge.subject_key, edge.object_key, edge.edge_id), + ) diff --git a/apps/backend/app/analysis/manifest.py b/apps/backend/app/analysis/manifest.py new file mode 100644 index 00000000..85bea086 --- /dev/null +++ b/apps/backend/app/analysis/manifest.py @@ -0,0 +1,182 @@ +"""Build and verify revision manifests from sealed snapshots (#113).""" + +from app.intelligence.canonical import canonical_json_bytes, sha256_prefixed +from app.intelligence.query_service import SnapshotQueryService +from app.models.snapshot import RiSnapshot +from app.schemas.manifest import ( + VERIFICATION_METHOD, + ManifestExtractor, + RevisionManifest, + RevisionManifestResponse, + RevisionManifestVerificationResponse, +) + +_SEALED_NOTE = ( + "This digest is a SHA-256 over the canonical JSON encoding of the manifest " + "fields above. It lets you confirm that an exported manifest still matches " + "the snapshot stored by this deployment. It is a content hash, not a " + "digital signature: nothing is signed and no key verification is involved, " + "so it does not prove authorship or protect against an operator who can " + "modify the stored snapshot." +) +_UNSEALED_NOTE = ( + "This snapshot is not sealed, so it has no canonical graph hash and cannot " + "be used as a verifiable revision identity." +) + + +def _extractors(snapshot: RiSnapshot) -> list[ManifestExtractor]: + """Split ``name@version`` producer entries into structured extractors. + + A producer without a version is reported with an empty version rather than + guessing one, so the manifest never invents provenance. + """ + + extractors: list[ManifestExtractor] = [] + for entry in snapshot.producer_version_set or []: + text = str(entry) + name, separator, version = text.rpartition("@") + if not separator: + name, version = text, "" + extractors.append(ManifestExtractor(name=name, version=version)) + return sorted(extractors, key=lambda item: (item.name, item.version)) + + +def build_manifest(snapshot: RiSnapshot) -> RevisionManifest: + return RevisionManifest( + repository_id=snapshot.repository_id, + revision_kind=snapshot.revision_kind, # type: ignore[arg-type] + revision_value=snapshot.revision_value, + revision_ref=snapshot.revision_ref, + snapshot_id=snapshot.snapshot_id, + snapshot_schema_version=snapshot.schema_version, + extractors=_extractors(snapshot), + producer_set_hash=snapshot.producer_set_hash, + config_hash=snapshot.config_hash, + canonical_graph_hash=snapshot.canonical_graph_hash, + created_at=snapshot.created_at, + sealed_at=snapshot.sealed_at, + ) + + +def manifest_digest(manifest: RevisionManifest) -> str: + """Deterministic digest over the manifest body. + + Uses the same canonical JSON encoding the snapshot store uses for its own + hashes, so the digest is stable across processes, machines and orderings. + """ + + payload = manifest.model_dump(mode="json", by_alias=True) + return sha256_prefixed(canonical_json_bytes(payload)) + + +class RevisionManifestService: + """Owner-scoped manifest reads. Ownership is enforced by the query service.""" + + def __init__(self, snapshots: SnapshotQueryService) -> None: + self.snapshots = snapshots + + def _current_revision_snapshot(self, repository_id: str) -> RiSnapshot: + # The same resolution Review and Insights use, so the manifest always + # names the revision those surfaces describe. Resolving "latest sealed" + # independently let the manifest advertise a superseded revision while + # Review and Insights reported 404 for the current one. A repository + # owned by someone else stays indistinguishable from one that has never + # been analysed. + return self.snapshots.require_sealed_snapshot_for_current_revision(repository_id) + + def read(self, repository_id: str) -> RevisionManifestResponse: + snapshot = self._current_revision_snapshot(repository_id) + manifest = build_manifest(snapshot) + sealed = snapshot.canonical_graph_hash is not None + return RevisionManifestResponse( + manifest=manifest, + manifest_digest=manifest_digest(manifest), + verification_method=VERIFICATION_METHOD, + verification_state="verified" if sealed else "unverifiable", + verification_note=_SEALED_NOTE if sealed else _UNSEALED_NOTE, + ) + + def verify( + self, + repository_id: str, + submitted: RevisionManifest, + submitted_digest: str, + ) -> RevisionManifestVerificationResponse: + """Re-check a previously exported manifest against stored facts. + + Two independent checks must both pass: the submitted digest has to be + the digest of the submitted body (so tampering with a field is caught + even if the digest was left alone), and the submitted body has to equal + the manifest rebuilt from the stored snapshot (so a self-consistent but + fabricated manifest is caught too). + + The comparison is against the snapshot the manifest *names*, not against + whichever snapshot is newest. A manifest kept from before a re-analysis + is authentic and must be reported as authentic; calling it a mismatch + would tell a user their evidence had been altered when only the + repository moved on. That case is ``superseded``, and it is reported + separately from tampering. + """ + + current = self._current_revision_snapshot(repository_id) + + recomputed = manifest_digest(submitted) + if recomputed != submitted_digest: + # Reported before any lookup: a body that does not match its own + # digest has been altered regardless of which snapshot it names. + return RevisionManifestVerificationResponse( + verification_state="mismatch", + matches_stored_snapshot=False, + mismatched_fields=self._mismatched_fields(build_manifest(current), submitted), + detail=( + "The supplied digest is not the digest of the supplied manifest. " + "The manifest content has been altered." + ), + ) + + named = self.snapshots.sealed_snapshot_for_owner(repository_id, submitted.snapshot_id) + if named is None: + return RevisionManifestVerificationResponse( + verification_state="mismatch", + matches_stored_snapshot=False, + mismatched_fields=self._mismatched_fields(build_manifest(current), submitted), + detail=( + "This repository has no sealed snapshot with the identity this manifest " + "names, so the manifest cannot be confirmed against stored facts." + ), + ) + + mismatched = self._mismatched_fields(build_manifest(named), submitted) + if mismatched: + return RevisionManifestVerificationResponse( + verification_state="mismatch", + matches_stored_snapshot=False, + mismatched_fields=mismatched, + detail=( + "The manifest is internally consistent but does not match the snapshot stored for this repository." + ), + ) + if named.snapshot_id != current.snapshot_id: + return RevisionManifestVerificationResponse( + verification_state="superseded", + matches_stored_snapshot=True, + mismatched_fields=[], + detail=( + "The manifest matches a sealed snapshot stored for this repository, but " + "that snapshot is no longer the current revision. It remains a valid " + "record of the revision it names." + ), + ) + return RevisionManifestVerificationResponse( + verification_state="verified", + matches_stored_snapshot=True, + mismatched_fields=[], + detail="The manifest matches the sealed snapshot stored for this repository.", + ) + + @staticmethod + def _mismatched_fields(stored: RevisionManifest, submitted: RevisionManifest) -> list[str]: + stored_fields = stored.model_dump(mode="json", by_alias=True) + submitted_fields = submitted.model_dump(mode="json", by_alias=True) + return sorted(key for key in stored_fields if stored_fields[key] != submitted_fields.get(key)) diff --git a/apps/backend/app/analysis/resource_budget.py b/apps/backend/app/analysis/resource_budget.py new file mode 100644 index 00000000..fb3ddf9e --- /dev/null +++ b/apps/backend/app/analysis/resource_budget.py @@ -0,0 +1,114 @@ +"""Fail-closed resource budgets for one repository analysis run.""" + +from __future__ import annotations + +import ctypes +import importlib +import mmap +import sys +import time +from collections.abc import Callable +from dataclasses import dataclass, field +from pathlib import Path + + +RESOURCE_EXCEEDED_CODE = "resource_exceeded" +DEFAULT_MAX_REPOSITORY_SOURCE_BYTES = 1024 * 1024 * 1024 +DEFAULT_MAX_PROCESS_RSS_BYTES = 2 * 1024 * 1024 * 1024 +DEFAULT_MAX_ANALYSIS_SECONDS = 30 * 60 + + +class AnalysisResourceExceeded(RuntimeError): + """A repository analysis exceeded an operator-configured resource budget.""" + + def __init__(self, resource: str, limit: int | float, observed: int | float) -> None: + self.resource = resource + self.limit = limit + self.observed = observed + super().__init__(f"{RESOURCE_EXCEEDED_CODE}: {resource} budget exceeded ({observed} > {limit})") + + +def process_rss_bytes() -> int: + """Return this process's resident set size without an optional dependency.""" + + if sys.platform == "win32": + # PROCESS_MEMORY_COUNTERS.WorkingSetSize is the current resident set. + class _ProcessMemoryCounters(ctypes.Structure): + _fields_ = [ + ("cb", ctypes.c_ulong), + ("PageFaultCount", ctypes.c_ulong), + ("PeakWorkingSetSize", ctypes.c_size_t), + ("WorkingSetSize", ctypes.c_size_t), + ("QuotaPeakPagedPoolUsage", ctypes.c_size_t), + ("QuotaPagedPoolUsage", ctypes.c_size_t), + ("QuotaPeakNonPagedPoolUsage", ctypes.c_size_t), + ("QuotaNonPagedPoolUsage", ctypes.c_size_t), + ("PagefileUsage", ctypes.c_size_t), + ("PeakPagefileUsage", ctypes.c_size_t), + ] + + counters = _ProcessMemoryCounters() + counters.cb = ctypes.sizeof(counters) + windll = getattr(ctypes, "windll", None) + if windll is not None: + get_current_process = windll.kernel32.GetCurrentProcess + get_current_process.restype = ctypes.c_void_p + get_process_memory_info = windll.psapi.GetProcessMemoryInfo + get_process_memory_info.argtypes = ( + ctypes.c_void_p, + ctypes.POINTER(_ProcessMemoryCounters), + ctypes.c_ulong, + ) + get_process_memory_info.restype = ctypes.c_bool + process = get_current_process() + if get_process_memory_info(process, ctypes.byref(counters), counters.cb): + return int(counters.WorkingSetSize) + raise OSError("could not read the current Windows process RSS") + + statm = Path("/proc/self/statm") + if statm.exists(): + fields = statm.read_text(encoding="ascii").split() + if len(fields) >= 2: + return int(fields[1]) * mmap.PAGESIZE + + # Portable Unix fallback. Linux reports KiB; macOS reports bytes. + resource = importlib.import_module("resource") + maximum = int(resource.getrusage(resource.RUSAGE_SELF).ru_maxrss) + return maximum if sys.platform == "darwin" else maximum * 1024 + + +@dataclass +class AnalysisResourceBudget: + """Track total source bytes, elapsed time, and sampled process RSS.""" + + max_source_bytes: int + max_rss_bytes: int + max_seconds: float + rss_reader: Callable[[], int] = process_rss_bytes + monotonic: Callable[[], float] = time.monotonic + source_bytes: int = 0 + peak_rss_bytes: int = 0 + _started_at: float = field(init=False) + + def __post_init__(self) -> None: + if self.max_source_bytes <= 0 or self.max_rss_bytes <= 0 or self.max_seconds <= 0: + raise ValueError("analysis resource budgets must be greater than zero") + self._started_at = self.monotonic() + + def check(self) -> None: + elapsed = self.monotonic() - self._started_at + if elapsed > self.max_seconds: + raise AnalysisResourceExceeded("elapsed_seconds", self.max_seconds, elapsed) + rss = self.rss_reader() + self.peak_rss_bytes = max(self.peak_rss_bytes, rss) + if rss > self.max_rss_bytes: + raise AnalysisResourceExceeded("rss_bytes", self.max_rss_bytes, rss) + + def charge_source(self, size: int) -> None: + if size < 0: + raise ValueError("source size cannot be negative") + observed = self.source_bytes + size + if observed > self.max_source_bytes: + raise AnalysisResourceExceeded("source_bytes", self.max_source_bytes, observed) + self.source_bytes = observed + self.check() diff --git a/apps/backend/app/analysis/source_stream.py b/apps/backend/app/analysis/source_stream.py new file mode 100644 index 00000000..ae1dda16 --- /dev/null +++ b/apps/backend/app/analysis/source_stream.py @@ -0,0 +1,106 @@ +"""Deterministic, symlink-safe streaming of a stored repository manifest.""" + +from __future__ import annotations + +import os +import stat +from collections.abc import Callable, Iterator, Mapping, Sequence +from pathlib import Path, PurePosixPath + +from app.analysis.resource_budget import AnalysisResourceBudget +from app.intelligence import canonical + + +class UnsafeSourcePath(RuntimeError): + """A stored manifest path no longer resolves to a safe regular file.""" + + +class RepositorySourceStream: + """Yield bounded source payloads one at a time from the persisted file tree.""" + + def __init__( + self, + *, + root: Path, + file_tree: Sequence[object], + max_file_bytes: int, + budget: AnalysisResourceBudget, + check_cancelled: Callable[[], None] | None = None, + ) -> None: + if max_file_bytes <= 0: + raise ValueError("max_file_bytes must be greater than zero") + self.root = root.resolve(strict=True) + self.manifest = file_tree + self.max_file_bytes = max_file_bytes + self.budget = budget + self.check_cancelled = check_cancelled + + def __iter__(self) -> Iterator[tuple[str, bytes]]: + for path in self._manifest_paths(self.manifest): + self._checkpoint() + candidate = self.root.joinpath(*PurePosixPath(path).parts) + self._reject_symlink_components(candidate, path) + flags = os.O_RDONLY | getattr(os, "O_BINARY", 0) | getattr(os, "O_NOFOLLOW", 0) + try: + descriptor = os.open(candidate, flags) + except FileNotFoundError: + # Preserve the existing policy for files removed after ingestion. + continue + except OSError as exc: + raise UnsafeSourcePath(f"could not safely open stored source path {path!r}") from exc + try: + metadata = os.fstat(descriptor) + if not stat.S_ISREG(metadata.st_mode): + raise UnsafeSourcePath(f"stored source path {path!r} is not a regular file") + self.budget.charge_source(metadata.st_size) + with os.fdopen(descriptor, "rb", closefd=True) as handle: + descriptor = -1 + source = handle.read(self.max_file_bytes + 1) + finally: + if descriptor >= 0: + os.close(descriptor) + self._checkpoint() + yield path, source + + def _checkpoint(self) -> None: + if self.check_cancelled is not None: + self.check_cancelled() + self.budget.check() + + def _reject_symlink_components(self, candidate: Path, path: str) -> None: + current = self.root + for component in candidate.relative_to(self.root).parts: + current /= component + try: + is_junction = getattr(current, "is_junction", lambda: False)() + if current.is_symlink() or is_junction: + raise UnsafeSourcePath(f"stored source path {path!r} traverses a symlink") + except OSError as exc: + raise UnsafeSourcePath(f"could not validate stored source path {path!r}") from exc + + @classmethod + def _manifest_paths(cls, nodes: Sequence[object]) -> tuple[str, ...]: + normalized: set[str] = set() + for raw_path in cls._iter_file_paths(nodes): + # Persisted file-tree paths use one leading slash as a repository + # root marker. It is not a host-filesystem absolute path. + manifest_path = raw_path[1:] if raw_path.startswith("/") and not raw_path.startswith("//") else raw_path + try: + path = canonical.normalize_repo_path(manifest_path) + except canonical.PathEscapeError as exc: + raise UnsafeSourcePath(f"stored source path {raw_path!r} escapes the repository root") from exc + if not path: + raise UnsafeSourcePath("stored source path must not be empty") + normalized.add(path) + return tuple(sorted(normalized)) + + @classmethod + def _iter_file_paths(cls, nodes: Sequence[object]) -> Iterator[str]: + for node in nodes: + if not isinstance(node, Mapping): + continue + if node.get("type") == "file" and node.get("path"): + yield str(node["path"]) + children = node.get("children") + if isinstance(children, Sequence) and not isinstance(children, (str, bytes)): + yield from cls._iter_file_paths(children) diff --git a/apps/backend/app/api/deps.py b/apps/backend/app/api/deps.py index 20353e7a..30d9c2ca 100644 --- a/apps/backend/app/api/deps.py +++ b/apps/backend/app/api/deps.py @@ -1,29 +1,48 @@ from fastapi import Depends +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from sqlalchemy.orm import Session -from app.ai.orchestrator import AiOrchestrator, AiProviderConfigStore +from app.core.ai_egress import ProviderEgressPolicy +from app.ai.orchestrator import AiOrchestrator from app.ai.prompt_builder import PromptBuilder +from app.ai.providers.config_store import EncryptedProviderConfigStore from app.ai.providers.anthropic import AnthropicProvider from app.ai.providers.factory import ProviderFactory from app.ai.providers.gemini import GeminiProvider +from app.ai.providers.http import SecureProviderHttpSender from app.ai.providers.ollama import OllamaProvider from app.ai.providers.openai import OpenAIProvider from app.ai.providers.openrouter import OpenRouterProvider from app.ai.providers.registry import ProviderRegistry from app.ai.repository_context import RepositoryContextBuilder from app.analysis.architecture import ArchitectureAnalyzer +from app.analysis.authentication import AuthenticationExplanationService +from app.auth.oauth_providers import GitHubOAuthClient, GoogleOAuthClient +from app.auth.security import decode_access_token +from app.auth.service import AuthService from app.core.config import Settings, get_settings +from app.core.crypto import ProviderKeyCipher, build_provider_cipher from app.core.database import get_db +from app.core.exceptions import NotFoundError, UnauthorizedError from app.github.client import GitHubClient from app.graph.dependency_graph import DependencyGraphBuilder -from app.intelligence.engine import RepositoryIntelligenceEngine +from app.analysis.manifest import RevisionManifestService +from app.insights.service import RepositoryInsightsBuilder +from app.intelligence.query_service import SnapshotQueryService +from app.models.repository import RepositoryRecord +from app.models.user import User from app.parsers.repository_parser import RepositoryParser +from app.repositories.ai_conversation_repository import AiConversationRepository from app.repositories.repository_repository import RepositoryRepository from app.reports.export_service import ExportService from app.review.review_service import EngineeringReviewBuilder +from app.services.account_deletion_service import AccountDeletionService from app.services.ai_service import AiService +from app.services.analysis_job_service import AnalysisJobService from app.services.analysis_service import AnalysisService from app.services.documentation_service import DocumentationService +from app.services.evidence_service import EvidenceSourceService +from app.services.oauth_service import OAuthService from app.services.repository_service import RepositoryService from app.storage.local import LocalStorage @@ -32,10 +51,65 @@ def get_repository_repository(db: Session = Depends(get_db)) -> RepositoryReposi return RepositoryRepository(db) +_bearer_scheme = HTTPBearer(auto_error=False) + + +def get_auth_service( + db: Session = Depends(get_db), + settings: Settings = Depends(get_settings), +) -> AuthService: + return AuthService(db, settings) + + +def _user_from_bearer(token: str, db: Session, settings: Settings) -> User: + user_id = decode_access_token(token, settings) + user = db.get(User, user_id) + if user is None or not user.is_active: + raise UnauthorizedError("Invalid or expired access token.") + return user + + +def get_current_user( + credentials: HTTPAuthorizationCredentials | None = Depends(_bearer_scheme), + db: Session = Depends(get_db), + settings: Settings = Depends(get_settings), +) -> User: + """Require a valid Bearer access token and return its user (401 otherwise).""" + if credentials is None: + raise UnauthorizedError("Not authenticated.") + return _user_from_bearer(credentials.credentials, db, settings) + + +def get_google_oauth_client(settings: Settings = Depends(get_settings)) -> GoogleOAuthClient: + return GoogleOAuthClient(settings) + + +def get_github_oauth_client(settings: Settings = Depends(get_settings)) -> GitHubOAuthClient: + return GitHubOAuthClient(settings) + + +def get_oauth_service( + db: Session = Depends(get_db), + settings: Settings = Depends(get_settings), + auth_service: AuthService = Depends(get_auth_service), + google_client: GoogleOAuthClient = Depends(get_google_oauth_client), + github_client: GitHubOAuthClient = Depends(get_github_oauth_client), +) -> OAuthService: + return OAuthService(db, settings, auth_service, {"google": google_client, "github": github_client}) + + def get_local_storage(settings: Settings = Depends(get_settings)) -> LocalStorage: return LocalStorage(settings) +def get_account_deletion_service( + db: Session = Depends(get_db), + repository: RepositoryRepository = Depends(get_repository_repository), + storage: LocalStorage = Depends(get_local_storage), +) -> AccountDeletionService: + return AccountDeletionService(db, repository, storage) + + def get_github_client(settings: Settings = Depends(get_settings)) -> GitHubClient: return GitHubClient(settings) @@ -44,8 +118,11 @@ def get_repository_parser() -> RepositoryParser: return RepositoryParser() -def get_repository_intelligence_engine() -> RepositoryIntelligenceEngine: - return RepositoryIntelligenceEngine() +def get_snapshot_query_service( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> SnapshotQueryService: + return SnapshotQueryService(db, current_user.id) def get_repository_service( @@ -53,58 +130,126 @@ def get_repository_service( storage: LocalStorage = Depends(get_local_storage), github: GitHubClient = Depends(get_github_client), parser: RepositoryParser = Depends(get_repository_parser), - intelligence: RepositoryIntelligenceEngine = Depends(get_repository_intelligence_engine), settings: Settings = Depends(get_settings), + current_user: User = Depends(get_current_user), ) -> RepositoryService: return RepositoryService( repository=repository, storage=storage, github=github, parser=parser, - intelligence=intelligence, settings=settings, + owner_id=current_user.id, ) +def require_repository_owner( + repository_id: str, + repository: RepositoryRepository = Depends(get_repository_repository), + current_user: User = Depends(get_current_user), +) -> RepositoryRecord: + """Resolve URL-scoped repositories before request-body/query validation. + + Routes with additional required input can use this as a route dependency + so a non-owner receives the standard indistinguishable 404 even when that + input is missing or malformed. Services must remain owner-scoped as the + authoritative check; this dependency preserves the API's validation-order + contract. + """ + + record = repository.get_for_owner(repository_id, current_user.id) + if record is None: + raise NotFoundError("Repository not found.", {"repositoryId": repository_id}) + return record + + def get_architecture_analyzer( - intelligence: RepositoryIntelligenceEngine = Depends(get_repository_intelligence_engine), + snapshots: SnapshotQueryService = Depends(get_snapshot_query_service), ) -> ArchitectureAnalyzer: - return ArchitectureAnalyzer(intelligence) + return ArchitectureAnalyzer(snapshots) + + +def get_authentication_explanation_service( + snapshots: SnapshotQueryService = Depends(get_snapshot_query_service), +) -> AuthenticationExplanationService: + return AuthenticationExplanationService(snapshots) + + +def get_revision_manifest_service( + snapshots: SnapshotQueryService = Depends(get_snapshot_query_service), +) -> RevisionManifestService: + return RevisionManifestService(snapshots) + + +def get_evidence_source_service( + repository: RepositoryRepository = Depends(get_repository_repository), + snapshots: SnapshotQueryService = Depends(get_snapshot_query_service), + files: RepositoryService = Depends(get_repository_service), + current_user: User = Depends(get_current_user), +) -> EvidenceSourceService: + return EvidenceSourceService(repository, snapshots, files, current_user.id) def get_dependency_graph_builder( - intelligence: RepositoryIntelligenceEngine = Depends(get_repository_intelligence_engine), + snapshots: SnapshotQueryService = Depends(get_snapshot_query_service), ) -> DependencyGraphBuilder: - return DependencyGraphBuilder(intelligence) + return DependencyGraphBuilder(snapshots) def get_engineering_review_builder( - intelligence: RepositoryIntelligenceEngine = Depends(get_repository_intelligence_engine), + snapshots: SnapshotQueryService = Depends(get_snapshot_query_service), ) -> EngineeringReviewBuilder: - return EngineeringReviewBuilder(intelligence) + return EngineeringReviewBuilder(snapshots) + + +def get_repository_insights_builder( + snapshots: SnapshotQueryService = Depends(get_snapshot_query_service), +) -> RepositoryInsightsBuilder: + return RepositoryInsightsBuilder(snapshots) + + +def get_provider_cipher(settings: Settings = Depends(get_settings)) -> ProviderKeyCipher: + return build_provider_cipher(settings) -def get_ai_config_store(settings: Settings = Depends(get_settings)) -> AiProviderConfigStore: - return AiProviderConfigStore(settings) +def get_ai_egress_policy(settings: Settings = Depends(get_settings)) -> ProviderEgressPolicy: + return ProviderEgressPolicy.from_settings(settings) + + +def get_provider_http_sender( + policy: ProviderEgressPolicy = Depends(get_ai_egress_policy), +) -> SecureProviderHttpSender: + return SecureProviderHttpSender(policy) + + +def get_ai_config_store( + db: Session = Depends(get_db), + cipher: ProviderKeyCipher = Depends(get_provider_cipher), + policy: ProviderEgressPolicy = Depends(get_ai_egress_policy), + current_user: User = Depends(get_current_user), +) -> EncryptedProviderConfigStore: + return EncryptedProviderConfigStore(db, cipher, current_user.id, policy) def get_repository_context_builder( - intelligence: RepositoryIntelligenceEngine = Depends(get_repository_intelligence_engine), + snapshots: SnapshotQueryService = Depends(get_snapshot_query_service), ) -> RepositoryContextBuilder: - return RepositoryContextBuilder(intelligence) + return RepositoryContextBuilder(snapshots) def get_prompt_builder() -> PromptBuilder: return PromptBuilder() -def get_provider_registry() -> ProviderRegistry: +def get_provider_registry( + sender: SecureProviderHttpSender = Depends(get_provider_http_sender), +) -> ProviderRegistry: registry = ProviderRegistry() - registry.register("openai", OpenAIProvider()) - registry.register("anthropic", AnthropicProvider()) - registry.register("gemini", GeminiProvider()) - registry.register("openrouter", OpenRouterProvider()) - registry.register("ollama", OllamaProvider()) + registry.register("openai", OpenAIProvider(sender)) + registry.register("anthropic", AnthropicProvider(sender)) + registry.register("gemini", GeminiProvider(sender)) + registry.register("openrouter", OpenRouterProvider(sender)) + registry.register("ollama", OllamaProvider(sender)) return registry @@ -119,23 +264,40 @@ def get_analysis_service( architecture: ArchitectureAnalyzer = Depends(get_architecture_analyzer), dependencies: DependencyGraphBuilder = Depends(get_dependency_graph_builder), review: EngineeringReviewBuilder = Depends(get_engineering_review_builder), - intelligence: RepositoryIntelligenceEngine = Depends(get_repository_intelligence_engine), + insights: RepositoryInsightsBuilder = Depends(get_repository_insights_builder), + authentication: AuthenticationExplanationService = Depends(get_authentication_explanation_service), + current_user: User = Depends(get_current_user), ) -> AnalysisService: return AnalysisService( repository=repository, architecture=architecture, dependencies=dependencies, review=review, - intelligence=intelligence, + insights=insights, + authentication=authentication, + owner_id=current_user.id, ) +def get_analysis_job_service( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> AnalysisJobService: + return AnalysisJobService(db, owner_id=current_user.id) + + +def get_ai_conversation_repository(db: Session = Depends(get_db)) -> AiConversationRepository: + return AiConversationRepository(db) + + def get_ai_service( repository: RepositoryRepository = Depends(get_repository_repository), - config_store: AiProviderConfigStore = Depends(get_ai_config_store), + config_store: EncryptedProviderConfigStore = Depends(get_ai_config_store), context_builder: RepositoryContextBuilder = Depends(get_repository_context_builder), prompt_builder: PromptBuilder = Depends(get_prompt_builder), provider_factory: ProviderFactory = Depends(get_provider_factory), + conversation_repository: AiConversationRepository = Depends(get_ai_conversation_repository), + current_user: User = Depends(get_current_user), ) -> AiService: orchestrator = AiOrchestrator( repository=repository, @@ -143,17 +305,22 @@ def get_ai_service( context_builder=context_builder, prompt_builder=prompt_builder, provider_factory=provider_factory, + conversation_repository=conversation_repository, + owner_id=current_user.id, ) return AiService(orchestrator) def get_documentation_service( repository: RepositoryRepository = Depends(get_repository_repository), - architecture: ArchitectureAnalyzer = Depends(get_architecture_analyzer), - dependencies: DependencyGraphBuilder = Depends(get_dependency_graph_builder), - intelligence: RepositoryIntelligenceEngine = Depends(get_repository_intelligence_engine), + snapshots: SnapshotQueryService = Depends(get_snapshot_query_service), + current_user: User = Depends(get_current_user), ) -> DocumentationService: - return DocumentationService(repository=repository, architecture=architecture, dependencies=dependencies, intelligence=intelligence) + return DocumentationService( + repository=repository, + snapshots=snapshots, + owner_id=current_user.id, + ) def get_export_service( diff --git a/apps/backend/app/api/openapi.py b/apps/backend/app/api/openapi.py new file mode 100644 index 00000000..6d2cbb21 --- /dev/null +++ b/apps/backend/app/api/openapi.py @@ -0,0 +1,156 @@ +"""Reusable OpenAPI response metadata. + +The API has one runtime error envelope, :class:`ErrorResponse`. Route modules +use the helpers in this module to publish that fact consistently without +changing how requests are handled or how responses are serialised. +""" + +from collections.abc import Mapping +from typing import Any + +from app.core.exceptions import ErrorResponse + + +_ERROR_DESCRIPTIONS = { + 401: "Authentication is required or the access token is invalid.", + 404: "The requested resource does not exist or is not accessible to this user.", + 409: "The request conflicts with existing state.", + 422: "The request could not be validated.", + 429: "The request-rate limit has been exceeded.", + 500: "An unexpected server error occurred.", + 502: "An upstream service could not complete the request.", + 504: "An upstream service did not respond before the timeout.", +} + +_ERROR_EXAMPLES: dict[int, dict[str, Any]] = { + 401: { + "code": "unauthorized", + "message": "Not authenticated.", + "details": None, + "request_id": "req_01HXYZEXAMPLE", + }, + 404: { + "code": "not_found", + "message": "Repository not found.", + "details": {"repositoryId": "11111111-1111-1111-1111-111111111111"}, + "request_id": "req_01HXYZEXAMPLE", + }, + 409: { + "code": "conflict_error", + "message": "Repository has already been imported.", + "details": {"repositoryId": "11111111-1111-1111-1111-111111111111"}, + "request_id": "req_01HXYZEXAMPLE", + }, + 422: { + "code": "request_validation_error", + "message": "Request validation failed.", + "details": {"errors": [{"loc": ["body", "url"], "msg": "Field required"}]}, + "request_id": "req_01HXYZEXAMPLE", + }, + 429: { + "code": "rate_limited", + "message": "Too many requests. Try again shortly.", + "details": {"retryAfterSeconds": 30}, + "request_id": "req_01HXYZEXAMPLE", + }, + 500: { + "code": "internal_server_error", + "message": "An unexpected error occurred.", + "details": None, + "request_id": "req_01HXYZEXAMPLE", + }, + 502: { + "code": "external_service_error", + "message": "AI provider request failed.", + "details": {"provider": "openai"}, + "request_id": "req_01HXYZEXAMPLE", + }, + 504: { + "code": "timeout_error", + "message": "GitHub repository clone timed out.", + "details": {"timeoutSeconds": 120}, + "request_id": "req_01HXYZEXAMPLE", + }, +} + +_SUPPRESS_AUTOMATIC_422_MARKER = "x-partha-suppress-automatic-422" + + +def response_example( + description: str, + example: Any, + *, + media_type: str = "application/json", + schema: Mapping[str, Any] | None = None, +) -> dict[str, Any]: + """Describe a response body without affecting its runtime implementation.""" + media: dict[str, Any] = {"example": example} + if schema is not None: + media["schema"] = dict(schema) + return {"description": description, "content": {media_type: media}} + + +def error_responses(*status_codes: int) -> dict[int | str, dict[str, Any]]: + """Return documented standard-error responses for the supplied statuses.""" + missing = set(status_codes) - _ERROR_DESCRIPTIONS.keys() + if missing: + raise ValueError(f"No OpenAPI error metadata for status codes: {sorted(missing)}") + return { + status_code: { + "model": ErrorResponse, + **response_example( + _ERROR_DESCRIPTIONS[status_code], + _ERROR_EXAMPLES[status_code], + schema={"$ref": "#/components/schemas/ErrorResponse"}, + ), + } + for status_code in status_codes + } + + +def suppress_automatic_validation_error() -> dict[str, bool]: + """Mark an operation whose request shape cannot produce a 422 response. + + FastAPI adds a default 422 response for every route with any parameter, + including unconstrained string path parameters. Routes using this marker are + filtered from the generated document after FastAPI has assembled it. + """ + return {_SUPPRESS_AUTOMATIC_422_MARKER: True} + + +def remove_suppressed_automatic_validation_errors(document: dict[str, Any]) -> None: + """Remove FastAPI's synthetic 422 response from explicitly marked operations.""" + for path_item in document.get("paths", {}).values(): + for operation in path_item.values(): + if not isinstance(operation, dict) or not operation.pop(_SUPPRESS_AUTOMATIC_422_MARKER, False): + continue + operation.get("responses", {}).pop("422", None) + + +def documented_responses( + success_status: int, + success_description: str, + success_example: Any, + *error_status_codes: int, + media_type: str = "application/json", + schema: Mapping[str, Any] | None = None, +) -> dict[int | str, dict[str, Any]]: + """Combine one success example with route-specific standard errors. + + Typed with the ``int | str`` key FastAPI's own ``responses=`` parameter + declares (OpenAPI technically allows a "default" string key), not the + narrower ``int`` this always actually returns -- a ``dict[int, ...]`` + return type is not assignable where ``dict[int | str, ...]`` is expected + (dict is invariant in its key type), so every caller with no other type + debt already covering the file would fail exactly the way this one did. + """ + responses: dict[int | str, dict[str, Any]] = { + success_status: response_example( + success_description, + success_example, + media_type=media_type, + schema=schema, + ) + } + responses.update(error_responses(*error_status_codes)) + return responses diff --git a/apps/backend/app/api/router.py b/apps/backend/app/api/router.py index e01040e6..3114e0af 100644 --- a/apps/backend/app/api/router.py +++ b/apps/backend/app/api/router.py @@ -1,10 +1,14 @@ from fastapi import APIRouter -from app.api.routes import ai, analysis, documentation, reports, repositories +from app.api.routes import ai, analysis, auth, documentation, intelligence, oauth, reports, repositories, waitlist api_router = APIRouter() +api_router.include_router(auth.router) +api_router.include_router(oauth.router) api_router.include_router(repositories.router) +api_router.include_router(intelligence.router) api_router.include_router(analysis.router) api_router.include_router(ai.router) api_router.include_router(documentation.router) api_router.include_router(reports.router) +api_router.include_router(waitlist.router) diff --git a/apps/backend/app/api/routes/ai.py b/apps/backend/app/api/routes/ai.py index 2300c045..77fb57b2 100644 --- a/apps/backend/app/api/routes/ai.py +++ b/apps/backend/app/api/routes/ai.py @@ -1,43 +1,220 @@ -import json +from typing import Annotated -from fastapi import APIRouter, Depends -from fastapi.responses import StreamingResponse +from fastapi import APIRouter, Body, Depends, Query -from app.api.deps import get_ai_service -from app.schemas.ai import AiProviderConfig, AiProviderPublicConfig, AiProviderTestRequest, AiProviderTestResponse, AiQueryRequest, AiQueryResponse +from app.api.deps import get_ai_service, get_current_user +from app.api.openapi import documented_responses +from app.schemas.ai import ( + AiConversationResponse, + AiProviderCapabilitiesResponse, + AiProviderConfig, + AiProviderPublicConfig, + AiProviderTestRequest, + AiProviderTestResponse, + AiQueryRequest, + AiQueryResponse, +) from app.services.ai_service import AiService -router = APIRouter(prefix="/ai", tags=["ai"]) +# Every AI route requires auth; the repository and the provider config are both +# owner-scoped in the orchestrator, so a query can never run against another +# user's repository or spend their provider key. +router = APIRouter(prefix="/ai", tags=["ai"], dependencies=[Depends(get_current_user)]) +_REPOSITORY_ID = "11111111-1111-1111-1111-111111111111" +_PUBLIC_CONFIG_EXAMPLE = { + "provider": "openai", + "model": "gpt-4.1-mini", + "baseUrl": None, + "hasApiKey": True, + "apiKeyLast4": "1234", +} +_CONFIG_REQUEST_EXAMPLE = { + "summary": "Configure an OpenAI provider", + "value": {"provider": "openai", "apiKey": "sk-example-not-a-real-key", "model": "gpt-4.1-mini"}, +} +_TEST_REQUEST_EXAMPLE = { + "summary": "Test the saved provider configuration", + "value": {"provider": "openai", "model": "gpt-4.1-mini"}, +} +_CAPABILITIES_EXAMPLE = { + "providers": [ + { + "provider": "openai", + "displayName": "OpenAI", + "requiresApiKey": True, + "requiresBaseUrl": False, + "defaultModel": "gpt-4o-mini", + "setupUrl": "https://platform.openai.com/api-keys", + "setupSteps": [ + "Create an OpenAI account and generate an API key.", + "Paste in the API key.", + "Confirm the model ID (default: gpt-4o-mini).", + "Test the connection, then save.", + ], + "supportState": "supported", + }, + { + "provider": "ollama", + "displayName": "Ollama", + "requiresApiKey": False, + "requiresBaseUrl": True, + "defaultModel": "llama3.2", + "setupUrl": "https://ollama.com/download", + "setupSteps": [ + "Install and start Ollama, either locally or on a server you control.", + "Enter the base URL where it's running.", + "Confirm the model ID (default: llama3.2).", + "Test the connection, then save.", + ], + "supportState": "supported", + }, + ] +} +_QUERY_REQUEST_EXAMPLE = { + "summary": "Ask about an imported repository", + "value": {"repositoryId": _REPOSITORY_ID, "query": "Which modules handle authentication?"}, +} +_QUERY_RESPONSE_EXAMPLE = { + "message": { + "role": "assistant", + "content": "Authentication is handled by the auth module.", + "timestamp": "2026-07-17T00:00:00Z", + "citations": [], + }, + "suggestions": [], +} +_CONVERSATION_RESPONSE_EXAMPLE = { + "repositoryId": _REPOSITORY_ID, + "messages": [ + { + "role": "user", + "content": "Which modules handle authentication?", + "timestamp": "2026-07-17T00:00:00Z", + "citations": None, + }, + { + "role": "assistant", + "content": "Authentication is handled by the auth module.", + "timestamp": "2026-07-17T00:00:01Z", + "citations": None, + }, + ], +} -@router.get("/config", response_model=AiProviderPublicConfig) + +@router.get( + "/providers", + response_model=AiProviderCapabilitiesResponse, + responses=documented_responses( + 200, + "Safe, non-secret setup metadata for every supported provider.", + _CAPABILITIES_EXAMPLE, + 401, + 429, + 500, + ), +) +def list_ai_providers(service: AiService = Depends(get_ai_service)) -> AiProviderCapabilitiesResponse: + return service.list_provider_capabilities() + + +@router.get( + "/config", + response_model=AiProviderPublicConfig, + responses=documented_responses( + 200, + "Saved provider configuration without the full API key.", + _PUBLIC_CONFIG_EXAMPLE, + 401, + 429, + 500, + ), +) def get_ai_config(service: AiService = Depends(get_ai_service)) -> AiProviderPublicConfig: return service.get_config() -@router.put("/config", response_model=AiProviderPublicConfig) -def save_ai_config(config: AiProviderConfig, service: AiService = Depends(get_ai_service)) -> AiProviderPublicConfig: +@router.put( + "/config", + response_model=AiProviderPublicConfig, + responses=documented_responses( + 200, + "Provider configuration saved without returning the full API key.", + _PUBLIC_CONFIG_EXAMPLE, + 401, + 422, + 429, + 500, + ), +) +def save_ai_config( + config: Annotated[AiProviderConfig, Body(openapi_examples={"provider": _CONFIG_REQUEST_EXAMPLE})], + service: AiService = Depends(get_ai_service), +) -> AiProviderPublicConfig: return service.save_config(config) -@router.post("/test", response_model=AiProviderTestResponse) -async def test_ai_config(request: AiProviderTestRequest, service: AiService = Depends(get_ai_service)) -> AiProviderTestResponse: +@router.post( + "/test", + response_model=AiProviderTestResponse, + responses=documented_responses( + 200, + "Provider connectivity result.", + {"ok": True, "message": "Provider connection succeeded.", "checkedAt": "2026-07-17T00:00:00Z"}, + 401, + 422, + 429, + 502, + 500, + ), +) +async def test_ai_config( + request: Annotated[AiProviderTestRequest, Body(openapi_examples={"connection": _TEST_REQUEST_EXAMPLE})], + service: AiService = Depends(get_ai_service), +) -> AiProviderTestResponse: return await service.test_connection(request) -@router.post("/query", response_model=AiQueryResponse) -async def query_ai(request: AiQueryRequest, service: AiService = Depends(get_ai_service)) -> AiQueryResponse: +@router.post( + "/query", + response_model=AiQueryResponse, + responses=documented_responses( + 200, + "Repository-aware AI response.", + _QUERY_RESPONSE_EXAMPLE, + 401, + 404, + 422, + 429, + 502, + 500, + ), +) +async def query_ai( + request: Annotated[AiQueryRequest, Body(openapi_examples={"repository-question": _QUERY_REQUEST_EXAMPLE})], + service: AiService = Depends(get_ai_service), +) -> AiQueryResponse: return await service.query(request) -@router.post("/stream") -async def stream_ai(request: AiQueryRequest, service: AiService = Depends(get_ai_service)) -> StreamingResponse: - async def events(): - response = await service.query(request) - for word in response.message.content.split(" "): - yield f"data: {json.dumps({'type': 'content', 'content': word + ' '})}\n\n" - for citation in response.message.citations or []: - yield f"data: {json.dumps({'type': 'citation', 'citation': citation.model_dump(mode='json', by_alias=True)})}\n\n" - yield f"data: {json.dumps({'type': 'done'})}\n\n" - - return StreamingResponse(events(), media_type="text/event-stream") +@router.get( + "/conversations", + response_model=AiConversationResponse, + responses=documented_responses( + 200, + "Persisted conversation thread for a repository, oldest turn first.", + _CONVERSATION_RESPONSE_EXAMPLE, + 401, + 404, + 422, + 429, + 500, + ), +) +def list_ai_conversation( + repository_id: Annotated[str, Query(alias="repositoryId", min_length=1)], + service: AiService = Depends(get_ai_service), +) -> AiConversationResponse: + messages = service.list_conversation(repository_id) + return AiConversationResponse(repository_id=repository_id, messages=messages) diff --git a/apps/backend/app/api/routes/analysis.py b/apps/backend/app/api/routes/analysis.py index 1912d02c..7d1aa959 100644 --- a/apps/backend/app/api/routes/analysis.py +++ b/apps/backend/app/api/routes/analysis.py @@ -1,32 +1,171 @@ -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, Query -from app.api.deps import get_analysis_service +from app.api.deps import ( + get_analysis_job_service, + get_analysis_service, + get_current_user, + get_evidence_source_service, + require_repository_owner, + get_revision_manifest_service, +) +from app.api.openapi import documented_responses, suppress_automatic_validation_error +from app.models.analysis_job import AnalysisJob from app.schemas.analysis import AnalysisStartResponse, AnalysisStatusResponse from app.schemas.architecture import ArchitectureResponse +from app.schemas.authentication import AuthenticationExplanationResponse from app.schemas.dependencies import DependencyGraphResponse -from app.schemas.review import EngineeringReviewResponse +from app.schemas.evidence import EvidenceSourceResponse +from app.schemas.insights import RepositoryInsightsResponse +from app.schemas.manifest import ( + RevisionManifestResponse, + RevisionManifestVerificationRequest, + RevisionManifestVerificationResponse, +) +from app.schemas.review import EngineeringReviewResponse, ReviewCategoryId, ReviewSeverity +from app.services.analysis_job_service import AnalysisJobService from app.services.analysis_service import AnalysisService +from app.services.evidence_service import EvidenceSourceService +from app.analysis.manifest import RevisionManifestService -router = APIRouter(prefix="/analysis", tags=["analysis"]) +# Every analysis route requires auth; records are owner-scoped in the services. +router = APIRouter(prefix="/analysis", tags=["analysis"], dependencies=[Depends(get_current_user)]) +_REPOSITORY_ID = "11111111-1111-1111-1111-111111111111" +_JOB_ID = "22222222-2222-2222-2222-222222222222" +_COMMON_ERRORS = (401, 404, 429, 500) +_CANCEL_ERRORS = (401, 404, 409, 429, 500) +_REVIEW_ERRORS = (401, 404, 422, 429, 500) +_REVIEW_DEFAULT_LIMIT = 50 +_REVIEW_MAX_LIMIT = 200 -@router.post("/{repository_id}/start", response_model=AnalysisStartResponse) + +def _status_response(repository_id: str, job: AnalysisJob | None) -> AnalysisStatusResponse: + """Map the durable job row to the status contract. + + A missing job means analysis has never been submitted; the route surfaces + that as ``queued`` with zero progress rather than inventing a separate + "not started" state. + """ + + if job is None: + return AnalysisStatusResponse(repository_id=repository_id, status="queued", progress=0) + return AnalysisStatusResponse( + repository_id=repository_id, + status=job.status, + job_id=job.id, + stage=job.stage, + progress=job.progress, + started_at=job.started_at, + completed_at=job.completed_at, + error=job.error_message, + ) + + +@router.post( + "/{repository_id}/start", + response_model=AnalysisStartResponse, + responses=documented_responses( + 200, + "Analysis was durably enqueued (or already complete); the request never blocks on the worker.", + {"repositoryId": _REPOSITORY_ID, "status": "queued", "jobId": _JOB_ID}, + *_COMMON_ERRORS, + ), + openapi_extra=suppress_automatic_validation_error(), +) def start_analysis( repository_id: str, - service: AnalysisService = Depends(get_analysis_service), + service: AnalysisJobService = Depends(get_analysis_job_service), ) -> AnalysisStartResponse: - return service.start(repository_id) + job = service.submit(repository_id) + return AnalysisStartResponse(repository_id=job.repository_id, status=job.status, job_id=job.id) -@router.get("/{repository_id}/status", response_model=AnalysisStatusResponse) +@router.get( + "/{repository_id}/status", + response_model=AnalysisStatusResponse, + responses=documented_responses( + 200, + "Current durable analysis-job status.", + { + "repositoryId": _REPOSITORY_ID, + "status": "completed", + "jobId": _JOB_ID, + "stage": "completed", + "progress": 100, + "startedAt": "2026-07-17T00:00:00Z", + "completedAt": "2026-07-17T00:00:02Z", + "error": None, + }, + *_COMMON_ERRORS, + ), + openapi_extra=suppress_automatic_validation_error(), +) def get_analysis_status( repository_id: str, - service: AnalysisService = Depends(get_analysis_service), + service: AnalysisJobService = Depends(get_analysis_job_service), ) -> AnalysisStatusResponse: - return service.status(repository_id) + return _status_response(repository_id, service.status(repository_id)) -@router.get("/{repository_id}/architecture", response_model=ArchitectureResponse) +@router.post( + "/{repository_id}/cancel", + response_model=AnalysisStatusResponse, + responses=documented_responses( + 200, + "Analysis cancellation was accepted; a running job is cancelled cooperatively.", + { + "repositoryId": _REPOSITORY_ID, + "status": "cancelled", + "jobId": _JOB_ID, + "stage": None, + "progress": 0, + "startedAt": None, + "completedAt": "2026-07-17T00:00:01Z", + "error": None, + }, + *_CANCEL_ERRORS, + ), + openapi_extra=suppress_automatic_validation_error(), +) +def cancel_analysis( + repository_id: str, + service: AnalysisJobService = Depends(get_analysis_job_service), +) -> AnalysisStatusResponse: + return _status_response(repository_id, service.cancel(repository_id)) + + +@router.get( + "/{repository_id}/architecture", + response_model=ArchitectureResponse, + responses=documented_responses( + 200, + "Architecture model derived from the sealed repository-intelligence snapshot " + "bound to the repository's current revision. A repository with no sealed " + "snapshot yet returns 404, never a fallback graph (#217).", + { + "repositoryId": _REPOSITORY_ID, + "repositoryName": "example-service", + "architectureType": "Layered application", + "detectedLayers": [], + "nodes": [], + "edges": [], + "modules": [], + "requestFlow": [], + "relationshipSnapshotId": "snap_example", + "diagnostics": [], + "summary": { + "language": "Python", + "framework": "FastAPI", + "totalModules": 0, + "totalNodes": 0, + "entryPoint": "/app/main.py", + "architecturePattern": "Layered application", + }, + }, + *_COMMON_ERRORS, + ), + openapi_extra=suppress_automatic_validation_error(), +) def get_architecture( repository_id: str, service: AnalysisService = Depends(get_analysis_service), @@ -34,7 +173,234 @@ def get_architecture( return service.architecture_model(repository_id) -@router.get("/{repository_id}/dependencies", response_model=DependencyGraphResponse) +@router.get( + "/{repository_id}/architecture/authentication", + response_model=AuthenticationExplanationResponse, + responses=documented_responses( + 200, + "Evidence-backed explanation of how authentication works, read exclusively " + "from the sealed Repository Intelligence snapshot query layer.", + { + "schemaVersion": "auth-explanation.v1", + "repositoryId": _REPOSITORY_ID, + "repositoryName": "example-service", + "revisionKind": "upload", + "revisionValue": "sha256:" + "0" * 64, + "snapshotId": "snap_example", + "status": "ready", + "summary": ( + "Found 1 authentication-relevant route(s), 1 middleware/guard dependency(ies), " + "1 service(s), and 1 model(s), each backed by a citation to its exact stored source span." + ), + "claims": [ + { + "kind": "route", + "name": "/me", + "confidence": "observed", + "evidence": [ + { + "snapshotId": "snap_example", + "factId": "src/routes.py::(anonymous:route#1)", + "path": "src/routes.py", + "startLine": 10, + "endLine": 10, + } + ], + } + ], + "relationships": [], + "diagnostics": [], + }, + *_COMMON_ERRORS, + ), + openapi_extra=suppress_automatic_validation_error(), +) +def get_authentication_explanation( + repository_id: str, + service: AnalysisService = Depends(get_analysis_service), +) -> AuthenticationExplanationResponse: + return service.authentication_explanation(repository_id) + + +@router.get( + "/{repository_id}/evidence", + response_model=EvidenceSourceResponse, + dependencies=[Depends(require_repository_owner)], + responses=documented_responses( + 200, + "Source text for one evidence citation, verified against the exact snapshot revision it was cited from.", + { + "schemaVersion": "evidence-source.v1", + "repositoryId": _REPOSITORY_ID, + "snapshotId": "snap_example", + "factId": "src/routes.py::(anonymous:route#1)", + "revisionKind": "upload", + "revisionValue": "sha256:" + "0" * 64, + "path": "src/routes.py", + "startLine": 10, + "endLine": 10, + "status": "ready", + "reason": None, + "content": '@app.get("/me")\n', + "truncated": False, + "size": 16, + }, + 401, + 404, + 422, + 429, + 500, + ), +) +def get_evidence_source( + repository_id: str, + snapshot_id: str = Query(..., alias="snapshotId"), + fact_id: str = Query(..., alias="factId", min_length=1, max_length=1024), + path: str = Query(...), + start_line: int = Query(..., alias="startLine", ge=1), + end_line: int = Query(..., alias="endLine", ge=1), + service: EvidenceSourceService = Depends(get_evidence_source_service), +) -> EvidenceSourceResponse: + return service.read(repository_id, snapshot_id, fact_id, path, start_line, end_line) + + +_MANIFEST_EXAMPLE = { + "manifest": { + "schemaVersion": "revision-manifest.v1", + "repositoryId": _REPOSITORY_ID, + "revisionKind": "upload", + "revisionValue": "sha256:" + "0" * 64, + "revisionRef": None, + "snapshotId": "snap_example", + "snapshotSchemaVersion": "ri.v1", + "extractors": [ + {"name": "typescript-extractor", "version": "1.0.0"}, + {"name": "relationship-resolver", "version": "1.0.0"}, + ], + "producerSetHash": "sha256:" + "1" * 64, + "configHash": "sha256:" + "2" * 64, + "canonicalGraphHash": "sha256:" + "3" * 64, + "createdAt": "2026-07-25T00:00:00Z", + "sealedAt": "2026-07-25T00:00:05Z", + }, + "manifestDigest": "sha256:" + "4" * 64, + "verificationMethod": "sha256-canonical-json", + "verificationState": "verified", + "verificationNote": ( + "This digest is a SHA-256 over the canonical JSON encoding of the manifest " + "fields above. It is a content hash, not a digital signature." + ), +} + + +@router.get( + "/{repository_id}/revision-manifest", + response_model=RevisionManifestResponse, + dependencies=[Depends(require_repository_owner)], + responses=documented_responses( + 200, + "Verifiable identity of the sealed snapshot backing this repository's " + "evidence: revision, snapshot, extractor versions and canonical digest. " + "The digest is a canonical content hash, not a digital signature.", + _MANIFEST_EXAMPLE, + *_COMMON_ERRORS, + ), + openapi_extra=suppress_automatic_validation_error(), +) +def get_revision_manifest( + repository_id: str, + service: RevisionManifestService = Depends(get_revision_manifest_service), +) -> RevisionManifestResponse: + return service.read(repository_id) + + +@router.post( + "/{repository_id}/revision-manifest/verify", + response_model=RevisionManifestVerificationResponse, + dependencies=[Depends(require_repository_owner)], + responses=documented_responses( + 200, + "Re-check a previously exported manifest against the stored snapshot. " + "Reports a mismatch when the supplied digest does not match the supplied " + "manifest, or when the manifest does not match stored facts.", + { + "verificationState": "verified", + "verificationMethod": "sha256-canonical-json", + "matchesStoredSnapshot": True, + "mismatchedFields": [], + "detail": "The manifest matches the sealed snapshot stored for this repository.", + }, + 401, + 404, + 422, + 429, + 500, + ), +) +def verify_revision_manifest( + repository_id: str, + request: RevisionManifestVerificationRequest, + service: RevisionManifestService = Depends(get_revision_manifest_service), +) -> RevisionManifestVerificationResponse: + return service.verify(repository_id, request.manifest, request.manifest_digest) + + +@router.get( + "/{repository_id}/dependencies", + response_model=DependencyGraphResponse, + responses=documented_responses( + 200, + "Dependency inventory and resolved depends_on relationships from the selected sealed ri.v1 snapshot.", + { + "schemaVersion": "dependency-graph.v2", + "repositoryId": _REPOSITORY_ID, + "repositoryName": "example-service", + "revisionKind": "upload", + "revisionValue": "sha256:" + "0" * 64, + "snapshotId": "snap_example", + "snapshotSchemaVersion": "ri.v1", + "canonicalGraphHash": "sha256:" + "1" * 64, + "manifestDigest": "sha256:" + "2" * 64, + "provenance": { + "source": "ri.v1", + "snapshotId": "snap_example", + "snapshotSchemaVersion": "ri.v1", + "canonicalGraphHash": "sha256:" + "1" * 64, + }, + "generatedAt": "2026-07-17T00:00:00Z", + "nodes": [ + { + "id": "dep:npm:react", + "name": "react", + "version": "^18.3.0", + "type": "production", + "ecosystem": "npm", + "declarations": [ + { + "name": "react", + "manifestPath": "apps/frontend/package.json", + "workspacePath": "apps/frontend", + "startLine": 3, + "endLine": 3, + "extractor": "dependency-manifest", + "extractorVersion": "1.2.0", + "ecosystem": "npm", + "version": "^18.3.0", + "type": "production", + } + ], + } + ], + "edges": [{"id": "edge_example", "source": "repo:root", "target": "dep:npm:react", "type": "depends-on"}], + "totalDependencies": 1, + "manifestCount": 1, + "diagnostics": [], + "vulnerabilityAssessment": {"status": "not_computed"}, + "outdatedAssessment": {"status": "not_computed"}, + }, + *_REVIEW_ERRORS, + ), +) def get_dependencies( repository_id: str, service: AnalysisService = Depends(get_analysis_service), @@ -42,9 +408,122 @@ def get_dependencies( return service.dependency_graph(repository_id) -@router.get("/{repository_id}/review", response_model=EngineeringReviewResponse) +@router.get( + "/{repository_id}/review", + response_model=EngineeringReviewResponse, + responses=documented_responses( + 200, + "Deterministic evidence-backed findings for the selected sealed ri.v1 snapshot.", + { + "schemaVersion": "engineering-review.v2", + "repositoryId": _REPOSITORY_ID, + "repositoryName": "example-service", + "revisionKind": "upload", + "revisionValue": "sha256:" + "0" * 64, + "snapshotId": "snap_example", + "snapshotSchemaVersion": "ri.v1", + "canonicalGraphHash": "sha256:" + "1" * 64, + "manifestDigest": "sha256:" + "2" * 64, + "provenance": { + "source": "ri.v1", + "snapshotId": "snap_example", + "snapshotSchemaVersion": "ri.v1", + "canonicalGraphHash": "sha256:" + "1" * 64, + }, + "generatedAt": "2026-07-17T00:00:00Z", + "assessmentStatus": "partially_assessed", + "categories": [], + "findings": [], + "pagination": {"offset": 0, "limit": 50, "total": 0}, + "summary": { + "message": ( + "0 evidence-backed findings were identified in this revision. " + "Security vulnerability scanning was not performed." + ), + "findingsBySeverity": { + "info": 0, + "low": 0, + "medium": 0, + "high": 0, + "critical": 0, + }, + "assessedCategories": 2, + "partiallyAssessedCategories": 4, + "notAssessedCategories": 0, + "insufficientEvidenceCategories": 1, + "evidenceBackedFindingCount": 0, + "fileScopedFindingCount": 0, + "omittedUnsupportedDiagnosticCount": 0, + "vulnerabilityScanning": "not_assessed", + }, + }, + *_REVIEW_ERRORS, + ), +) def get_review( repository_id: str, + category: ReviewCategoryId | None = Query(None, description="Return only findings in this category."), + severity: ReviewSeverity | None = Query(None, description="Return only findings at this severity."), + diagnostic_code: str | None = Query( + None, alias="diagnosticCode", description="Return only findings for this diagnostic code." + ), + offset: int = Query(0, ge=0, description="Zero-based offset into the matched findings."), + limit: int = Query( + _REVIEW_DEFAULT_LIMIT, ge=1, le=_REVIEW_MAX_LIMIT, description=f"Maximum {_REVIEW_MAX_LIMIT} findings per page." + ), service: AnalysisService = Depends(get_analysis_service), ) -> EngineeringReviewResponse: - return service.engineering_review(repository_id) + return service.engineering_review( + repository_id, + category=category, + severity=severity, + diagnostic_code=diagnostic_code, + offset=offset, + limit=limit, + ) + + +@router.get( + "/{repository_id}/insights", + response_model=RepositoryInsightsResponse, + responses=documented_responses( + 200, + "Defined repository metrics computed only from the selected sealed ri.v1 snapshot.", + { + "schemaVersion": "repository-insights.v1", + "repositoryId": _REPOSITORY_ID, + "repositoryName": "example-service", + "revisionKind": "upload", + "revisionValue": "sha256:" + "0" * 64, + "snapshotId": "snap_example", + "snapshotSchemaVersion": "ri.v1", + "canonicalGraphHash": "sha256:" + "1" * 64, + "manifestDigest": "sha256:" + "2" * 64, + "provenance": { + "source": "ri.v1", + "snapshotId": "snap_example", + "snapshotSchemaVersion": "ri.v1", + "canonicalGraphHash": "sha256:" + "1" * 64, + }, + "computedAt": "2026-07-17T00:00:00Z", + "snapshotCreatedAt": "2026-07-17T00:00:00Z", + "snapshotSealedAt": "2026-07-17T00:00:00Z", + "extractorSet": [], + "metrics": [], + "relationshipsByPredicate": [], + "diagnosticsBySeverity": [], + "diagnosticsByCode": [], + "languages": [], + "changeOverTime": { + "assessmentState": "not_assessed", + "message": "Change-over-time insights are not available yet.", + }, + }, + *_REVIEW_ERRORS, + ), +) +def get_insights( + repository_id: str, + service: AnalysisService = Depends(get_analysis_service), +) -> RepositoryInsightsResponse: + return service.repository_insights(repository_id) diff --git a/apps/backend/app/api/routes/auth.py b/apps/backend/app/api/routes/auth.py new file mode 100644 index 00000000..244f1fe8 --- /dev/null +++ b/apps/backend/app/api/routes/auth.py @@ -0,0 +1,181 @@ +from typing import Annotated + +from fastapi import APIRouter, Body, Cookie, Depends, Response, status + +from app.api.deps import get_account_deletion_service, get_auth_service, get_current_user +from app.api.openapi import documented_responses, error_responses, suppress_automatic_validation_error +from app.auth.service import AuthService +from app.core.config import Settings, get_settings +from app.core.exceptions import UnauthorizedError +from app.models.user import User +from app.schemas.auth import AccountDeletionRequest, AuthResponse, LoginRequest, RegisterRequest, UserResponse +from app.services.account_deletion_service import AccountDeletionService + +router = APIRouter(prefix="/auth", tags=["auth"]) + +REFRESH_COOKIE = "partha_refresh" + +_USER_EXAMPLE = { + "id": "11111111-1111-1111-1111-111111111111", + "email": "developer@example.com", + "createdAt": "2026-07-17T00:00:00Z", +} +_AUTH_RESPONSE_EXAMPLE = { + "accessToken": "example-access-token", + "tokenType": "bearer", + "user": _USER_EXAMPLE, +} +_REGISTER_EXAMPLE = { + "summary": "Create an account", + "value": { + "email": "developer@example.com", + "password": "correct-horse-battery-staple", + }, +} +_LOGIN_EXAMPLE = { + "summary": "Sign in to an existing account", + "value": {"email": "developer@example.com", "password": "correct-horse-battery-staple"}, +} + + +def _set_refresh_cookie(response: Response, raw_token: str, settings: Settings) -> None: + response.set_cookie( + key=REFRESH_COOKIE, + value=raw_token, + max_age=settings.refresh_token_ttl_seconds, + httponly=True, + samesite="lax", + # Secure cookies would be dropped over plain-http local development. + secure=settings.app_env not in {"development", "test"}, + path="/auth", + ) + + +@router.post( + "/register", + response_model=AuthResponse, + status_code=status.HTTP_201_CREATED, + responses=documented_responses( + status.HTTP_201_CREATED, + "Account created and a refresh-cookie session started.", + _AUTH_RESPONSE_EXAMPLE, + 409, + 422, + 429, + 500, + ), +) +def register( + request: Annotated[RegisterRequest, Body(openapi_examples={"registration": _REGISTER_EXAMPLE})], + response: Response, + service: AuthService = Depends(get_auth_service), + settings: Settings = Depends(get_settings), +) -> AuthResponse: + user, access_token, raw_refresh = service.register(request.email, request.password) + _set_refresh_cookie(response, raw_refresh, settings) + return AuthResponse(access_token=access_token, user=UserResponse.model_validate(user)) + + +@router.post( + "/login", + response_model=AuthResponse, + responses=documented_responses( + status.HTTP_200_OK, + "Authenticated session with an access token and refresh cookie.", + _AUTH_RESPONSE_EXAMPLE, + 401, + 422, + 429, + 500, + ), +) +def login( + request: Annotated[LoginRequest, Body(openapi_examples={"login": _LOGIN_EXAMPLE})], + response: Response, + service: AuthService = Depends(get_auth_service), + settings: Settings = Depends(get_settings), +) -> AuthResponse: + user, access_token, raw_refresh = service.login(request.email, request.password) + _set_refresh_cookie(response, raw_refresh, settings) + return AuthResponse(access_token=access_token, user=UserResponse.model_validate(user)) + + +@router.post( + "/refresh", + response_model=AuthResponse, + responses=documented_responses( + status.HTTP_200_OK, + "Access token renewed and the refresh token rotated.", + _AUTH_RESPONSE_EXAMPLE, + 401, + 422, + 429, + 500, + ), +) +def refresh( + response: Response, + refresh_token: str | None = Cookie(default=None, alias=REFRESH_COOKIE), + service: AuthService = Depends(get_auth_service), + settings: Settings = Depends(get_settings), +) -> AuthResponse: + if not refresh_token: + raise UnauthorizedError("Missing refresh token.") + user, access_token, raw_refresh = service.refresh(refresh_token) + _set_refresh_cookie(response, raw_refresh, settings) + return AuthResponse(access_token=access_token, user=UserResponse.model_validate(user)) + + +@router.post( + "/logout", + status_code=status.HTTP_204_NO_CONTENT, + responses=error_responses(429, 500), + openapi_extra=suppress_automatic_validation_error(), +) +def logout( + refresh_token: str | None = Cookie(default=None, alias=REFRESH_COOKIE), + service: AuthService = Depends(get_auth_service), +) -> Response: + service.logout(refresh_token) + response = Response(status_code=status.HTTP_204_NO_CONTENT) + response.delete_cookie(REFRESH_COOKIE, path="/auth") + return response + + +@router.get( + "/me", + response_model=UserResponse, + responses=documented_responses( + status.HTTP_200_OK, + "The authenticated user.", + _USER_EXAMPLE, + 401, + 429, + 500, + ), +) +def me(current_user: User = Depends(get_current_user)) -> UserResponse: + return UserResponse.model_validate(current_user) + + +@router.delete( + "/me", + status_code=status.HTTP_204_NO_CONTENT, + responses=error_responses(401, 422, 429, 500), +) +def delete_account( + request: AccountDeletionRequest, + current_user: User = Depends(get_current_user), + service: AccountDeletionService = Depends(get_account_deletion_service), +) -> Response: + """Permanently delete the caller's account and every owner-scoped record. + + Requires the account password and the account email typed back as a + deliberate confirmation (schemas.auth.AccountDeletionRequest) — this is a + destructive, unrecoverable operation. See AccountDeletionService for the + deletion order and AccountDeletionAuditRecord for the audit trail. + """ + service.delete_account(current_user, request.password, request.confirm_email) + response = Response(status_code=status.HTTP_204_NO_CONTENT) + response.delete_cookie(REFRESH_COOKIE, path="/auth") + return response diff --git a/apps/backend/app/api/routes/documentation.py b/apps/backend/app/api/routes/documentation.py index 9c13fbae..d8644d25 100644 --- a/apps/backend/app/api/routes/documentation.py +++ b/apps/backend/app/api/routes/documentation.py @@ -1,15 +1,52 @@ -from fastapi import APIRouter, Depends +from typing import Annotated -from app.api.deps import get_documentation_service +from fastapi import APIRouter, Body, Depends + +from app.api.deps import get_current_user, get_documentation_service +from app.api.openapi import documented_responses from app.schemas.documentation import GenerateDocRequest, GenerateDocResponse from app.services.documentation_service import DocumentationService -router = APIRouter(prefix="/documentation", tags=["documentation"]) +# Every documentation route requires auth; records are owner-scoped in the service. +router = APIRouter(prefix="/documentation", tags=["documentation"], dependencies=[Depends(get_current_user)]) + +_REQUEST_EXAMPLE = { + "summary": "Generate a concise Markdown document", + "value": { + "repositoryId": "11111111-1111-1111-1111-111111111111", + "format": "markdown", + "sections": ["overview", "architecture"], + }, +} +_RESPONSE_EXAMPLE = { + "content": "# Example service\\n\\n## Overview\\n\\nGenerated documentation.", + "format": "markdown", + "generatedAt": "2026-07-17T00:00:00Z", + "source": "ri.v1", + "snapshotId": "snap_example", + "snapshotSchemaVersion": "ri.v1", + "revisionKind": "git", + "revisionValue": "0123456789abcdef0123456789abcdef01234567", + "revisionRef": "refs/heads/main", +} -@router.post("/generate", response_model=GenerateDocResponse) +@router.post( + "/generate", + response_model=GenerateDocResponse, + responses=documented_responses( + 200, + "Generated documentation for the requested repository.", + _RESPONSE_EXAMPLE, + 401, + 404, + 422, + 429, + 500, + ), +) def generate_documentation( - request: GenerateDocRequest, + request: Annotated[GenerateDocRequest, Body(openapi_examples={"markdown": _REQUEST_EXAMPLE})], service: DocumentationService = Depends(get_documentation_service), ) -> GenerateDocResponse: return service.generate(request) diff --git a/apps/backend/app/api/routes/intelligence.py b/apps/backend/app/api/routes/intelligence.py new file mode 100644 index 00000000..d3f982c3 --- /dev/null +++ b/apps/backend/app/api/routes/intelligence.py @@ -0,0 +1,391 @@ +"""Read-only API for immutable, owner-scoped Repository Intelligence snapshots.""" + +from typing import Annotated + +from fastapi import APIRouter, Depends, Query + +from app.api.deps import get_current_user, get_snapshot_query_service +from app.api.openapi import documented_responses +from app.intelligence.query_service import IMPACT_MAX_DEPTH, SnapshotQueryService +from app.schemas.intelligence import ( + RiAssertionResponse, + RiAssertionsResponse, + RiEdgeResponse, + RiEvidenceResponse, + RiEvidenceResponsePage, + RiImpactDirectionResponse, + RiImpactResponse, + RiImpactStepResponse, + RiNeighboursResponse, + RiNodeResponse, + RiPagination, + RiPathResponse, + RiPathsResponse, + RiReferencesResponse, + RiSnapshotMetadataResponse, + RiSymbolsResponse, +) + +router = APIRouter( + prefix="/intelligence/v1/snapshots", + tags=["repository-intelligence"], + dependencies=[Depends(get_current_user)], +) + +_DEFAULT_LIMIT = 50 +_MAX_LIMIT = 100 +PaginationOffset = Annotated[int, Query(ge=0, description="Zero-based deterministic result offset.")] +PaginationLimit = Annotated[int, Query(ge=1, le=_MAX_LIMIT, description="Maximum 100 results per page.")] +ImpactDepth = Annotated[ + int, + Query( + ge=1, + le=IMPACT_MAX_DEPTH, + description=f"Directed import/dependency traversal depth (1-{IMPACT_MAX_DEPTH}).", + ), +] + + +def _metadata(snapshot) -> RiSnapshotMetadataResponse: + return RiSnapshotMetadataResponse( + schema_version=snapshot.schema_version, + snapshot_id=snapshot.snapshot_id, + repository_id=snapshot.repository_id, + revision_kind=snapshot.revision_kind, + revision_value=snapshot.revision_value, + revision_ref=snapshot.revision_ref, + state="completed", + producer_version_set=snapshot.producer_version_set, + producer_set_hash=snapshot.producer_set_hash, + config_hash=snapshot.config_hash, + canonical_graph_hash=snapshot.canonical_graph_hash, + created_at=snapshot.created_at, + updated_at=snapshot.updated_at, + sealed_at=snapshot.sealed_at, + ) + + +def _evidence(snapshot, item, *, fact_kind: str, fact_id: str) -> RiEvidenceResponse: + return RiEvidenceResponse( + schema_version=snapshot.schema_version, + fact_kind=fact_kind, + fact_id=fact_id, + path=item.path, + start_line=item.start_line, + end_line=item.end_line, + granularity=item.granularity, + extractor=item.extractor, + extractor_version=item.extractor_version, + ) + + +def _node(snapshot, node, evidence) -> RiNodeResponse: + return RiNodeResponse( + stable_key=node.stable_key, + node_kind=node.node_kind, + name=node.name, + language=node.language, + truth_class=node.truth_class, + properties=node.properties, + evidence=[ + _evidence(snapshot, item, fact_kind="node", fact_id=node.stable_key) for item in evidence.get(node.id, []) + ], + ) + + +def _edge(snapshot, edge, evidence, derivations) -> RiEdgeResponse: + return RiEdgeResponse( + edge_id=edge.edge_id, + subject_kind=edge.subject_kind, + subject_key=edge.subject_key, + predicate=edge.predicate, + object_kind=edge.object_kind, + object_key=edge.object_key, + truth_class=edge.truth_class, + producer=edge.producer, + producer_version=edge.producer_version, + evidence=[ + _evidence(snapshot, item, fact_kind="edge", fact_id=edge.edge_id) for item in evidence.get(edge.id, []) + ], + derived_from=[{"kind": item.ref_kind, "identity": item.ref_identity} for item in derivations.get(edge.id, [])], + ) + + +def _assertion(assertion, derivations) -> RiAssertionResponse: + return RiAssertionResponse( + assertion_id=assertion.assertion_id, + subject_kind=assertion.subject_kind, + subject_key=assertion.subject_key, + predicate=assertion.predicate, + value=assertion.value, + truth_class=assertion.truth_class, + producer=assertion.producer, + producer_version=assertion.producer_version, + derived_from=[ + {"kind": item.ref_kind, "identity": item.ref_identity} for item in derivations.get(assertion.id, []) + ], + ) + + +def _pagination(offset: int, limit: int, total: int) -> RiPagination: + return RiPagination(offset=offset, limit=limit, total=total) + + +def _impact_direction(snapshot, direction, evidence, derivations) -> RiImpactDirectionResponse: + return RiImpactDirectionResponse( + data=[ + RiImpactStepResponse( + depth=step.depth, + node_key=step.node_key, + via=_edge(snapshot, step.edge, evidence, derivations), + ) + for step in direction.steps + ], + limit_reached=direction.limit_reached, + ) + + +@router.get( + "/{snapshot_id}", + response_model=RiSnapshotMetadataResponse, + responses=documented_responses( + 200, "Sealed ri.v1 snapshot metadata.", {"schemaVersion": "ri.v1"}, 401, 404, 422, 429, 500 + ), +) +def get_snapshot( + snapshot_id: str, service: SnapshotQueryService = Depends(get_snapshot_query_service) +) -> RiSnapshotMetadataResponse: + return _metadata(service.metadata(snapshot_id)) + + +@router.get( + "/{snapshot_id}/symbols", + response_model=RiSymbolsResponse, + responses=documented_responses( + 200, + "Observed symbol nodes with stored evidence.", + {"schemaVersion": "ri.v1", "data": [], "pagination": {"offset": 0, "limit": 50, "total": 0}}, + 401, + 404, + 422, + 429, + 500, + ), +) +def list_symbols( + snapshot_id: str, + service: SnapshotQueryService = Depends(get_snapshot_query_service), + offset: PaginationOffset = 0, + limit: PaginationLimit = _DEFAULT_LIMIT, +) -> RiSymbolsResponse: + snapshot, nodes, total = service.symbols(snapshot_id, offset=offset, limit=limit) + evidence = service.evidence_for_nodes(snapshot, nodes) + return RiSymbolsResponse( + schema_version=snapshot.schema_version, + data=[_node(snapshot, node, evidence) for node in nodes], + pagination=_pagination(offset, limit, total), + ) + + +@router.get( + "/{snapshot_id}/neighbours", + response_model=RiNeighboursResponse, + responses=documented_responses( + 200, + "Stored resolved edges incident to a node.", + { + "schemaVersion": "ri.v1", + "nodeKey": "repo:root", + "data": [], + "pagination": {"offset": 0, "limit": 50, "total": 0}, + }, + 401, + 404, + 422, + 429, + 500, + ), +) +def list_neighbours( + snapshot_id: str, + node_key: Annotated[str, Query(alias="nodeKey", min_length=1, max_length=1024)], + service: SnapshotQueryService = Depends(get_snapshot_query_service), + offset: PaginationOffset = 0, + limit: PaginationLimit = _DEFAULT_LIMIT, +) -> RiNeighboursResponse: + snapshot, edges, total = service.neighbours(snapshot_id, node_key=node_key, offset=offset, limit=limit) + evidence = service.evidence_for_edges(snapshot, edges) + derivations = service.derivations_for_edges(snapshot, edges) + return RiNeighboursResponse( + schema_version=snapshot.schema_version, + node_key=node_key, + data=[_edge(snapshot, edge, evidence, derivations) for edge in edges], + pagination=_pagination(offset, limit, total), + ) + + +@router.get( + "/{snapshot_id}/impact", + response_model=RiImpactResponse, + responses=documented_responses( + 200, + "Bounded, provenance-backed dependents and dependencies over stored import/dependency edges.", + { + "schemaVersion": "ri.v1", + "nodeKey": "file:src/api.py", + "depth": 1, + "dependents": {"data": [], "limitReached": False}, + "dependencies": {"data": [], "limitReached": False}, + }, + 401, + 404, + 422, + 429, + 500, + ), +) +def get_impact( + snapshot_id: str, + node_key: Annotated[str, Query(alias="nodeKey", min_length=1, max_length=1024)], + service: SnapshotQueryService = Depends(get_snapshot_query_service), + depth: ImpactDepth = 1, +) -> RiImpactResponse: + impact = service.impact(snapshot_id, node_key=node_key, depth=depth) + edges = [step.edge for direction in (impact.dependents, impact.dependencies) for step in direction.steps] + evidence = service.evidence_for_edges(impact.snapshot, edges) + derivations = service.derivations_for_edges(impact.snapshot, edges) + return RiImpactResponse( + schema_version=impact.snapshot.schema_version, + node_key=impact.node_key, + depth=impact.depth, + dependents=_impact_direction(impact.snapshot, impact.dependents, evidence, derivations), + dependencies=_impact_direction(impact.snapshot, impact.dependencies, evidence, derivations), + ) + + +@router.get( + "/{snapshot_id}/references", + response_model=RiReferencesResponse, + responses=documented_responses( + 200, + "Stored resolved relationship facts only.", + {"schemaVersion": "ri.v1", "data": [], "pagination": {"offset": 0, "limit": 50, "total": 0}}, + 401, + 404, + 422, + 429, + 500, + ), +) +def list_references( + snapshot_id: str, + service: SnapshotQueryService = Depends(get_snapshot_query_service), + offset: PaginationOffset = 0, + limit: PaginationLimit = _DEFAULT_LIMIT, +) -> RiReferencesResponse: + snapshot, edges, total = service.references(snapshot_id, offset=offset, limit=limit) + evidence = service.evidence_for_edges(snapshot, edges) + derivations = service.derivations_for_edges(snapshot, edges) + return RiReferencesResponse( + schema_version=snapshot.schema_version, + data=[_edge(snapshot, edge, evidence, derivations) for edge in edges], + pagination=_pagination(offset, limit, total), + ) + + +@router.get( + "/{snapshot_id}/assertions", + response_model=RiAssertionsResponse, + responses=documented_responses( + 200, + "Stored inferred assertions with their derivations.", + {"schemaVersion": "ri.v1", "data": [], "pagination": {"offset": 0, "limit": 50, "total": 0}}, + 401, + 404, + 422, + 429, + 500, + ), +) +def list_assertions( + snapshot_id: str, + service: SnapshotQueryService = Depends(get_snapshot_query_service), + offset: PaginationOffset = 0, + limit: PaginationLimit = _DEFAULT_LIMIT, +) -> RiAssertionsResponse: + snapshot, assertions, total = service.assertions(snapshot_id, offset=offset, limit=limit) + derivations = service.derivations_for_assertions(snapshot, assertions) + return RiAssertionsResponse( + schema_version=snapshot.schema_version, + data=[_assertion(assertion, derivations) for assertion in assertions], + pagination=_pagination(offset, limit, total), + ) + + +@router.get( + "/{snapshot_id}/paths", + response_model=RiPathsResponse, + responses=documented_responses( + 200, + "Stored file facts with provenance.", + {"schemaVersion": "ri.v1", "data": [], "pagination": {"offset": 0, "limit": 50, "total": 0}}, + 401, + 404, + 422, + 429, + 500, + ), +) +def list_paths( + snapshot_id: str, + service: SnapshotQueryService = Depends(get_snapshot_query_service), + offset: PaginationOffset = 0, + limit: PaginationLimit = _DEFAULT_LIMIT, +) -> RiPathsResponse: + snapshot, nodes, total = service.paths(snapshot_id, offset=offset, limit=limit) + evidence = service.evidence_for_nodes(snapshot, nodes) + return RiPathsResponse( + schema_version=snapshot.schema_version, + data=[ + RiPathResponse(path=node.stable_key.removeprefix("file:"), node=_node(snapshot, node, evidence)) + for node in nodes + ], + pagination=_pagination(offset, limit, total), + ) + + +@router.get( + "/{snapshot_id}/evidence", + response_model=RiEvidenceResponsePage, + responses=documented_responses( + 200, + "Stored evidence spans only.", + {"schemaVersion": "ri.v1", "data": [], "pagination": {"offset": 0, "limit": 50, "total": 0}}, + 401, + 404, + 422, + 429, + 500, + ), +) +def list_evidence( + snapshot_id: str, + service: SnapshotQueryService = Depends(get_snapshot_query_service), + offset: PaginationOffset = 0, + limit: PaginationLimit = _DEFAULT_LIMIT, +) -> RiEvidenceResponsePage: + snapshot, rows, total = service.evidence(snapshot_id, offset=offset, limit=limit) + identities = service.fact_identity_for_evidence(snapshot, rows) + data = [] + for item in rows: + if item.node_ref is not None: + fact_kind, parent_id = "node", item.node_ref + elif item.edge_ref is not None: + fact_kind, parent_id = "edge", item.edge_ref + else: + fact_kind, parent_id = "observation", item.observation_ref + fact_id = identities[(fact_kind, parent_id)] + data.append(_evidence(snapshot, item, fact_kind=fact_kind, fact_id=fact_id)) + return RiEvidenceResponsePage( + schema_version=snapshot.schema_version, data=data, pagination=_pagination(offset, limit, total) + ) diff --git a/apps/backend/app/api/routes/oauth.py b/apps/backend/app/api/routes/oauth.py new file mode 100644 index 00000000..4db0eaeb --- /dev/null +++ b/apps/backend/app/api/routes/oauth.py @@ -0,0 +1,236 @@ +"""Google/GitHub OAuth sign-in and account linking (#288). + +Credentials-deferred build -- see the comment on issue #288 for exactly what +still needs the owner's real client id/secret plus a decided public callback +domain before this can go live. Every code path here is exercised in tests +against clearly-fake mocked provider clients (app/auth/oauth_providers.py); +nothing here makes a real network call in tests. +""" + +from typing import Annotated + +from fastapi import APIRouter, Body, Depends, Query, Request, Response, status +from fastapi.responses import RedirectResponse + +from app.api.deps import get_current_user, get_oauth_service +from app.api.openapi import documented_responses, error_responses +from app.api.routes.auth import _set_refresh_cookie +from app.core.config import Settings, get_settings +from app.core.exceptions import ValidationServiceError +from app.models.user import User +from app.schemas.auth import ( + AuthResponse, + OAuthLinkConfirmRequest, + OAuthLinkedIdentitiesResponse, + OAuthLinkedIdentity, + OAuthProvidersResponse, + OAuthStartResponse, + UserResponse, +) +from app.services.oauth_service import OAuthService + +router = APIRouter(prefix="/auth/oauth", tags=["auth"]) + +# The only two providers OAuthService is ever wired with (app/api/deps.py); +# validated here so an unsupported path segment fails fast with a normal 422 +# rather than surfacing as an opaque "provider unavailable" from the service. +_KNOWN_PROVIDERS = {"google", "github"} + +_PROVIDERS_EXAMPLE = {"providers": ["google"]} +_START_EXAMPLE = {"authorizeUrl": "https://accounts.google.com/o/oauth2/v2/auth?client_id=..."} +_LINKED_EXAMPLE = { + "identities": [{"provider": "google", "email": "developer@example.com", "createdAt": "2026-07-17T00:00:00Z"}] +} +_LINK_CONFIRM_AUTH_EXAMPLE = { + "accessToken": "example-access-token", + "tokenType": "bearer", + "user": { + "id": "11111111-1111-1111-1111-111111111111", + "email": "developer@example.com", + "createdAt": "2026-07-17T00:00:00Z", + }, +} +_LINK_CONFIRM_REQUEST_EXAMPLE = { + "summary": "Confirm the discovered identity belongs to this account", + "value": { + "pendingLinkId": "11111111-1111-1111-1111-111111111111", + "password": "correct-horse-battery-staple", + }, +} + + +def _validate_provider(provider: str) -> str: + if provider not in _KNOWN_PROVIDERS: + raise ValidationServiceError(f"Unsupported provider: {provider}") + return provider + + +def _redirect_base(request: Request, settings: Settings) -> str: + """The frontend origin to send the browser back to once the callback + finishes, success or error. + + Trusts only an Origin/Referer that is itself one of the configured CORS + origins -- the same trust boundary already enforced for cross-origin + requests -- and otherwise falls back to the first configured origin so a + request with a missing or unrecognized header still gets a safe, + deterministic place to land. + """ + for header_name in ("origin", "referer"): + header = request.headers.get(header_name) + if not header: + continue + candidate = header.rstrip("/") + for origin in settings.cors_origins: + if candidate == origin or candidate.startswith(origin + "/"): + return origin + return settings.cors_origins[0] + + +@router.get( + "/providers", + response_model=OAuthProvidersResponse, + responses=documented_responses(200, "Providers with real credentials configured.", _PROVIDERS_EXAMPLE, 429, 500), +) +def list_oauth_providers(service: OAuthService = Depends(get_oauth_service)) -> OAuthProvidersResponse: + return OAuthProvidersResponse(providers=service.configured_providers()) + + +@router.get( + "/{provider}/start", + response_model=OAuthStartResponse, + responses=documented_responses(200, "Authorize URL to send the browser to.", _START_EXAMPLE, 422, 429, 500), +) +def start_oauth_login( + provider: str, + request: Request, + settings: Settings = Depends(get_settings), + service: OAuthService = Depends(get_oauth_service), +) -> OAuthStartResponse: + provider = _validate_provider(provider) + url = service.start(provider, intent="login", frontend_redirect_base=_redirect_base(request, settings)) + return OAuthStartResponse(authorize_url=url) + + +@router.post( + "/{provider}/link", + response_model=OAuthStartResponse, + responses=documented_responses( + 200, "Authorize URL to link this provider to the caller's account.", _START_EXAMPLE, 401, 422, 429, 500 + ), +) +def start_oauth_link( + provider: str, + request: Request, + settings: Settings = Depends(get_settings), + current_user: User = Depends(get_current_user), + service: OAuthService = Depends(get_oauth_service), +) -> OAuthStartResponse: + provider = _validate_provider(provider) + url = service.start( + provider, + intent="link", + frontend_redirect_base=_redirect_base(request, settings), + link_user_id=current_user.id, + ) + return OAuthStartResponse(authorize_url=url) + + +@router.get( + "/{provider}/callback", + responses=documented_responses( + 200, + "Always redirects (302) to the frontend's /oauth/complete route with the outcome in the query " + "string; the schema below documents the shape only to satisfy this API's success-example " + "convention -- no caller ever receives this body.", + {"note": "This operation always returns a 302 redirect, never this body."}, + 422, + 429, + 500, + schema={"type": "object", "properties": {"note": {"type": "string"}}}, + ), +) +async def oauth_callback( + provider: str, + settings: Settings = Depends(get_settings), + service: OAuthService = Depends(get_oauth_service), + code: str | None = Query(default=None), + state: str | None = Query(default=None), + error: str | None = Query(default=None), +) -> RedirectResponse: + """The browser lands here via a top-level navigation from the provider, + never via an XHR/fetch call -- so the only way to hand the outcome back + is a redirect to the frontend's own /oauth/complete route, which reads + the query string and finishes locally (see useAuthStore.bootstrap() on + the success path, which re-derives the access token from the refresh + cookie this response sets).""" + provider = _validate_provider(provider) + if not state: + raise ValidationServiceError("Missing OAuth state.") + frontend_base, result = await service.complete_callback(provider, state=state, code=code, provider_error=error) + + if result.kind == "session": + redirect = RedirectResponse( + url=f"{frontend_base}/oauth/complete?status=success", status_code=status.HTTP_302_FOUND + ) + _set_refresh_cookie(redirect, result.refresh_token or "", settings) + return redirect + if result.kind == "linked": + return RedirectResponse(url=f"{frontend_base}/oauth/complete?status=linked", status_code=status.HTTP_302_FOUND) + if result.kind == "pending_link": + return RedirectResponse( + url=f"{frontend_base}/oauth/complete?status=pending-link&pendingLinkId={result.pending_link_id}&provider={provider}", + status_code=status.HTTP_302_FOUND, + ) + return RedirectResponse( + url=f"{frontend_base}/oauth/complete?status=error&reason={result.error_code or 'unknown'}", + status_code=status.HTTP_302_FOUND, + ) + + +@router.post( + "/link/confirm", + response_model=AuthResponse, + responses=documented_responses(200, "Linked and signed in.", _LINK_CONFIRM_AUTH_EXAMPLE, 401, 409, 422, 429, 500), +) +def confirm_oauth_link( + body: Annotated[OAuthLinkConfirmRequest, Body(openapi_examples={"confirm": _LINK_CONFIRM_REQUEST_EXAMPLE})], + response: Response, + settings: Settings = Depends(get_settings), + service: OAuthService = Depends(get_oauth_service), +) -> AuthResponse: + user, access_token, raw_refresh = service.confirm_pending_link(body.pending_link_id, body.password) + _set_refresh_cookie(response, raw_refresh, settings) + return AuthResponse(access_token=access_token, user=UserResponse.model_validate(user)) + + +@router.get( + "/linked", + response_model=OAuthLinkedIdentitiesResponse, + responses=documented_responses(200, "The caller's linked provider identities.", _LINKED_EXAMPLE, 401, 429, 500), +) +def list_linked_identities( + current_user: User = Depends(get_current_user), + service: OAuthService = Depends(get_oauth_service), +) -> OAuthLinkedIdentitiesResponse: + identities = service.linked_identities(current_user.id) + return OAuthLinkedIdentitiesResponse( + identities=[ + OAuthLinkedIdentity(provider=identity.provider, email=identity.email, created_at=identity.created_at) + for identity in identities + ] + ) + + +@router.delete( + "/{provider}", + status_code=status.HTTP_204_NO_CONTENT, + responses=error_responses(401, 404, 422, 429, 500), +) +def unlink_oauth_provider( + provider: str, + current_user: User = Depends(get_current_user), + service: OAuthService = Depends(get_oauth_service), +) -> Response: + provider = _validate_provider(provider) + service.unlink(current_user, provider) + return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/apps/backend/app/api/routes/reports.py b/apps/backend/app/api/routes/reports.py index 8becc5f6..9860960b 100644 --- a/apps/backend/app/api/routes/reports.py +++ b/apps/backend/app/api/routes/reports.py @@ -1,15 +1,49 @@ -from fastapi import APIRouter, Depends +from typing import Annotated -from app.api.deps import get_export_service +from fastapi import APIRouter, Body, Depends + +from app.api.deps import get_current_user, get_export_service +from app.api.openapi import documented_responses from app.reports.export_service import ExportService from app.schemas.reports import ExportRequest, ExportResponse -router = APIRouter(tags=["export"]) +# The export route requires auth; the underlying analysis/documentation services +# resolve the repository owner-scoped, so exports return only the caller's data. +router = APIRouter(tags=["export"], dependencies=[Depends(get_current_user)]) + +_REQUEST_EXAMPLE = { + "summary": "Export the engineering review as JSON", + "value": { + "repositoryId": "11111111-1111-1111-1111-111111111111", + "target": "review", + "format": "json", + }, +} +_RESPONSE_EXAMPLE = { + "filename": "engineering-review.json", + "mediaType": "application/json", + "encoding": "utf-8", + "content": '{\\n \\"repositoryId\\": \\"11111111-1111-1111-1111-111111111111\\"\\n}', +} -@router.post("/export", response_model=ExportResponse) +@router.post( + "/export", + response_model=ExportResponse, + responses=documented_responses( + 200, + "Repository report rendered in the requested format; an uncomputed review returns 409.", + _RESPONSE_EXAMPLE, + 401, + 404, + 409, + 422, + 429, + 500, + ), +) def export_report( - request: ExportRequest, + request: Annotated[ExportRequest, Body(openapi_examples={"review-json": _REQUEST_EXAMPLE})], service: ExportService = Depends(get_export_service), ) -> ExportResponse: return service.export(request) diff --git a/apps/backend/app/api/routes/repositories.py b/apps/backend/app/api/routes/repositories.py index 5ba8b676..f8645552 100644 --- a/apps/backend/app/api/routes/repositories.py +++ b/apps/backend/app/api/routes/repositories.py @@ -1,18 +1,117 @@ -from fastapi import APIRouter, Depends, Query, Response, UploadFile, status +from typing import Annotated -from app.api.deps import get_repository_service +from fastapi import APIRouter, Body, Depends, Query, Response, UploadFile, status + +from app.api.deps import get_current_user, get_repository_service +from app.api.openapi import documented_responses, error_responses, suppress_automatic_validation_error from app.schemas.repository import ( GitHubImportRequest, RepositoryFileResponse, + RepositoryLineageResponse, RepositoryListResponse, RepositoryResponse, ) from app.services.repository_service import RepositoryService -router = APIRouter(prefix="/repositories", tags=["repositories"]) +# Router-level auth: every repository route requires a valid access token, so a +# new route added here is protected by default instead of by remembering to add +# a dependency. Data is additionally owner-scoped inside RepositoryService. +router = APIRouter(prefix="/repositories", tags=["repositories"], dependencies=[Depends(get_current_user)]) + +_REPOSITORY_ID = "11111111-1111-1111-1111-111111111111" +_REPOSITORY_EXAMPLE = { + "id": _REPOSITORY_ID, + "name": "example-service", + "description": None, + "source": "github", + "sourceUrl": "https://github.com/example/example-service", + "branch": "main", + "size": 2048, + "fileCount": 12, + "status": "completed", + "analysisStage": "completed", + "analysisProgress": 100, + "uploadedAt": "2026-07-17T00:00:00Z", + "analysedAt": "2026-07-17T00:00:02Z", + "errorMessage": None, + "revision": { + "kind": "git", + "value": "0123456789abcdef0123456789abcdef01234567", + "ref": "refs/heads/main", + }, + "commitSha": "0123456789abcdef0123456789abcdef01234567", + "meta": None, + "fileTree": [], +} +_REPOSITORY_LINEAGE_EXAMPLE = { + "isLineaged": True, + "lineageId": "22222222-2222-2222-2222-222222222222", + "canonicalSourceKey": "github.com/example/example-service", + "canonicalBranch": "refs/heads/main", + "entries": [ + { + "repositoryId": _REPOSITORY_ID, + "sequence": 2, + "name": "example-service", + "status": "completed", + "revision": { + "kind": "git", + "value": "0123456789abcdef0123456789abcdef01234567", + "ref": "refs/heads/main", + }, + "uploadedAt": "2026-07-17T00:00:00Z", + "isCurrent": True, + }, + { + "repositoryId": "33333333-3333-3333-3333-333333333333", + "sequence": 1, + "name": "example-service", + "status": "completed", + "revision": { + "kind": "git", + "value": "abcdef0123456789abcdef0123456789abcdef01", + "ref": "refs/heads/main", + }, + "uploadedAt": "2026-06-01T00:00:00Z", + "isCurrent": False, + }, + ], +} +_GITHUB_IMPORT_EXAMPLE = { + "summary": "Import a public GitHub repository", + "value": {"url": "https://github.com/octocat/Hello-World", "branch": "master"}, +} -@router.post("/upload", response_model=RepositoryResponse, status_code=status.HTTP_201_CREATED) +@router.post( + "/upload", + response_model=RepositoryResponse, + status_code=status.HTTP_201_CREATED, + responses=documented_responses( + status.HTTP_201_CREATED, + "Repository archive accepted and parsed.", + _REPOSITORY_EXAMPLE, + 401, + 409, + 422, + 429, + 500, + ), + openapi_extra={ + "requestBody": { + "content": { + "multipart/form-data": { + "examples": { + "archive": { + "summary": "Repository archive", + "value": {"file": "example-service.zip"}, + } + } + } + } + } + }, +) async def upload_repository( file: UploadFile, service: RepositoryService = Depends(get_repository_service), @@ -20,22 +119,62 @@ async def upload_repository( return await service.import_uploaded_repository(file) -@router.post("/github", response_model=RepositoryResponse, status_code=status.HTTP_201_CREATED) +@router.post( + "/github", + response_model=RepositoryResponse, + status_code=status.HTTP_201_CREATED, + responses=documented_responses( + status.HTTP_201_CREATED, + "Public GitHub repository cloned and parsed.", + _REPOSITORY_EXAMPLE, + 401, + 409, + 422, + 429, + 502, + 504, + 500, + ), +) def import_github_repository( - request: GitHubImportRequest, + request: Annotated[GitHubImportRequest, Body(openapi_examples={"github": _GITHUB_IMPORT_EXAMPLE})], service: RepositoryService = Depends(get_repository_service), ) -> RepositoryResponse: return service.import_github_repository(request) -@router.get("", response_model=RepositoryListResponse) +@router.get( + "", + response_model=RepositoryListResponse, + responses=documented_responses( + status.HTTP_200_OK, + "Repositories owned by the authenticated user.", + {"data": [_REPOSITORY_EXAMPLE], "total": 1}, + 401, + 429, + 500, + ), +) def list_repositories( service: RepositoryService = Depends(get_repository_service), ) -> RepositoryListResponse: return service.list_repositories() -@router.get("/{repository_id}", response_model=RepositoryResponse) +@router.get( + "/{repository_id}", + response_model=RepositoryResponse, + responses=documented_responses( + status.HTTP_200_OK, + "Repository metadata and file tree.", + _REPOSITORY_EXAMPLE, + 401, + 404, + 429, + 500, + ), + openapi_extra=suppress_automatic_validation_error(), +) def get_repository( repository_id: str, service: RepositoryService = Depends(get_repository_service), @@ -43,7 +182,50 @@ def get_repository( return service.get_repository(repository_id) -@router.get("/{repository_id}/file", response_model=RepositoryFileResponse) +@router.get( + "/{repository_id}/lineage", + response_model=RepositoryLineageResponse, + responses=documented_responses( + status.HTTP_200_OK, + "History of repository imports this repository belongs to, most recent first. " + "A standalone (unlineaged) repository returns `isLineaged: false` and a single entry: itself.", + _REPOSITORY_LINEAGE_EXAMPLE, + 401, + 404, + 429, + 500, + ), + openapi_extra=suppress_automatic_validation_error(), +) +def get_repository_lineage( + repository_id: str, + service: RepositoryService = Depends(get_repository_service), +) -> RepositoryLineageResponse: + return service.get_lineage(repository_id) + + +@router.get( + "/{repository_id}/file", + response_model=RepositoryFileResponse, + responses=documented_responses( + status.HTTP_200_OK, + "Preview of a repository-relative file.", + { + "path": "/README.md", + "content": "# Example service\\n", + "size": 18, + "truncated": False, + "isBinary": False, + "isImage": False, + "mediaType": None, + }, + 401, + 404, + 422, + 429, + 500, + ), +) def get_repository_file( repository_id: str, path: str = Query(..., description="Repository-relative file path."), @@ -52,7 +234,12 @@ def get_repository_file( return service.read_file(repository_id, path) -@router.delete("/{repository_id}", status_code=status.HTTP_204_NO_CONTENT) +@router.delete( + "/{repository_id}", + status_code=status.HTTP_204_NO_CONTENT, + responses=error_responses(401, 404, 429, 500), + openapi_extra=suppress_automatic_validation_error(), +) def delete_repository( repository_id: str, service: RepositoryService = Depends(get_repository_service), diff --git a/apps/backend/app/api/routes/waitlist.py b/apps/backend/app/api/routes/waitlist.py new file mode 100644 index 00000000..0702e9ff --- /dev/null +++ b/apps/backend/app/api/routes/waitlist.py @@ -0,0 +1,59 @@ +from typing import Annotated +from uuid import uuid4 + +from fastapi import APIRouter, Body, Depends, status +from sqlalchemy import select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from app.api.openapi import documented_responses +from app.core.database import get_db +from app.models.waitlist_entry import WaitlistEntry +from app.schemas.waitlist import WaitlistSignupRequest, WaitlistSignupResponse + +# Deliberately no auth dependency: this is the one public-facing write route +# in the API, reachable from the landing page before anyone has an account +# or an invite. Its own per-minute budget in the "auth" rate-limit class is +# the abuse guard. +router = APIRouter(prefix="/waitlist", tags=["waitlist"]) + +_SIGNUP_EXAMPLE = { + "summary": "Join the waitlist", + "value": {"email": "interested@example.com", "name": "Jane Doe"}, +} +_RESPONSE_EXAMPLE = {"status": "ok"} + + +@router.post( + "", + response_model=WaitlistSignupResponse, + status_code=status.HTTP_201_CREATED, + responses=documented_responses( + status.HTTP_201_CREATED, + "Recorded. The same response is returned whether or not this email had already signed up.", + _RESPONSE_EXAMPLE, + 422, + 429, + 500, + ), +) +def join_waitlist( + request: Annotated[WaitlistSignupRequest, Body(openapi_examples={"signup": _SIGNUP_EXAMPLE})], + db: Session = Depends(get_db), +) -> WaitlistSignupResponse: + normalized = request.email.strip().lower() + existing = db.scalars(select(WaitlistEntry).where(WaitlistEntry.email == normalized)).first() + if existing is None: + db.add(WaitlistEntry(id=str(uuid4()), email=normalized, name=(request.name or "").strip() or None)) + try: + db.commit() + except IntegrityError: + # A concurrent signup for the same email landed between the + # check above and this insert -- the row exists either way, so + # this is still a success from the caller's point of view, not + # an error to surface. + db.rollback() + # Same response for a brand-new signup, a repeat signup, and a + # concurrent-collision signup: never discloses which case occurred, and + # a visitor resubmitting the form never sees an error (#334). + return WaitlistSignupResponse(status="ok") diff --git a/apps/backend/app/auth/__init__.py b/apps/backend/app/auth/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/backend/app/auth/oauth_providers.py b/apps/backend/app/auth/oauth_providers.py new file mode 100644 index 00000000..92875d28 --- /dev/null +++ b/apps/backend/app/auth/oauth_providers.py @@ -0,0 +1,270 @@ +"""Google and GitHub OAuth provider clients (#288). + +Credentials-deferred build: no real OAuth application has been registered +for either provider yet (see the comment on issue #288 for what a real +go-live still needs). Everything here is fully implemented and exercised +end-to-end in tests against clearly-fake mocked HTTP responses -- nothing in +this module makes a real network call in tests, and going live only needs a +real client id/secret plus the redirect URIs registered with each +provider's console. + +Google uses Authorization Code + PKCE + OIDC: the token exchange returns a +signed id_token whose signature is verified against Google's published JWKS, +whose issuer/audience are checked, and whose nonce is matched against the +flow that started it -- the identity comes from that verified token, never +from an unauthenticated userinfo call. GitHub OAuth Apps support neither +PKCE nor OIDC: the exchange returns an opaque access token that is then used +to call GitHub's REST API for the account's identity and verified email. + +Both clients share one interface (OAuthProviderClient) so OAuthService never +branches on which provider it's talking to. +""" + +from __future__ import annotations + +import base64 +import hashlib +import secrets +from dataclasses import dataclass +from typing import Protocol +from urllib.parse import urlencode + +import httpx +import jwt +from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey +from jwt import PyJWTError +from jwt.algorithms import RSAAlgorithm + +from app.core.config import Settings + +GOOGLE_AUTHORIZE_URL = "https://accounts.google.com/o/oauth2/v2/auth" +GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token" +GOOGLE_JWKS_URL = "https://www.googleapis.com/oauth2/v3/certs" +GOOGLE_ISSUERS = {"https://accounts.google.com", "accounts.google.com"} + +GITHUB_AUTHORIZE_URL = "https://github.com/login/oauth/authorize" +GITHUB_TOKEN_URL = "https://github.com/login/oauth/access_token" +GITHUB_USER_URL = "https://api.github.com/user" +GITHUB_EMAILS_URL = "https://api.github.com/user/emails" + +_HTTP_TIMEOUT_SECONDS = 10.0 + + +class OAuthProviderError(Exception): + """Any provider-side failure: a bad/expired code, a network error, an + invalid or expired id_token, a provider outage. Callers translate this + into one generic user-facing message; the detail stays server-side in + logs so it can never be used to probe provider internals.""" + + +@dataclass(frozen=True) +class OAuthIdentityInfo: + subject: str + email: str | None + email_verified: bool + display_name: str | None + + +def generate_pkce_pair() -> tuple[str, str]: + """Return (code_verifier, code_challenge) for RFC 7636 S256 PKCE.""" + verifier = secrets.token_urlsafe(64)[:128] + digest = hashlib.sha256(verifier.encode("ascii")).digest() + challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii") + return verifier, challenge + + +def generate_state() -> str: + return secrets.token_urlsafe(32) + + +def generate_nonce() -> str: + return secrets.token_urlsafe(32) + + +class OAuthProviderClient(Protocol): + def is_configured(self) -> bool: ... + + def authorize_url(self, *, redirect_uri: str, state: str, code_challenge: str, nonce: str) -> str: ... + + async def resolve_identity( + self, *, code: str, redirect_uri: str, code_verifier: str | None, nonce: str | None + ) -> OAuthIdentityInfo: ... + + +def _verify_google_id_token(id_token: str, jwks: dict, *, client_id: str) -> dict: + try: + header = jwt.get_unverified_header(id_token) + except PyJWTError as exc: + raise OAuthProviderError("Malformed Google id_token.") from exc + kid = header.get("kid") + matching = next((key for key in jwks.get("keys", []) if key.get("kid") == kid), None) + if matching is None: + raise OAuthProviderError("No matching Google JWKS key for id_token.") + try: + public_key = RSAAlgorithm.from_jwk(matching) + if not isinstance(public_key, RSAPublicKey): + # A JWKS entry is only ever meant to publish a public key; this + # would mean either a malformed response or a `from_jwk` result + # this code doesn't expect, either way not safe to verify with. + raise OAuthProviderError("Google JWKS key is not a usable RSA public key.") + claims = jwt.decode( + id_token, + key=public_key, + algorithms=["RS256"], + audience=client_id, + issuer=list(GOOGLE_ISSUERS), + ) + except PyJWTError as exc: + raise OAuthProviderError("Google id_token failed verification.") from exc + return claims + + +class GoogleOAuthClient: + def __init__(self, settings: Settings, http_client: httpx.AsyncClient | None = None) -> None: + self._settings = settings + # Tests inject a fake client so nothing here ever makes a real + # network call; production leaves this None and a short-lived client + # is opened per call. + self._http = http_client + + def is_configured(self) -> bool: + return bool(self._settings.google_oauth_client_id and self._settings.google_oauth_client_secret) + + def authorize_url(self, *, redirect_uri: str, state: str, code_challenge: str, nonce: str) -> str: + params = { + "client_id": self._settings.google_oauth_client_id, + "redirect_uri": redirect_uri, + "response_type": "code", + "scope": "openid email profile", + "state": state, + "code_challenge": code_challenge, + "code_challenge_method": "S256", + "nonce": nonce, + "access_type": "online", + "prompt": "select_account", + } + return f"{GOOGLE_AUTHORIZE_URL}?{urlencode(params)}" + + async def resolve_identity( + self, *, code: str, redirect_uri: str, code_verifier: str | None, nonce: str | None + ) -> OAuthIdentityInfo: + client = self._http or httpx.AsyncClient(timeout=_HTTP_TIMEOUT_SECONDS, follow_redirects=False) + owns_client = self._http is None + try: + token_response = await client.post( + GOOGLE_TOKEN_URL, + data={ + "client_id": self._settings.google_oauth_client_id, + "client_secret": self._settings.google_oauth_client_secret, + "code": code, + "code_verifier": code_verifier or "", + "redirect_uri": redirect_uri, + "grant_type": "authorization_code", + }, + ) + if token_response.status_code != 200: + raise OAuthProviderError(f"Google token exchange failed: {token_response.status_code}") + token_body = token_response.json() + id_token = token_body.get("id_token") + if not id_token: + raise OAuthProviderError("Google token response missing id_token.") + + jwks_response = await client.get(GOOGLE_JWKS_URL) + if jwks_response.status_code != 200: + raise OAuthProviderError(f"Failed to fetch Google JWKS: {jwks_response.status_code}") + jwks = jwks_response.json() + except httpx.HTTPError as exc: + raise OAuthProviderError("Network error talking to Google.") from exc + finally: + if owns_client: + await client.aclose() + + claims = _verify_google_id_token(id_token, jwks, client_id=self._settings.google_oauth_client_id) + if claims.get("nonce") != nonce: + raise OAuthProviderError("Google id_token nonce mismatch.") + subject = claims.get("sub") + if not subject: + raise OAuthProviderError("Google id_token missing sub claim.") + return OAuthIdentityInfo( + subject=str(subject), + email=claims.get("email"), + email_verified=bool(claims.get("email_verified", False)), + display_name=claims.get("name"), + ) + + +class GitHubOAuthClient: + def __init__(self, settings: Settings, http_client: httpx.AsyncClient | None = None) -> None: + self._settings = settings + self._http = http_client + + def is_configured(self) -> bool: + return bool(self._settings.github_oauth_client_id and self._settings.github_oauth_client_secret) + + def authorize_url(self, *, redirect_uri: str, state: str, code_challenge: str, nonce: str) -> str: + # GitHub OAuth Apps support neither PKCE nor OIDC; code_challenge and + # nonce are accepted (to keep the interface uniform with Google) and + # simply unused. + params = { + "client_id": self._settings.github_oauth_client_id, + "redirect_uri": redirect_uri, + "scope": "read:user user:email", + "state": state, + "allow_signup": "true", + } + return f"{GITHUB_AUTHORIZE_URL}?{urlencode(params)}" + + async def resolve_identity( + self, *, code: str, redirect_uri: str, code_verifier: str | None, nonce: str | None + ) -> OAuthIdentityInfo: + client = self._http or httpx.AsyncClient(timeout=_HTTP_TIMEOUT_SECONDS, follow_redirects=False) + owns_client = self._http is None + try: + token_response = await client.post( + GITHUB_TOKEN_URL, + headers={"Accept": "application/json"}, + data={ + "client_id": self._settings.github_oauth_client_id, + "client_secret": self._settings.github_oauth_client_secret, + "code": code, + "redirect_uri": redirect_uri, + }, + ) + if token_response.status_code != 200: + raise OAuthProviderError(f"GitHub token exchange failed: {token_response.status_code}") + token_body = token_response.json() + access_token = token_body.get("access_token") + if not access_token or token_body.get("error"): + raise OAuthProviderError(f"GitHub token exchange denied: {token_body.get('error', 'unknown')}") + + auth_header = {"Authorization": f"Bearer {access_token}", "Accept": "application/vnd.github+json"} + user_response = await client.get(GITHUB_USER_URL, headers=auth_header) + if user_response.status_code != 200: + raise OAuthProviderError(f"GitHub user lookup failed: {user_response.status_code}") + user_body = user_response.json() + subject = user_body.get("id") + if subject is None: + raise OAuthProviderError("GitHub user response missing id.") + + email = user_body.get("email") + email_verified = bool(email) + if not email: + emails_response = await client.get(GITHUB_EMAILS_URL, headers=auth_header) + if emails_response.status_code == 200: + for entry in emails_response.json(): + if entry.get("primary") and entry.get("verified"): + email = entry.get("email") + email_verified = True + break + except httpx.HTTPError as exc: + raise OAuthProviderError("Network error talking to GitHub.") from exc + finally: + if owns_client: + await client.aclose() + + return OAuthIdentityInfo( + subject=str(subject), + email=email, + email_verified=email_verified, + display_name=user_body.get("name") or user_body.get("login"), + ) diff --git a/apps/backend/app/auth/security.py b/apps/backend/app/auth/security.py new file mode 100644 index 00000000..5296215b --- /dev/null +++ b/apps/backend/app/auth/security.py @@ -0,0 +1,92 @@ +import contextlib +import hashlib +import secrets +from datetime import UTC, datetime, timedelta +from uuid import uuid4 + +import jwt +from argon2 import PasswordHasher +from argon2.exceptions import VerificationError, VerifyMismatchError + +from app.core.config import Settings +from app.core.exceptions import UnauthorizedError + +JWT_ALGORITHM = "HS256" + +_hasher = PasswordHasher() + +# Verified against when login hits an unknown email or a user without a +# credential, so those paths cost the same as a real password check and the +# response cannot be timed to enumerate accounts. +_DUMMY_HASH = _hasher.hash("partha-timing-equalizer") + + +def hash_password(password: str) -> str: + return _hasher.hash(password) + + +def verify_password(password_hash: str, password: str) -> bool: + try: + return _hasher.verify(password_hash, password) + except (VerifyMismatchError, VerificationError): + return False + + +def burn_password_check(password: str) -> None: + """Spend one argon2 verification without authenticating anyone. + + The result is intentionally discarded: the call exists only to make an + unknown-account login path cost the same as a real one (anti-enumeration), + so a mismatch is the expected, ignored outcome. + """ + with contextlib.suppress(VerifyMismatchError, VerificationError): + _hasher.verify(_DUMMY_HASH, password) + + +def create_access_token(user_id: str, settings: Settings, ttl_seconds: int | None = None) -> str: + now = datetime.now(UTC) + ttl = settings.access_token_ttl_seconds if ttl_seconds is None else ttl_seconds + claims = { + "sub": user_id, + "iat": now, + "exp": now + timedelta(seconds=ttl), + "jti": uuid4().hex, + } + return jwt.encode(claims, settings.auth_secret_key, algorithm=JWT_ALGORITHM) + + +def decode_access_token(token: str, settings: Settings) -> str: + """Return the user id from a valid access token, or raise UnauthorizedError. + + Expired, malformed, and wrongly-signed tokens all surface the same generic + message so a caller cannot distinguish why a token was rejected. + """ + try: + claims = jwt.decode(token, settings.auth_secret_key, algorithms=[JWT_ALGORITHM]) + except jwt.PyJWTError as exc: + raise UnauthorizedError("Invalid or expired access token.") from exc + user_id = claims.get("sub") + if not isinstance(user_id, str) or not user_id: + raise UnauthorizedError("Invalid or expired access token.") + return user_id + + +def new_refresh_token() -> str: + return secrets.token_urlsafe(48) + + +def hash_refresh_token(raw: str) -> str: + return hashlib.sha256(raw.encode("utf-8")).hexdigest() + + +def hash_invite_code(raw: str) -> str: + """Same construction as hash_refresh_token, named separately: an invite + code and a refresh token are different secrets with different lifetimes, + and a shared helper would blur that even though the hash itself is + identical (sha256 hex digest -- both are high-entropy random tokens, not + passwords, so argon2 would be the wrong tool here). + + Retained only for historical continuity with the `invite_tokens` table + (#341, retired by #374's admin-managed email allowlist) -- nothing in + the live registration path calls this anymore.""" + return hashlib.sha256(raw.encode("utf-8")).hexdigest() diff --git a/apps/backend/app/auth/service.py b/apps/backend/app/auth/service.py new file mode 100644 index 00000000..8cdf00fb --- /dev/null +++ b/apps/backend/app/auth/service.py @@ -0,0 +1,316 @@ +import logging +from datetime import UTC, datetime, timedelta +from uuid import uuid4 + +from sqlalchemy import func, select, update +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from app.auth.security import ( + burn_password_check, + create_access_token, + hash_password, + hash_refresh_token, + new_refresh_token, + verify_password, +) +from app.core.config import Settings +from app.core.exceptions import ConflictServiceError, UnauthorizedError, ValidationServiceError +from app.models.approved_email import ApprovedEmail +from app.models.refresh_token import RefreshToken +from app.models.user import SEED_USER_ID, User + +logger = logging.getLogger(__name__) + +# Every credential failure returns this exact message so responses cannot be +# used to tell apart unknown email / wrong password / disabled account. +INVALID_CREDENTIALS = "Invalid email or password." +INVALID_REFRESH = "Invalid refresh token." +# #374: registration is gated by an admin-managed allowlist, not a secret -- +# unlike the retired invite-code message, this can say exactly what's wrong, +# the same way the waitlist's own "we'll be in touch" framing does. +EMAIL_NOT_APPROVED = "This email hasn't been approved for access yet. Join the waitlist and we'll be in touch." + + +def _as_utc(value: datetime) -> datetime: + # SQLite returns naive datetimes for DateTime(timezone=True) columns while + # Postgres returns aware ones; normalize before comparing. + return value if value.tzinfo is not None else value.replace(tzinfo=UTC) + + +class AuthService: + def __init__(self, db: Session, settings: Settings) -> None: + self.db = db + self.settings = settings + + def register(self, email: str, password: str) -> tuple[User, str, str]: + normalized = email.strip().lower() + self._ensure_email_available(normalized) + approval = self._require_approval(normalized) + user = User(id=str(uuid4()), email=normalized, password_hash=hash_password(password)) + return self._create_approved_user(user, approval) + + def register_oauth_user(self, email: str) -> tuple[User, str, str]: + """Create a brand-new, password-less account for a verified OAuth + identity (OAuthService, #288/#374). + + Identical approval gate and audit trail as ``register()`` -- the + allowlist is the single source of truth for who may ever get a new + PARTHA account, regardless of which door (password or OAuth) they + come through. The only difference from ``register()`` is that there + is no password to hash. + """ + normalized = email.strip().lower() + self._ensure_email_available(normalized) + approval = self._require_approval(normalized) + user = User(id=str(uuid4()), email=normalized, password_hash=None) + return self._create_approved_user(user, approval) + + def _ensure_email_available(self, normalized_email: str) -> None: + existing = self.db.scalars(select(User).where(User.email == normalized_email)).first() + if existing: + raise ConflictServiceError("An account with this email already exists.") + + def _require_approval(self, normalized_email: str) -> ApprovedEmail: + approval = self.db.scalars(select(ApprovedEmail).where(ApprovedEmail.email == normalized_email)).first() + if approval is not None: + return approval + + # Checked before the #388 bootstrap below, not after: in development + # every registration is already frictionless regardless of ordinal + # position, so the dev-bypass reason is the more specific and + # accurate one to attribute here. Bootstrap is the fallback for + # every environment this local-only rule doesn't cover. + if self.settings.app_env == "development": + # #384: local development must stay exactly as frictionless as it + # was before the allowlist existed -- restoring that means an + # email that was never explicitly approved is auto-approved here + # instead of rejected, so the rest of this method's caller + # (_create_approved_user) still has a real, persisted + # ApprovedEmail row to stamp used_at/used_by_user_id onto. Every + # other behavior (uniqueness, the audit trail, the OAuth path) + # stays identical to the real approved case -- this only ever + # changes what happens when no admin has approved the address + # yet, and only in development. + # + # Deliberately `development` only, not the broader dev/test + # leniency pairing used elsewhere in Settings + # (AUTH_SECRET_KEY/AI_ENCRYPTION_KEY): the backend test suite + # runs under this same default app_env with nothing overriding + # it, and its own allowlist-rejection tests need this bypass to + # NOT apply to them (see tests/conftest.py's `client` fixture, + # which sets APP_ENV=test specifically so this distinction is + # real rather than accidental). `test` is intentionally excluded. + auto_approval = ApprovedEmail( + id=str(uuid4()), + email=normalized_email, + note="Auto-approved: local development (#384). Never happens outside APP_ENV=development.", + added_by="dev-bypass", + ) + self.db.add(auto_approval) + return auto_approval + + bootstrap_approval = self._first_user_bootstrap(normalized_email) + if bootstrap_approval is not None: + return bootstrap_approval + + raise ValidationServiceError(EMAIL_NOT_APPROVED) + + def _first_user_bootstrap(self, normalized_email: str) -> ApprovedEmail | None: + """#388: the first real account ever registered on a fresh instance + becomes its owner. Checked as the fallback for every environment the + #384 dev-only bypass above doesn't already cover unconditionally -- + so in practice this is what makes registration possible at all in + `staging`/`production`/any other real deployment. + + Without this, a genuine self-hoster running their own copy of PARTHA + in production mode has no way to ever register at all: nobody is + pre-approved on a fresh database except the hardcoded product-owner + row seeded by the #374 migration, which is this project's own owner, + not theirs. This is the self-hoster claiming their own instance, the + same bootstrap pattern used by most self-hosted software (the first + person to reach the setup wizard becomes the admin). + + "First" is measured by the `users` table being otherwise empty, + excluding the permanent system placeholder row every database gets + from the 0002 migration (SEED_USER_ID) -- that row is not a real + account and must never itself count as "already have an owner". + + Every registration after the first real one goes through the normal + allowlist exactly as before; this only ever changes what happens + once, the very first time. + + Known, accepted limitation: this check and the eventual `User` + insert are not atomic with each other (the insert happens later, in + _create_approved_user). Two concurrent *first-ever* registrations on + the same fresh database could both observe zero real users and both + be auto-approved as owner. The window only exists for the single + moment between a fresh instance's first boot and its first + successful registration, is closed permanently the instant one + registration commits, and does not reopen or weaken the allowlist + for anyone after that. Closing it completely would need a + dedicated, atomically-claimed mutex (e.g. a single-row table claimed + via a unique-constraint insert, the same pattern _create_approved_user + already uses for email-uniqueness races) -- deliberately not added + here; flagged instead of guessed at, since it's a real design + tradeoff between full correctness and a new migration/table for a + narrow, single-operator bootstrap scenario. + """ + real_user_count = self.db.scalar(select(func.count()).select_from(User).where(User.id != SEED_USER_ID)) + if real_user_count: + return None + + bootstrap_approval = ApprovedEmail( + id=str(uuid4()), + email=normalized_email, + note="Auto-approved: first user on this instance becomes its owner (#388).", + added_by="first-user-bootstrap", + ) + self.db.add(bootstrap_approval) + return bootstrap_approval + + def _create_approved_user(self, user: User, approval: ApprovedEmail) -> tuple[User, str, str]: + self.db.add(user) + try: + # Flushed here, ahead of commit, so a concurrent registration for + # the same email surfaces here as an IntegrityError rather than + # only at commit -- the identical handling is needed at both + # points, since either can be where the race actually lands. + self.db.flush() + except IntegrityError: + self.db.rollback() + if self.db.scalars(select(User).where(User.email == user.email)).first() is not None: + raise ConflictServiceError("An account with this email already exists.") from None + raise + + # Purely informational (see ApprovedEmail's docstring) -- unlike the + # retired invite-code redemption, this never gates anything and so + # needs no atomic conditional UPDATE: the email-uniqueness check + # above is what actually prevents two accounts for one email. + approval.used_at = datetime.now(UTC) + approval.used_by_user_id = user.id + + try: + self.db.commit() + except IntegrityError: + # A second, unrelated integrity failure at commit time (the email + # collision itself is already handled above, at flush) must still + # not surface as a raw 500. + self.db.rollback() + if self.db.scalars(select(User).where(User.email == user.email)).first() is not None: + raise ConflictServiceError("An account with this email already exists.") from None + raise + self.db.refresh(user) + return self.open_session(user) + + def login(self, email: str, password: str) -> tuple[User, str, str]: + normalized = email.strip().lower() + user = self.db.scalars(select(User).where(User.email == normalized)).first() + if user is None or user.password_hash is None: + # Unknown accounts and credential-less accounts (e.g. the seed + # user) cost one hash check, the same as a real login attempt. + burn_password_check(password) + raise UnauthorizedError(INVALID_CREDENTIALS) + if not verify_password(user.password_hash, password): + raise UnauthorizedError(INVALID_CREDENTIALS) + if not user.is_active: + raise UnauthorizedError(INVALID_CREDENTIALS) + return self.open_session(user) + + def refresh(self, raw_token: str) -> tuple[User, str, str]: + now = datetime.now(UTC) + record = self.db.scalars( + select(RefreshToken).where(RefreshToken.token_hash == hash_refresh_token(raw_token)) + ).first() + if record is None or record.revoked_at is not None or _as_utc(record.expires_at) <= now: + raise UnauthorizedError(INVALID_REFRESH) + if record.used_at is not None: + # A rotated token came back: someone is replaying it. Kill the + # whole family so the holder of the successor is logged out too. + self._revoke_family(record.family_id, now) + logger.warning( + "Refresh token reuse detected; family revoked", + extra={"family_id": record.family_id, "user_id": record.user_id}, + ) + raise UnauthorizedError(INVALID_REFRESH) + + user = self.db.get(User, record.user_id) + if user is None or not user.is_active: + self._revoke_family(record.family_id, now) + raise UnauthorizedError(INVALID_REFRESH) + + # Claim the token atomically. Two requests presenting the same token can + # both pass the used_at check above (they read before either writes); + # the UPDATE ... WHERE used_at IS NULL and the row lock it takes let only + # one win. The loser is a concurrent replay, so it is handled like reuse. + if not self._claim_token(record.id, now): + self._revoke_family(record.family_id, now) + logger.warning( + "Concurrent refresh of a single token; family revoked", + extra={"family_id": record.family_id, "user_id": record.user_id}, + ) + raise UnauthorizedError(INVALID_REFRESH) + + raw_successor = self._issue_refresh(user.id, record.family_id) + self.db.commit() + return user, create_access_token(user.id, self.settings), raw_successor + + def _claim_token(self, token_id: str, now: datetime) -> bool: + """Mark a refresh token used, but only if it is not already used. + + Returns True for the single caller whose UPDATE affects the row and + False for any concurrent caller. The atomic ``UPDATE ... WHERE used_at + IS NULL`` is what makes rotation safe under real database concurrency, + rather than the earlier read-then-write which two racers could both pass. + """ + result = self.db.execute( + update(RefreshToken).where(RefreshToken.id == token_id, RefreshToken.used_at.is_(None)).values(used_at=now) + ) + return result.rowcount == 1 + + def logout(self, raw_token: str | None) -> None: + """Revoke the session family for the presented refresh token. + + Idempotent by design: logging out with a missing or unknown token is + not an error, so repeated logouts and cleared cookies stay harmless. + """ + if not raw_token: + return + record = self.db.scalars( + select(RefreshToken).where(RefreshToken.token_hash == hash_refresh_token(raw_token)) + ).first() + if record is None: + return + self._revoke_family(record.family_id, datetime.now(UTC)) + self.db.commit() + + def open_session(self, user: User) -> tuple[User, str, str]: + """Issue a fresh refresh-token family and access token for `user`. + + Public so callers that authenticate a user by some means other than + password login (OAuthService, #288) can reuse the exact same session + primitive as register()/login() rather than duplicating it. + """ + raw_refresh = self._issue_refresh(user.id, family_id=None) + self.db.commit() + return user, create_access_token(user.id, self.settings), raw_refresh + + def _issue_refresh(self, user_id: str, family_id: str | None) -> str: + raw = new_refresh_token() + self.db.add( + RefreshToken( + id=str(uuid4()), + user_id=user_id, + token_hash=hash_refresh_token(raw), + family_id=family_id or str(uuid4()), + expires_at=datetime.now(UTC) + timedelta(seconds=self.settings.refresh_token_ttl_seconds), + ) + ) + return raw + + def _revoke_family(self, family_id: str, now: datetime) -> None: + records = self.db.scalars(select(RefreshToken).where(RefreshToken.family_id == family_id)).all() + for record in records: + if record.revoked_at is None: + record.revoked_at = now + self.db.commit() diff --git a/apps/backend/app/core/ai_egress.py b/apps/backend/app/core/ai_egress.py new file mode 100644 index 00000000..930308bb --- /dev/null +++ b/apps/backend/app/core/ai_egress.py @@ -0,0 +1,415 @@ +"""Central destination policy for outbound AI-provider traffic. + +This module deliberately has no dependency on the HTTP client or persistence +layers. It validates a provider configuration before it is saved and produces +an IP-pinned destination immediately before a request is sent. Keeping that +logic here makes the policy straightforward to unit test with a fake resolver. +""" + +from __future__ import annotations + +from collections.abc import Callable, Sequence +from dataclasses import dataclass +import ipaddress +import re +import socket +from typing import Protocol +from urllib.parse import urlsplit, urlunsplit + + +_ALLOWED_SCHEMES = {"http", "https"} +_DEFAULT_PORTS = {"http": 80, "https": 443} +_HOST_LABEL = re.compile(r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$") +_NUMERIC_DOTTED_HOST = re.compile(r"[0-9.]+$") + + +class DestinationPolicyError(ValueError): + """A deliberately non-specific policy denial safe to return to callers.""" + + def __init__(self) -> None: + super().__init__("AI provider destination is not permitted.") + + +class DestinationPolicyConfigurationError(ValueError): + """Raised for an invalid administrator-owned policy setting.""" + + +class ProviderConfigLike(Protocol): + provider: str + base_url: str | None + + +Resolver = Callable[[str, int], Sequence[str]] + + +@dataclass(frozen=True) +class NormalizedDestination: + """A canonical URL suitable for exact policy comparison. + + ``host`` never includes IPv6 brackets; ``port`` is always explicit in the + data model even when it is omitted from the canonical URL. + """ + + scheme: str + host: str + port: int + path: str + query: str = "" + + @property + def is_ip_literal(self) -> bool: + try: + ipaddress.ip_address(self.host) + except ValueError: + return False + return True + + @property + def authority(self) -> str: + host = f"[{self.host}]" if ":" in self.host else self.host + if self.port != _DEFAULT_PORTS[self.scheme]: + return f"{host}:{self.port}" + return host + + @property + def base_url(self) -> str: + return urlunsplit((self.scheme, self.authority, self.path, "", "")) + + @property + def request_url(self) -> str: + return urlunsplit((self.scheme, self.authority, self.path, self.query, "")) + + @property + def host_header(self) -> str: + return self.authority + + def pinned_request_url(self, address: ipaddress.IPv4Address | ipaddress.IPv6Address) -> str: + host = str(address) + authority = f"[{host}]" if address.version == 6 else host + if self.port != _DEFAULT_PORTS[self.scheme]: + authority = f"{authority}:{self.port}" + return urlunsplit((self.scheme, authority, self.path, self.query, "")) + + +@dataclass(frozen=True) +class PinnedDestination: + """A destination whose connection address was validated immediately before use.""" + + destination: NormalizedDestination + address: ipaddress.IPv4Address | ipaddress.IPv6Address + + @property + def connection_url(self) -> str: + return self.destination.pinned_request_url(self.address) + + +@dataclass(frozen=True) +class _FixedProviderDestination: + scheme: str + host: str + base_path: str + + +# These origins are intentionally code-owned. A tenant can select a provider +# and model, but can never replace a cloud provider's destination. +FIXED_PROVIDER_DESTINATIONS: dict[str, _FixedProviderDestination] = { + "openai": _FixedProviderDestination("https", "api.openai.com", "/v1"), + "anthropic": _FixedProviderDestination("https", "api.anthropic.com", "/v1"), + "gemini": _FixedProviderDestination("https", "generativelanguage.googleapis.com", "/v1beta"), + "openrouter": _FixedProviderDestination("https", "openrouter.ai", "/api/v1"), +} + + +def _deny() -> DestinationPolicyError: + return DestinationPolicyError() + + +def _configuration_error(message: str) -> DestinationPolicyConfigurationError: + return DestinationPolicyConfigurationError(message) + + +def _canonical_host(raw_host: str) -> str: + # A single fully-qualified-domain trailing dot has one unambiguous meaning; + # multiple dots are an unsafe alternate spelling and are rejected. + if raw_host.endswith(".."): + raise _deny() + host = raw_host[:-1] if raw_host.endswith(".") else raw_host + if not host: + raise _deny() + + try: + return str(ipaddress.ip_address(host)) + except ValueError: + pass + + # Decimal, octal, hexadecimal, and shortened IPv4 representations have + # inconsistent parser behaviour across libraries. Treat a numeric-looking + # non-canonical host as ambiguous instead of trying to reinterpret it. + if _NUMERIC_DOTTED_HOST.fullmatch(host) or host.lower().startswith("0x"): + raise _deny() + + try: + host = host.encode("idna").decode("ascii").lower() + except UnicodeError as exc: + raise _deny() from exc + + if len(host) > 253 or not host or host.startswith(".") or host.endswith("."): + raise _deny() + labels = host.split(".") + if any(not _HOST_LABEL.fullmatch(label) for label in labels): + raise _deny() + return host + + +def _normalise_path(path: str) -> str: + if not path: + return "/" + if not path.startswith("/") or "\\" in path or "%" in path: + raise _deny() + segments = path.split("/") + if any(segment in {".", ".."} for segment in segments): + raise _deny() + # Empty segments in the middle are a second spelling for a different or + # implementation-defined route. A trailing slash is harmless and is + # normalized so administrator entries compare consistently. + if any(not segment for segment in segments[1:-1]): + raise _deny() + return path.rstrip("/") or "/" + + +def _normalise_url(value: str | None, *, allow_query: bool) -> NormalizedDestination: + if not isinstance(value, str) or not value or value != value.strip(): + raise _deny() + if any(ord(character) <= 0x20 for character in value) or "\\" in value or "%" in value: + raise _deny() + if "#" in value or (not allow_query and "?" in value): + raise _deny() + + try: + parsed = urlsplit(value, allow_fragments=True) + except ValueError as exc: + raise _deny() from exc + + scheme = parsed.scheme.lower() + if scheme not in _ALLOWED_SCHEMES or not parsed.netloc or parsed.fragment: + raise _deny() + if parsed.username is not None or parsed.password is not None or "@" in parsed.netloc: + raise _deny() + if not allow_query and parsed.query: + raise _deny() + + try: + raw_host = parsed.hostname + port = parsed.port + except ValueError as exc: + raise _deny() from exc + if raw_host is None or parsed.netloc.rsplit("@", maxsplit=1)[-1].endswith(":"): + raise _deny() + + host = _canonical_host(raw_host) + port = port if port is not None else _DEFAULT_PORTS[scheme] + if not 1 <= port <= 65535: + raise _deny() + + return NormalizedDestination( + scheme=scheme, + host=host, + port=port, + path=_normalise_path(parsed.path), + query=parsed.query if allow_query else "", + ) + + +def normalize_base_url(value: str | None) -> NormalizedDestination: + """Normalize an administrator-configured provider base URL. + + Base URLs cannot carry a query or fragment, because those would make exact + destination comparison ambiguous. + """ + + return _normalise_url(value, allow_query=False) + + +def normalize_request_url(value: str | None) -> NormalizedDestination: + """Normalize a provider request URL without exposing the original value.""" + + return _normalise_url(value, allow_query=True) + + +def resolve_hostname(host: str, port: int) -> Sequence[str]: + """Return DNS answers without choosing a connection address yet.""" + + records = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM) + return tuple(record[4][0] for record in records) + + +def _is_permitted_address_class( + address: ipaddress.IPv4Address | ipaddress.IPv6Address, + *, + allow_internal: bool, +) -> bool: + """Return whether an address class is safe for a provider connection. + + ``is_global`` alone is not a public-unicast test: Python correctly reports + globally scoped multicast as global. Unspecified, multicast, link-local, + reserved, scoped IPv6, and non-global shared address space are never valid + provider connection targets. Explicit self-hosted policy may additionally + admit private or loopback unicast addresses, but only after CIDR matching. + + PARTHA supports Python 3.12 and 3.13. The explicit address-class regression + cases pin the intended policy across those stdlib ``ipaddress`` versions; + review them whenever the supported Python range changes. + """ + + if isinstance(address, ipaddress.IPv6Address): + if address.scope_id is not None: + return False + if address.ipv4_mapped is not None: + return _is_permitted_address_class(address.ipv4_mapped, allow_internal=allow_internal) + + if address.is_unspecified or address.is_multicast or address.is_link_local: + return False + if allow_internal and address.is_loopback: + return True + if address.is_reserved: + return False + if allow_internal: + return address.is_global or address.is_private + return address.is_global + + +class ProviderEgressPolicy: + """Validate AI-provider destinations at save time and request time.""" + + def __init__( + self, + *, + mode: str, + allowed_base_urls: Sequence[str] = (), + allowed_cidrs: Sequence[str] = (), + resolver: Resolver = resolve_hostname, + ) -> None: + if not isinstance(mode, str): + raise _configuration_error("AI_EGRESS_MODE must be either 'hosted' or 'self_hosted'.") + normalized_mode = mode.lower() + if normalized_mode not in {"hosted", "self_hosted"}: + raise _configuration_error("AI_EGRESS_MODE must be either 'hosted' or 'self_hosted'.") + self.mode = normalized_mode + self.resolver = resolver + # Settings normalizes deployment values at startup; repeat the work + # here so direct construction in tests and other callers is equally + # strict and cannot bypass canonical comparison. + try: + self.allowed_base_urls = frozenset(normalize_base_url(value).base_url for value in allowed_base_urls) + except (DestinationPolicyError, TypeError) as exc: + raise _configuration_error("AI_EGRESS_ALLOWED_BASE_URLS contains an invalid URL.") from exc + try: + self.allowed_cidrs = tuple(ipaddress.ip_network(value, strict=True) for value in allowed_cidrs) + except (TypeError, ValueError) as exc: + raise _configuration_error("AI_EGRESS_ALLOWED_CIDRS contains an invalid CIDR.") from exc + + @classmethod + def from_settings(cls, settings: object, *, resolver: Resolver = resolve_hostname) -> "ProviderEgressPolicy": + return cls( + mode=getattr(settings, "ai_egress_mode"), + allowed_base_urls=getattr(settings, "ai_egress_allowed_base_urls"), + allowed_cidrs=getattr(settings, "ai_egress_allowed_cidrs"), + resolver=resolver, + ) + + def validate_config(self, config: ProviderConfigLike) -> None: + """Validate a user-supplied configuration before persistence. + + Fixed cloud providers have no configurable endpoint. For Ollama, both + the exact normalized base URL and its current DNS result must satisfy + the deployment policy before a record is created or changed. + """ + + if config.provider in FIXED_PROVIDER_DESTINATIONS: + if config.base_url is not None: + raise _deny() + return + base = self._configurable_base(config) + self._validated_addresses(base, configurable=True) + + def prepare_request(self, config: ProviderConfigLike, request_url: str) -> PinnedDestination: + """Re-check policy and return a DNS-pinned connection destination.""" + + destination = normalize_request_url(request_url) + if config.provider in FIXED_PROVIDER_DESTINATIONS: + self._validate_fixed_request(config, destination) + addresses = self._validated_addresses(destination, configurable=False) + else: + base = self._configurable_base(config) + self._require_under_base(destination, base) + addresses = self._validated_addresses(destination, configurable=True) + return PinnedDestination(destination=destination, address=addresses[0]) + + def _configurable_base(self, config: ProviderConfigLike) -> NormalizedDestination: + if config.provider != "ollama": + raise _deny() + base = normalize_base_url(config.base_url) + if base.base_url not in self.allowed_base_urls: + raise _deny() + return base + + def _validate_fixed_request(self, config: ProviderConfigLike, destination: NormalizedDestination) -> None: + if config.base_url is not None: + raise _deny() + expected = FIXED_PROVIDER_DESTINATIONS[config.provider] + if destination.scheme != expected.scheme or destination.host != expected.host: + raise _deny() + if destination.port != _DEFAULT_PORTS[expected.scheme]: + raise _deny() + if destination.path != expected.base_path and not destination.path.startswith(f"{expected.base_path}/"): + raise _deny() + + def _require_under_base(self, destination: NormalizedDestination, base: NormalizedDestination) -> None: + if ( + destination.scheme != base.scheme + or destination.host != base.host + or destination.port != base.port + or (base.path != "/" and destination.path != base.path and not destination.path.startswith(f"{base.path}/")) + ): + raise _deny() + + def _validated_addresses( + self, + destination: NormalizedDestination, + *, + configurable: bool, + ) -> tuple[ipaddress.IPv4Address | ipaddress.IPv6Address, ...]: + if destination.is_ip_literal: + answers: Sequence[str] = (destination.host,) + else: + try: + answers = self.resolver(destination.host, destination.port) + except Exception as exc: + raise _deny() from exc + + if isinstance(answers, (str, bytes)) or not answers: + raise _deny() + + resolved: list[ipaddress.IPv4Address | ipaddress.IPv6Address] = [] + seen: set[ipaddress.IPv4Address | ipaddress.IPv6Address] = set() + for answer in answers: + try: + address = ipaddress.ip_address(answer) + except (TypeError, ValueError) as exc: + raise _deny() from exc + if address not in seen: + seen.add(address) + resolved.append(address) + if not resolved: + raise _deny() + + allow_internal = configurable and self.mode == "self_hosted" + if any(not _is_permitted_address_class(address, allow_internal=allow_internal) for address in resolved): + raise _deny() + + if allow_internal: + if not self.allowed_cidrs or any( + not any(address in network for network in self.allowed_cidrs) for address in resolved + ): + raise _deny() + return tuple(resolved) diff --git a/apps/backend/app/core/config.py b/apps/backend/app/core/config.py index 360d9e85..6e4e6dab 100644 --- a/apps/backend/app/core/config.py +++ b/apps/backend/app/core/config.py @@ -1,14 +1,34 @@ +import base64 +import binascii +import hashlib +import ipaddress import logging from functools import lru_cache from pathlib import Path from typing import Annotated from urllib.parse import urlparse -from pydantic import Field, field_validator +from pydantic import Field, field_validator, model_validator from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict +from app.core.ai_egress import DestinationPolicyError, normalize_base_url + SUPPORTED_LOG_FORMATS = {"text", "json"} +# 32 chars ~= 256 bits from a token_urlsafe/hex secret, the floor for an HS256 +# signing key. Enforced outside development/test only, so local work is not +# blocked by a short throwaway value. +AUTH_SECRET_MIN_LENGTH = 32 + +# A Fernet key is the URL-safe base64 encoding of exactly 32 random bytes. The +# development/test default is derived deterministically so local work needs no +# configuration, but it is never used outside those environments — staging and +# production must supply their own key or refuse to start. +FERNET_KEY_BYTES = 32 +_DEV_AI_ENCRYPTION_KEY = base64.urlsafe_b64encode( + hashlib.sha256(b"partha-dev-provider-encryption-key").digest() +).decode() + class Settings(BaseSettings): app_name: str = "PARTHA Backend" @@ -18,12 +38,85 @@ class Settings(BaseSettings): database_url: str = "sqlite:///./.local/partha.db" redis_url: str = "redis://localhost:6379/0" storage_path: Path = Path("./.local/storage") + # Single-service hosting (#339): when a built frontend exists at this path, + # app.main mounts it and serves the SPA for any route the API router + # doesn't own. Absent in local dev (frontend runs on its own Vite server + # instead), so main.py treats a missing directory as "nothing to mount" + # rather than an error. + frontend_dist_path: Path = Path("../frontend/dist") cors_origins: Annotated[list[str], NoDecode] = Field( default_factory=lambda: ["http://localhost:5173", "http://127.0.0.1:5173"] ) - auto_create_tables: bool = True + # Tri-state: when AUTO_CREATE_TABLES is not set explicitly, it resolves to + # True only for development/test and False otherwise (production/staging), + # so non-dev deployments rely on Alembic migrations rather than create_all. + # An explicit env value always wins. + auto_create_tables: bool | None = None + # Signs access tokens (HS256). Empty is tolerated only in development/test, + # where a fixed insecure value is substituted; staging/production refuse to + # start without an explicit secret. + auth_secret_key: str = "" + access_token_ttl_seconds: int = 900 + refresh_token_ttl_seconds: int = 14 * 24 * 60 * 60 + # Fernet key (URL-safe base64 of 32 bytes) that encrypts each user's AI + # provider API keys at rest. Empty is tolerated only in development/test, + # where a fixed derived key is substituted; staging/production refuse to + # start without an explicit key so secrets are never stored under a shared + # well-known key. + ai_encryption_key: str = "" + # Fixed-window request budgets per identity (client IP until per-user + # keying lands with auth enforcement). "memory" is per-process and + # deterministic — right for tests/dev; Compose overrides to "redis" so + # budgets are shared across workers. + rate_limit_enabled: bool = True + rate_limit_backend: str = "memory" + rate_limit_default_per_minute: int = 120 + rate_limit_auth_per_minute: int = 10 + rate_limit_ai_per_minute: int = 20 + rate_limit_heavy_per_minute: int = 30 + # Durable analysis-job worker (#93, #324). ``analysis_worker_autostart`` + # gates the in-process ``AnalysisWorkerRunner`` started from ``app.main``'s + # lifespan; tests set it False so they drive ``AnalysisWorker.run_once()`` + # deterministically instead of racing a real thread. The poll interval bounds + # how long the runner loop sleeps between empty polls; the lease bounds how + # long a claimed job is owned before the control plane lets a stale-job sweep + # reclaim it. + analysis_worker_autostart: bool = True + analysis_job_poll_interval_seconds: int = 5 + analysis_job_lease_seconds: int = 300 + analysis_max_repository_source_bytes: int = 1024 * 1024 * 1024 + analysis_max_process_rss_bytes: int = 2 * 1024 * 1024 * 1024 + analysis_max_duration_seconds: int = 30 * 60 clone_timeout_seconds: int = 120 max_upload_size_bytes: int = 100 * 1024 * 1024 + max_clone_size_bytes: int = 500 * 1024 * 1024 + # Bound decompressed archive size / member count during extraction + # (app/storage/local.py) and total repository-wide file count after + # parsing (app/services/repository_service.py) — the compressed upload + # and post-hoc clone size are already capped above, but nothing bounded + # the decompressed/extracted side, a zip/tar-bomb and hostile-input gap. + max_extracted_size_bytes: int = 1024 * 1024 * 1024 # 1 GiB decompressed cap + max_extracted_entries: int = 50_000 # archive member count cap + max_file_count: int = 50_000 # repository-wide file count cap + # AI-provider egress is fail-safe by default. Local/internal endpoints are + # never enabled implicitly; deployments that need one must opt in with an + # exact administrator-owned URL and CIDR in self_hosted mode. + ai_egress_mode: str = "hosted" + ai_egress_allowed_base_urls: Annotated[list[str], NoDecode] = Field(default_factory=list) + ai_egress_allowed_cidrs: Annotated[list[str], NoDecode] = Field(default_factory=list) + # Google/GitHub sign-in (#288), credentials deferred: empty client + # id/secret is the normal, supported state -- OAuthService.configured_providers() + # and each provider client's is_configured() gate every live OAuth code + # path on both being set, so leaving these blank simply means neither + # provider's routes do anything but report themselves unavailable. + google_oauth_client_id: str = "" + google_oauth_client_secret: str = "" + github_oauth_client_id: str = "" + github_oauth_client_secret: str = "" + # The public origin this backend is reachable at, used to build the fixed + # OAuth redirect URIs registered with each provider's console (must match + # exactly there). Irrelevant until a provider is actually configured. + oauth_public_base_url: str = "http://localhost:8000" model_config = SettingsConfigDict( env_file=".env", @@ -38,6 +131,15 @@ def parse_cors_origins(cls, value: str | list[str]) -> list[str]: return [origin.strip() for origin in value.split(",") if origin.strip()] return value + @field_validator("ai_egress_allowed_base_urls", "ai_egress_allowed_cidrs", mode="before") + @classmethod + def parse_egress_lists(cls, value: str | list[str] | None) -> list[str]: + if value is None: + return [] + if isinstance(value, str): + return [entry.strip() for entry in value.split(",") if entry.strip()] + return value + @field_validator("app_env") @classmethod def validate_app_env(cls, value: str) -> str: @@ -68,7 +170,17 @@ def validate_database_url(cls, value: str) -> str: parsed = urlparse(value) if not parsed.scheme: raise ValueError("DATABASE_URL must include a scheme.") - if parsed.scheme not in {"sqlite", "postgresql+psycopg", "postgresql"}: + if parsed.scheme in {"postgres", "postgresql"}: + # Managed Postgres providers (Render among them) hand back a bare + # postgres(ql):// connection string. The only driver this project + # installs is psycopg 3 (requirements.txt: psycopg/psycopg-binary, + # never psycopg2) -- confirmed empirically that create_engine on a + # bare postgresql:// URL raises ModuleNotFoundError for psycopg2, + # which is not installed and never will be. Normalize to the + # explicit +psycopg driver rather than requiring every deployment + # to hand-edit its provisioned connection string. + return "postgresql+psycopg://" + value.split("://", 1)[1] + if parsed.scheme not in {"sqlite", "postgresql+psycopg"}: raise ValueError(f"Unsupported database URL scheme: {parsed.scheme}") return value @@ -91,13 +203,105 @@ def validate_cors_origins(cls, value: list[str]) -> list[str]: raise ValueError(f"Invalid CORS origin: {origin}") return value - @field_validator("clone_timeout_seconds", "max_upload_size_bytes") + @field_validator("rate_limit_backend") + @classmethod + def validate_rate_limit_backend(cls, value: str) -> str: + normalized = value.lower() + if normalized not in {"memory", "redis"}: + raise ValueError(f"Unsupported rate-limit backend: {value}") + return normalized + + @field_validator("ai_egress_mode") + @classmethod + def validate_ai_egress_mode(cls, value: str) -> str: + normalized = value.lower() + if normalized not in {"hosted", "self_hosted"}: + raise ValueError("AI_EGRESS_MODE must be either 'hosted' or 'self_hosted'.") + return normalized + + @field_validator("ai_egress_allowed_base_urls") + @classmethod + def validate_ai_egress_allowed_base_urls(cls, value: list[str]) -> list[str]: + normalized: list[str] = [] + for entry in value: + try: + normalized.append(normalize_base_url(entry).base_url) + except DestinationPolicyError as exc: + raise ValueError("AI_EGRESS_ALLOWED_BASE_URLS contains an invalid URL.") from exc + return normalized + + @field_validator("ai_egress_allowed_cidrs") + @classmethod + def validate_ai_egress_allowed_cidrs(cls, value: list[str]) -> list[str]: + normalized: list[str] = [] + for entry in value: + try: + normalized.append(str(ipaddress.ip_network(entry, strict=True))) + except (TypeError, ValueError) as exc: + raise ValueError("AI_EGRESS_ALLOWED_CIDRS contains an invalid CIDR.") from exc + return normalized + + @field_validator( + "analysis_job_poll_interval_seconds", + "analysis_job_lease_seconds", + "analysis_max_repository_source_bytes", + "analysis_max_process_rss_bytes", + "analysis_max_duration_seconds", + "clone_timeout_seconds", + "max_upload_size_bytes", + "max_clone_size_bytes", + "max_extracted_size_bytes", + "max_extracted_entries", + "max_file_count", + "access_token_ttl_seconds", + "refresh_token_ttl_seconds", + "rate_limit_default_per_minute", + "rate_limit_auth_per_minute", + "rate_limit_ai_per_minute", + "rate_limit_heavy_per_minute", + ) @classmethod def validate_positive_int(cls, value: int) -> int: if value <= 0: raise ValueError("Value must be greater than zero.") return value + @model_validator(mode="after") + def resolve_auto_create_tables(self) -> "Settings": + if self.auto_create_tables is None: + self.auto_create_tables = self.app_env in {"development", "test"} + return self + + @model_validator(mode="after") + def resolve_ai_encryption_key(self) -> "Settings": + lenient = self.app_env in {"development", "test"} + if not self.ai_encryption_key: + if not lenient: + raise ValueError("AI_ENCRYPTION_KEY must be set outside development/test environments.") + self.ai_encryption_key = _DEV_AI_ENCRYPTION_KEY + return self + try: + decoded = base64.urlsafe_b64decode(self.ai_encryption_key.encode("utf-8")) + except (binascii.Error, ValueError) as exc: + raise ValueError("AI_ENCRYPTION_KEY must be a URL-safe base64-encoded 32-byte Fernet key.") from exc + if len(decoded) != FERNET_KEY_BYTES: + raise ValueError(f"AI_ENCRYPTION_KEY must decode to exactly {FERNET_KEY_BYTES} bytes (a Fernet key).") + return self + + @model_validator(mode="after") + def resolve_auth_secret_key(self) -> "Settings": + lenient = self.app_env in {"development", "test"} + if not self.auth_secret_key: + if not lenient: + raise ValueError("AUTH_SECRET_KEY must be set outside development/test environments.") + self.auth_secret_key = "insecure-dev-secret-do-not-use-in-production" + elif not lenient and len(self.auth_secret_key) < AUTH_SECRET_MIN_LENGTH: + raise ValueError( + f"AUTH_SECRET_KEY must be at least {AUTH_SECRET_MIN_LENGTH} characters " + "outside development/test environments." + ) + return self + @lru_cache def get_settings() -> Settings: diff --git a/apps/backend/app/core/crypto.py b/apps/backend/app/core/crypto.py new file mode 100644 index 00000000..a874e879 --- /dev/null +++ b/apps/backend/app/core/crypto.py @@ -0,0 +1,40 @@ +"""Symmetric encryption for provider secrets stored at rest. + +Provider API keys are the only secrets PARTHA persists on behalf of a user, and +they must never sit in the database in plaintext (E1.5 / #65). Fernet gives +authenticated symmetric encryption (AES-128-CBC + HMAC) with a single key read +from configuration — a KMS-managed key can be dropped in later behind the same +interface without touching call sites. +""" + +from cryptography.fernet import Fernet, InvalidToken + +from app.core.config import Settings + +__all__ = ["ProviderKeyCipher", "InvalidToken", "build_provider_cipher"] + + +class ProviderKeyCipher: + """Encrypts and decrypts provider API keys with a configured Fernet key. + + The key comes from ``Settings.ai_encryption_key`` (env or KMS-injected). The + ciphertext is a URL-safe token that carries its own IV and auth tag, so it is + safe to store as an opaque string column. + """ + + def __init__(self, key: str) -> None: + # A malformed key is a configuration error and must fail loudly at + # startup rather than the first time a user saves a key. + self._fernet = Fernet(key.encode("utf-8")) + + def encrypt(self, plaintext: str) -> str: + return self._fernet.encrypt(plaintext.encode("utf-8")).decode("utf-8") + + def decrypt(self, token: str) -> str: + """Return the plaintext, or raise ``InvalidToken`` if the ciphertext was + produced with a different key or tampered with.""" + return self._fernet.decrypt(token.encode("utf-8")).decode("utf-8") + + +def build_provider_cipher(settings: Settings) -> ProviderKeyCipher: + return ProviderKeyCipher(settings.ai_encryption_key) diff --git a/apps/backend/app/core/database.py b/apps/backend/app/core/database.py index af00f372..80d4cee7 100644 --- a/apps/backend/app/core/database.py +++ b/apps/backend/app/core/database.py @@ -1,13 +1,96 @@ +import sqlite3 from collections.abc import Generator from pathlib import Path -from sqlalchemy import create_engine +from sqlalchemy import create_engine, event +from sqlalchemy.engine import Engine from sqlalchemy.orm import Session, sessionmaker from app.core.config import get_settings settings = get_settings() + +def _enable_sqlite_foreign_keys(dbapi_connection, _connection_record) -> None: + """Enforce foreign keys on SQLite (off by default in the driver). + + The Repository Intelligence snapshot tables (#88) depend on foreign-key and + composite-key enforcement for same-snapshot integrity; SQLite ignores + foreign keys unless ``PRAGMA foreign_keys=ON`` is set per connection. This + is a no-op on PostgreSQL, which always enforces them. + """ + + if isinstance(dbapi_connection, sqlite3.Connection): + cursor = dbapi_connection.cursor() + cursor.execute("PRAGMA foreign_keys=ON") + cursor.close() + + +def register_sqlite_foreign_key_enforcement() -> None: + """Register SQLite foreign-key enforcement on every ``Engine`` connection. + + Registered on the ``Engine`` class so it also applies to engines the test + fixtures build themselves. Idempotent, so tests can call it explicitly + instead of importing this module for its side effect; production keeps the + import-time registration below. + """ + + if not event.contains(Engine, "connect", _enable_sqlite_foreign_keys): + event.listen(Engine, "connect", _enable_sqlite_foreign_keys) + + +register_sqlite_foreign_key_enforcement() + +# A busy connection waits this long for a lock before raising "database is +# locked" (#162). 5s comfortably covers the worker's per-stage extraction +# work for an ordinary repository without masking a genuinely stuck writer +# behind a long, silent hang. +SQLITE_BUSY_TIMEOUT_MS = 5000 + + +def _configure_sqlite_concurrency(dbapi_connection, _connection_record) -> None: + """Enable WAL journaling and a busy-wait timeout on SQLite (#162). + + Development runs a background analysis-worker thread and the API's own + request handlers against the same SQLite file. SQLite's default + rollback-journal mode does not lock readers out for a transaction's + whole open-but-uncommitted duration -- it briefly locks them out + *around each individual commit*. The analysis worker persists many + facts (nodes, observations, diagnostics) via frequent small commits + during extraction, so with request handlers polling concurrently, those + brief windows collide often enough to surface as real "database is + locked" errors (confirmed empirically: reproducible in the thousands + under sustained concurrent commit/read pressure). WAL mode removes this + because a reader always sees the last committed snapshot independent of + what the writer is currently doing, so it never has to wait on a commit + at all. The busy timeout bounds the writer-vs-writer wait WAL still + serializes (the worker's own heartbeat, a future second worker) instead + of failing immediately. A no-op on PostgreSQL, which uses MVCC and never + takes this kind of lock for an ordinary transaction. + """ + + if isinstance(dbapi_connection, sqlite3.Connection): + cursor = dbapi_connection.cursor() + cursor.execute("PRAGMA journal_mode=WAL") + cursor.fetchone() + cursor.execute(f"PRAGMA busy_timeout={SQLITE_BUSY_TIMEOUT_MS}") + cursor.close() + + +def register_sqlite_concurrency_settings() -> None: + """Register WAL + busy-timeout on every ``Engine`` connection. + + Same registration pattern as ``register_sqlite_foreign_key_enforcement``: + registered on the ``Engine`` class so it also applies to engines the test + fixtures build themselves, and is idempotent. + """ + + if not event.contains(Engine, "connect", _configure_sqlite_concurrency): + event.listen(Engine, "connect", _configure_sqlite_concurrency) + + +register_sqlite_concurrency_settings() + connect_args = {} if settings.database_url.startswith("sqlite"): connect_args["check_same_thread"] = False diff --git a/apps/backend/app/core/exceptions.py b/apps/backend/app/core/exceptions.py index 5c3bbab1..e6828449 100644 --- a/apps/backend/app/core/exceptions.py +++ b/apps/backend/app/core/exceptions.py @@ -38,11 +38,20 @@ class NotFoundError(ServiceError): code = "not_found" +class UnauthorizedError(ServiceError): + status_code = status.HTTP_401_UNAUTHORIZED + code = "unauthorized" + + class ValidationServiceError(ServiceError): status_code = status.HTTP_422_UNPROCESSABLE_CONTENT code = "validation_error" +class UnsupportedSchemaVersionError(ValidationServiceError): + code = "unsupported_schema_version" + + class ConflictServiceError(ServiceError): status_code = status.HTTP_409_CONFLICT code = "conflict_error" diff --git a/apps/backend/app/core/logging.py b/apps/backend/app/core/logging.py index 8b13ad03..c460709c 100644 --- a/apps/backend/app/core/logging.py +++ b/apps/backend/app/core/logging.py @@ -7,6 +7,7 @@ from app.core.observability import get_request_id, redact_mapping LOG_RECORD_ATTRIBUTES = set(logging.makeLogRecord({}).__dict__) +SENSITIVE_HTTP_LOGGERS = ("httpx", "httpcore") class JsonFormatter(logging.Formatter): @@ -44,3 +45,9 @@ def configure_logging(level: str, log_format: str = "text") -> None: handlers=[handler], force=True, ) + # HTTPX logs full outbound URLs at INFO and httpcore includes connection + # targets in DEBUG traces. Provider URLs may contain sensitive paths and + # pinned internal addresses, so application debug logging must not make + # those third-party request details visible. + for logger_name in SENSITIVE_HTTP_LOGGERS: + logging.getLogger(logger_name).setLevel(logging.WARNING) diff --git a/apps/backend/app/core/observability.py b/apps/backend/app/core/observability.py index 75157fc5..e4601850 100644 --- a/apps/backend/app/core/observability.py +++ b/apps/backend/app/core/observability.py @@ -46,6 +46,8 @@ def __init__(self) -> None: self._request_duration_seconds = 0.0 self._status_counts: Counter[str] = Counter() self._route_counts: Counter[tuple[str, str, str]] = Counter() + self._rate_limited_total = 0 + self._rate_limit_degraded_total = 0 def record_request(self, method: str, route: str, status_code: int, duration_seconds: float) -> None: status_family = f"{status_code // 100}xx" @@ -55,6 +57,20 @@ def record_request(self, method: str, route: str, status_code: int, duration_sec self._status_counts[status_family] += 1 self._route_counts[(method, route, str(status_code))] += 1 + def record_rate_limited(self) -> None: + with self._lock: + self._rate_limited_total += 1 + + def record_rate_limit_degraded(self) -> None: + """Count requests allowed through because the rate-limit store failed.""" + with self._lock: + self._rate_limit_degraded_total += 1 + + @property + def rate_limit_degraded_total(self) -> int: + with self._lock: + return self._rate_limit_degraded_total + def render_prometheus(self) -> str: with self._lock: lines = [ @@ -81,6 +97,17 @@ def render_prometheus(self) -> str: "partha_http_requests_by_route_total" f'{{method="{method}",route="{route}",status_code="{status_code}"}} {count}' ) + + lines.extend( + [ + "# HELP partha_rate_limited_requests_total Requests rejected with 429 by the rate limiter.", + "# TYPE partha_rate_limited_requests_total counter", + f"partha_rate_limited_requests_total {self._rate_limited_total}", + "# HELP partha_rate_limit_degraded_total Requests allowed through because the rate-limit store was unavailable.", + "# TYPE partha_rate_limit_degraded_total counter", + f"partha_rate_limit_degraded_total {self._rate_limit_degraded_total}", + ] + ) return "\n".join(lines) + "\n" diff --git a/apps/backend/app/core/rate_limit.py b/apps/backend/app/core/rate_limit.py new file mode 100644 index 00000000..4281687f --- /dev/null +++ b/apps/backend/app/core/rate_limit.py @@ -0,0 +1,234 @@ +import logging +import math +import time +from collections.abc import Awaitable, Callable +from threading import Lock +from typing import Protocol + +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import JSONResponse, Response + +from app.auth.security import decode_access_token +from app.core.config import Settings +from app.core.exceptions import ErrorResponse, UnauthorizedError +from app.core.observability import get_request_id, runtime_metrics + +logger = logging.getLogger(__name__) + +WINDOW_SECONDS = 60 + +# Never rate-limited: operational probes must stay available under abuse, the +# docs are static, and OPTIONS is CORS preflight — a 429 there would surface as +# an opaque CORS failure in the browser instead of a readable error. +EXEMPT_PATHS = {"/health", "/ready", "/metrics"} +EXEMPT_PREFIXES = ("/docs", "/redoc", "/openapi.json") + + +def classify(method: str, path: str) -> str | None: + """Map a request to a budget class, or None when exempt. + + The auth entries are inert until the /auth router merges; they are listed + now so the brute-force budget applies the moment it does. + """ + if method == "OPTIONS" or path in EXEMPT_PATHS or path.startswith(EXEMPT_PREFIXES): + return None + if method == "POST" and path in {"/auth/login", "/auth/register", "/waitlist"}: + # Waitlist shares the auth budget: it is the other public, + # unauthenticated write route, and needs the same tight abuse guard. + return "auth" + if path == "/ai" or path.startswith("/ai/"): + return "ai" + if method == "POST" and ( + path in {"/repositories/upload", "/repositories/github", "/documentation/generate", "/export"} + or (path.startswith("/analysis/") and path.endswith("/start")) + ): + return "heavy" + return "default" + + +def _authenticated_user_id(request: Request, settings: Settings) -> str | None: + """The user id from a valid Bearer access token, or None. + + The token's signature is verified with the server's own signing key + (``decode_access_token``), so identity is never taken from an unverified + JWT: a forged, expired, or malformed token — or one signed by anyone but + us — yields None and falls back to the client IP. Only the ``Authorization`` + header is consulted; a raw bearer string, email, or forwarded header can + never select a user-specific budget. + """ + header = request.headers.get("authorization") + if not header: + return None + scheme, _, token = header.partition(" ") + if scheme.lower() != "bearer" or not token.strip(): + return None + try: + return decode_access_token(token.strip(), settings) + except UnauthorizedError: + return None + + +def resolve_rate_key(request: Request, settings: Settings) -> str: + """Identity a budget is charged against. + + Authenticated requests are keyed on the validated user id, so two signed-in + users behind one NAT'd address get independent budgets and one user cannot + exhaust another's by sharing an IP. Unauthenticated (and forged-token) + requests fall back to the direct socket peer address. + + Trusted-proxy caveat: the IP fallback reads ``request.client.host``, the + direct TCP peer. Behind a reverse proxy that is the proxy's address, so + every unauthenticated client would share one budget. Honouring + ``X-Forwarded-For`` safely needs a trusted-proxy allowlist (which hop to + believe) — deliberately NOT implemented here, because trusting a + client-settable header without one turns the limiter into a trivially + spoofable no-op. Enable forwarded-header parsing only once the deployment's + proxy topology is known. + """ + user_id = _authenticated_user_id(request, settings) + if user_id is not None: + return f"user:{user_id}" + client = request.client + return f"ip:{client.host if client else 'unknown'}" + + +class StoreUnavailableError(Exception): + """The backing store cannot be reached; the middleware fails open.""" + + +class RateLimitStore(Protocol): + """A fixed-window counter keyed by an arbitrary string.""" + + async def hit(self, key: str, window_seconds: int) -> tuple[int, int]: + """Record one hit; return (count in current window, seconds to reset).""" + + +class MemoryRateLimitStore: + """Fixed-window counters in process memory. + + Deterministic (the clock is injectable) and per-process — the right + behaviour for tests and single-instance development, and the fallback when + Redis is not configured. ``hit`` is async only to satisfy the store + interface; it does no I/O and never blocks the event loop. + """ + + def __init__(self, clock: Callable[[], float] = time.monotonic) -> None: + self._clock = clock + self._lock = Lock() + self._windows: dict[str, tuple[float, int]] = {} + + async def hit(self, key: str, window_seconds: int) -> tuple[int, int]: + now = self._clock() + with self._lock: + window_start, count = self._windows.get(key, (now, 0)) + if now - window_start >= window_seconds: + window_start, count = now, 0 + count += 1 + self._windows[key] = (window_start, count) + retry_after = max(1, math.ceil(window_seconds - (now - window_start))) + return count, retry_after + + +# Runs atomically on the server in a single round trip: increment the counter, +# arm the TTL on the first hit of a window (and re-arm if a key somehow lost its +# expiry), and return count + remaining TTL together. This closes the race the +# separate INCR/EXPIRE/TTL calls had, and keeps the hot path to one network hop. +_WINDOW_SCRIPT = """ +local count = redis.call('INCR', KEYS[1]) +if count == 1 then + redis.call('EXPIRE', KEYS[1], ARGV[1]) +else + local ttl = redis.call('TTL', KEYS[1]) + if ttl < 0 then + redis.call('EXPIRE', KEYS[1], ARGV[1]) + end +end +return {count, redis.call('TTL', KEYS[1])} +""" + + +class RedisRateLimitStore: + """Fixed-window counters shared across processes via an atomic Redis script. + + Uses ``redis.asyncio`` so the network round trip is awaited rather than + blocking the event loop. + """ + + def __init__(self, client) -> None: + self._client = client + self._script = client.register_script(_WINDOW_SCRIPT) + + async def hit(self, key: str, window_seconds: int) -> tuple[int, int]: + redis_key = f"partha:ratelimit:{key}" + try: + count, ttl = await self._script(keys=[redis_key], args=[window_seconds]) + except Exception as exc: + raise StoreUnavailableError(str(exc)) from exc + return int(count), max(1, int(ttl)) + + async def aclose(self) -> None: + await self._client.aclose() + + +def build_rate_limit_store(settings: Settings) -> RateLimitStore: + if settings.rate_limit_backend == "redis": + import redis.asyncio as redis_asyncio + + client = redis_asyncio.from_url(settings.redis_url, decode_responses=False) + return RedisRateLimitStore(client) + return MemoryRateLimitStore() + + +class RateLimitMiddleware(BaseHTTPMiddleware): + """Per-identity fixed-window budgets, stricter on expensive routes. + + The store is read from ``app.state.rate_limit_store`` on every request so + tests (and future operational tooling) can swap it without rebuilding the + app. Store failures fail OPEN: availability wins over strictness for a + self-hosted tool, but every degraded request is logged and counted so the + condition is impossible to miss. + """ + + async def dispatch(self, request: Request, call_next: Callable[[Request], Awaitable[Response]]) -> Response: + settings: Settings = request.app.state.rate_limit_settings + if not settings.rate_limit_enabled: + return await call_next(request) + + budget_class = classify(request.method, request.url.path) + if budget_class is None: + return await call_next(request) + + budgets = { + "auth": settings.rate_limit_auth_per_minute, + "ai": settings.rate_limit_ai_per_minute, + "heavy": settings.rate_limit_heavy_per_minute, + "default": settings.rate_limit_default_per_minute, + } + limit = budgets[budget_class] + key = f"{budget_class}:{resolve_rate_key(request, settings)}" + + store: RateLimitStore = request.app.state.rate_limit_store + try: + count, retry_after = await store.hit(key, WINDOW_SECONDS) + except StoreUnavailableError: + runtime_metrics.record_rate_limit_degraded() + logger.warning( + "Rate-limit store unavailable; allowing request unchecked", + extra={"path": request.url.path, "budget_class": budget_class}, + ) + return await call_next(request) + + if count > limit: + runtime_metrics.record_rate_limited() + return JSONResponse( + status_code=429, + headers={"Retry-After": str(retry_after)}, + content=ErrorResponse( + code="rate_limited", + message="Too many requests. Try again shortly.", + details={"retryAfterSeconds": retry_after}, + request_id=get_request_id(), + ).model_dump(), + ) + return await call_next(request) diff --git a/apps/backend/app/core/schema_sync.py b/apps/backend/app/core/schema_sync.py new file mode 100644 index 00000000..431d7371 --- /dev/null +++ b/apps/backend/app/core/schema_sync.py @@ -0,0 +1,177 @@ +"""Development database schema-sync and drift detection (#166). + +``AUTO_CREATE_TABLES``'s ``create_all`` creates any table missing from the +database but never alters an existing one and never advances the +``alembic_version`` stamp. After any schema-changing merge, an existing local +development database silently drifts from the code: requests fail at +runtime with opaque ``IntegrityError``/``OperationalError`` 500s instead of a +clear migration error. This module makes that drift visible -- and, in +development/test, self-heals it -- at startup instead of at request time. + +Reads revisions through Alembic's ``ScriptDirectory``/``MigrationContext`` +APIs and runs an upgrade through ``alembic.command.upgrade`` (the same +in-process Python API ``tests/test_migrations.py`` already uses); nothing +here shells out to the ``alembic`` CLI. Dialect-agnostic: SQLite and +PostgreSQL both pass through the same code path. +""" + +from __future__ import annotations + +import inspect +import logging +import re +from pathlib import Path + +from alembic import command +from alembic.config import Config +from alembic.runtime.migration import MigrationContext +from alembic.script import ScriptDirectory +from sqlalchemy import inspect as sa_inspect +from sqlalchemy.engine import Engine + +logger = logging.getLogger(__name__) + +_BACKEND_ROOT = Path(__file__).resolve().parents[2] +_DEV_ENVS = frozenset({"development", "test"}) +_CREATE_TABLE_PATTERN = re.compile(r'create_table\(\s*["\'](\w+)["\']') + + +class SchemaDriftError(RuntimeError): + """The database schema cannot be safely reconciled automatically. + + Raised only for the case a blind ``alembic upgrade head`` cannot resolve + on its own: a pending migration would create a table that already + exists physically (built by a prior ``create_all`` run without a + matching stamp). Auto-upgrading here would crash with "table already + exists"; the caller should let this propagate so the app refuses to + start with the actionable message it carries. + """ + + +def _alembic_config() -> Config: + config = Config(str(_BACKEND_ROOT / "alembic.ini")) + config.set_main_option("script_location", str(_BACKEND_ROOT / "alembic")) + return config + + +def _script_directory() -> ScriptDirectory: + return ScriptDirectory.from_config(_alembic_config()) + + +def head_revision() -> str: + heads = _script_directory().get_heads() + if len(heads) != 1: + raise SchemaDriftError(f"Expected exactly one Alembic head, found {heads!r}.") + return heads[0] + + +def current_revision(engine: Engine) -> str | None: + with engine.connect() as connection: + return MigrationContext.configure(connection).get_current_revision() + + +def stamp_head(engine: Engine) -> None: + """Stamp ``alembic_version`` at head without running any migration body. + + Used only right after ``Base.metadata.create_all`` builds every table + directly from the current ORM models -- which by definition already + matches head's shape -- so only the bookkeeping stamp needs to catch up, + not any migration body. + """ + + script = _script_directory() + head = head_revision() + with engine.begin() as connection: + MigrationContext.configure(connection).stamp(script, head) + logger.info("Stamped fresh database at Alembic head %s", head) + + +def _tables_created_between(script: ScriptDirectory, head: str, lower: str) -> dict[str, str]: + """Map table name -> revision id for every ``create_table`` after ``lower``. + + A static, cheap probe (no migration execution): reads each pending + revision's ``upgrade()`` source text rather than running it. + """ + + created: dict[str, str] = {} + for revision_script in script.iterate_revisions(head, lower): + upgrade_source = inspect.getsource(revision_script.module.upgrade) + for table_name in _CREATE_TABLE_PATTERN.findall(upgrade_source): + created[table_name] = revision_script.revision + return created + + +def _recommended_stamp(script: ScriptDirectory, head: str, lower: str, existing_tables: set[str]) -> str: + """The latest pending revision whose created table(s) already exist physically. + + Walked oldest-first from ``lower``; only advances past a revision when its + created table(s) are positively confirmed already present. A revision + that creates no table at all (a pure column/index change, like a plain + ``drop_column``) gives no physical evidence either way that it already + ran, so it must not be silently assumed applied -- advancing past it + would recommend stamping over a migration that never actually executed. + Stops at the first revision without that positive confirmation. + """ + + recommended = lower + for revision_script in reversed(list(script.iterate_revisions(head, lower))): + upgrade_source = inspect.getsource(revision_script.module.upgrade) + created = set(_CREATE_TABLE_PATTERN.findall(upgrade_source)) + if not created or not created.issubset(existing_tables): + break + recommended = revision_script.revision + return recommended + + +def ensure_schema_in_sync(engine: Engine, *, app_env: str) -> None: + """Detect and, in development/test, resolve local database schema drift. + + No-op outside development/test: production/staging behaviour is + unchanged, migrations remain the operator's explicit responsibility + there. + + When the database is genuinely behind head with no physical conflict, + upgrades it in place (logging exactly what ran). When a pending + migration would create a table that already exists physically -- + the create_all-without-stamp state -- raises ``SchemaDriftError`` with + the stamp-then-upgrade recovery instead of attempting the upgrade, so a + crash-loop on "table already exists" is never silently retried. + """ + + if app_env not in _DEV_ENVS: + return + + script = _script_directory() + head = head_revision() + current = current_revision(engine) + if current == head: + return + + lower = current or "base" + existing_tables = set(sa_inspect(engine).get_table_names()) + tables_by_revision = _tables_created_between(script, head, lower) + conflicts = {table: revision for table, revision in tables_by_revision.items() if table in existing_tables} + if conflicts: + recommended = _recommended_stamp(script, head, lower, existing_tables) + conflict_desc = ", ".join(f"{table!r} (from {revision})" for table, revision in sorted(conflicts.items())) + raise SchemaDriftError( + "Local database schema has drifted from its Alembic stamp: table(s) " + f"{conflict_desc} already exist physically even though the database is " + f"stamped at {current!r} (head is {head!r}). This happens when " + "AUTO_CREATE_TABLES built a table without updating alembic_version. " + "A blind 'alembic upgrade head' would crash trying to recreate it. " + "Recover with:\n" + f" cd apps/backend && .venv/bin/alembic stamp {recommended}\n" + " cd apps/backend && .venv/bin/alembic upgrade head\n" + "The first command marks the migrations already reflected in the physical " + "schema as applied without re-running them; the second then applies whatever " + "genuinely remains pending." + ) + + logger.warning( + "Local database schema is behind Alembic head (%s -> %s); upgrading automatically.", + current, + head, + ) + command.upgrade(_alembic_config(), "head") + logger.info("Upgraded local database schema to Alembic head %s", head) diff --git a/apps/backend/app/core/security_headers.py b/apps/backend/app/core/security_headers.py new file mode 100644 index 00000000..233c9d29 --- /dev/null +++ b/apps/backend/app/core/security_headers.py @@ -0,0 +1,37 @@ +from collections.abc import Awaitable, Callable + +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import Response + +# Applied to every response. HSTS is honoured by browsers only over HTTPS, so +# sending it over plain HTTP (local dev) is silently ignored and safe to leave on. +SECURITY_HEADERS: dict[str, str] = { + "X-Content-Type-Options": "nosniff", + "X-Frame-Options": "DENY", + "Referrer-Policy": "no-referrer", + "Strict-Transport-Security": "max-age=63072000; includeSubDomains", +} + +# PARTHA's API returns JSON and never needs to load, frame, or embed anything, so +# the policy is deny-all. The one exception is the interactive API docs, which +# render HTML that pulls Swagger/ReDoc assets; CSP is skipped for those paths only +# (the other headers still apply). +CONTENT_SECURITY_POLICY = "default-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'" +CSP_EXEMPT_PREFIXES = ("/docs", "/redoc", "/openapi.json") + + +class SecurityHeadersMiddleware(BaseHTTPMiddleware): + """Attach baseline security headers to every response. + + Uses ``setdefault`` so a handler that deliberately sets one of these headers + (e.g. a route needing a looser CSP) is never overridden. + """ + + async def dispatch(self, request: Request, call_next: Callable[[Request], Awaitable[Response]]) -> Response: + response = await call_next(request) + for header, value in SECURITY_HEADERS.items(): + response.headers.setdefault(header, value) + if not request.url.path.startswith(CSP_EXEMPT_PREFIXES): + response.headers.setdefault("Content-Security-Policy", CONTENT_SECURITY_POLICY) + return response diff --git a/apps/backend/app/extraction/__init__.py b/apps/backend/app/extraction/__init__.py new file mode 100644 index 00000000..fad3adea --- /dev/null +++ b/apps/backend/app/extraction/__init__.py @@ -0,0 +1,63 @@ +from app.extraction.base import ( + ExtractedDiagnostic, + ExtractedEvidence, + ExtractedNode, + ExtractedObservation, + ExtractionResult, + Extractor, +) +from app.extraction.pipeline import ( + DEFAULT_MAX_SOURCE_BYTES, + ExtractionPipeline, + ProducedExtraction, +) +from app.extraction.dependencies import ( + DEPENDENCY_SET_ARRAY_KEYS, + merge_dependency_facts, +) +from app.extraction.iac import IacExtractor +from app.extraction.lockfiles import LockfileExtractor +from app.extraction.manifests import DependencyManifestExtractor +from app.extraction.python import PythonExtractor +from app.extraction.typescript import TypeScriptExtractor + + +def production_extractors() -> tuple[Extractor, ...]: + """The extractor set durable analysis runs, in one place. + + Registering a new extractor here is what makes it real: the worker builds + its pipeline from this tuple, ``AnalysisJobService`` derives the planned + producer-version set from it (so submit and execution key on the identical + semantic identity), and the golden benchmark measures it. A second hand-kept + list anywhere else is how a producer ends up emitting facts a snapshot never + declared, which fails sealing. + """ + + return ( + PythonExtractor(), + TypeScriptExtractor(), + DependencyManifestExtractor(), + LockfileExtractor(), + IacExtractor(), + ) + + +__all__ = [ + "ExtractedDiagnostic", + "ExtractedEvidence", + "ExtractedNode", + "ExtractedObservation", + "ExtractionResult", + "Extractor", + "DEFAULT_MAX_SOURCE_BYTES", + "DEPENDENCY_SET_ARRAY_KEYS", + "DependencyManifestExtractor", + "ExtractionPipeline", + "IacExtractor", + "LockfileExtractor", + "ProducedExtraction", + "PythonExtractor", + "TypeScriptExtractor", + "merge_dependency_facts", + "production_extractors", +] diff --git a/apps/backend/app/extraction/base.py b/apps/backend/app/extraction/base.py new file mode 100644 index 00000000..0bf40fc0 --- /dev/null +++ b/apps/backend/app/extraction/base.py @@ -0,0 +1,211 @@ +from __future__ import annotations + +from collections import defaultdict +from collections.abc import Mapping, Sequence +from dataclasses import dataclass, replace +from typing import Protocol, runtime_checkable + +from app.intelligence import canonical + + +@dataclass(frozen=True) +class ExtractedEvidence: + path: str # repository-relative POSIX, normalized (RFC §4.2) + start_line: int # one-based + end_line: int # one-based, inclusive + logical_line_count: int + granularity: str = "span" # "span" | "file" + + +@dataclass(frozen=True) +class ExtractedNode: + node_kind: str # "file" | "module" | "symbol" | "dependency" + stable_key: str # normalized per RFC §4.3 + name: str | None + language: str | None + evidence: tuple[ExtractedEvidence, ...] + properties: Mapping[str, object] | None = None + + +@dataclass(frozen=True) +class ExtractedObservation: + observed_kind: str # "definition" | "import" | "call" | "route" | ... + subject_kind: str + subject_key: str + referent_text: str | None + ordinal: int + evidence: ExtractedEvidence + + +@dataclass(frozen=True) +class ExtractedDiagnostic: + code: str + category: str + severity: str # fatal | error | warning | info + message: str + path: str | None = None + span: tuple[int, int] | None = None + subject: str | None = None + details: Mapping[str, object] | None = None + + +@dataclass(frozen=True) +class ExtractionResult: + nodes: tuple[ExtractedNode, ...] = () + observations: tuple[ExtractedObservation, ...] = () + diagnostics: tuple[ExtractedDiagnostic, ...] = () + + +@runtime_checkable +class Extractor(Protocol): + name: str + version: str + + def supports(self, path: str) -> bool: ... + + def extract(self, path: str, source: bytes) -> ExtractionResult: ... + + +# --- Diagnostic codes (RFC §8.2) ------------------------------------------- + +RI_SRC_BINARY = "RI-SRC-BINARY" +RI_SRC_MALFORMED = "RI-SRC-MALFORMED" +RI_EXT_UNSUPPORTED = "RI-EXT-UNSUPPORTED" +RI_LIMIT_SKIP = "RI-LIMIT-SKIP" +RI_SPAN_INVALID = "RI-SPAN-INVALID" +RI_SEC_PATH_ESCAPE = "RI-SEC-PATH-ESCAPE" +RI_KEY_DUP_SYMBOL = "RI-KEY-DUP-SYMBOL" + +_CATEGORY = { + RI_SRC_BINARY: "binary source", + RI_SRC_MALFORMED: "malformed source", + RI_EXT_UNSUPPORTED: "unsupported construct", + RI_LIMIT_SKIP: "resource-limit skip", + RI_SPAN_INVALID: "invalid span", + RI_SEC_PATH_ESCAPE: "path escape", + RI_KEY_DUP_SYMBOL: "duplicate symbol", +} + + +def assign_ordinals( + observations: Sequence[ExtractedObservation], +) -> tuple[ExtractedObservation, ...]: + """Set RFC §6.4 ordinals: source order among otherwise-identical observations. + + ``ordinal`` exists to separate observations whose *other* identity fields are + all equal — two identical occurrences on one line, while columns are deferred. + It is deliberately not a file-wide counter: observations that already differ + in kind, subject, referent text, or span are distinct without it and each + start at 1. + + Collectors emit a placeholder ordinal; this is the single authority that sets + the real value, so the grouping key here stays in lockstep with the identity + document that ``canonical.compute_observation_id`` hashes. + """ + + counts: dict[tuple[object, ...], int] = defaultdict(int) + assigned: list[ExtractedObservation] = [] + for observation in observations: + identity = ( + observation.observed_kind, + observation.subject_kind, + observation.subject_key, + observation.referent_text, + observation.evidence.path, + observation.evidence.start_line, + observation.evidence.end_line, + ) + counts[identity] += 1 + assigned.append(replace(observation, ordinal=counts[identity])) + return tuple(assigned) + + +def logical_line_count(text: str) -> int: + """RFC §6.2: one logical line per file, plus one per U+000A.""" + + return 1 + text.count("\n") + + +def decode_source(path: str, source: bytes, *, producer: str) -> tuple[str | None, ExtractedDiagnostic | None]: + """Strict UTF-8 decode with RFC §6.2 binary/malformed handling. + + Returns ``(text, None)`` for decodable text (a zero-byte file decodes to + ``""``), or ``(None, diagnostic)`` when the file is binary (contains a NUL + byte) or is not valid UTF-8. + """ + + try: + normalized_path = canonical.normalize_repo_path(path) + subject = canonical.normalize_stable_key("file", f"file:{normalized_path}") + except canonical.PathEscapeError: + normalized_path = None + subject = None + + if b"\x00" in source: + return None, ExtractedDiagnostic( + code=RI_SRC_BINARY, + category=_CATEGORY[RI_SRC_BINARY], + severity="info", + message="file contains a NUL byte and is excluded from line-addressed extraction", + path=normalized_path, + subject=subject, + ) + try: + return source.decode("utf-8"), None + except UnicodeDecodeError: + return None, ExtractedDiagnostic( + code=RI_SRC_MALFORMED, + category=_CATEGORY[RI_SRC_MALFORMED], + severity="error", + message="file is not valid UTF-8 and could not be decoded", + path=normalized_path, + subject=subject, + ) + + +def build_evidence( + path: str, + start_line: int, + end_line: int, + logical_line_count: int, + *, + producer: str, + granularity: str = "span", +) -> tuple[ExtractedEvidence | None, ExtractedDiagnostic | None]: + """Validate a span and path (RFC §4.2, §6.2), returning evidence or a diagnostic. + + ``producer`` is the emitting extractor's ``name@version`` identifier. It is + accepted for call-site uniformity across extractors; the diagnostic's producer + is recorded when the extraction result is persisted to the snapshot store, not + embedded in the returned ``ExtractedDiagnostic`` here. + """ + + try: + normalized = canonical.normalize_repo_path(path) + except canonical.PathEscapeError: + return None, ExtractedDiagnostic( + code=RI_SEC_PATH_ESCAPE, + category=_CATEGORY[RI_SEC_PATH_ESCAPE], + severity="error", + message="evidence path is absolute or escapes the repository root", + path=None, + ) + if not (1 <= start_line <= end_line <= logical_line_count): + return None, ExtractedDiagnostic( + code=RI_SPAN_INVALID, + category=_CATEGORY[RI_SPAN_INVALID], + severity="error", + message=(f"span {start_line}..{end_line} is not within 1..{logical_line_count}"), + path=normalized, + span=(start_line, end_line), + ) + return ( + ExtractedEvidence( + path=normalized, + start_line=start_line, + end_line=end_line, + logical_line_count=logical_line_count, + granularity=granularity, + ), + None, + ) diff --git a/apps/backend/app/extraction/dependencies.py b/apps/backend/app/extraction/dependencies.py new file mode 100644 index 00000000..c4890d20 --- /dev/null +++ b/apps/backend/app/extraction/dependencies.py @@ -0,0 +1,173 @@ +"""Deterministic merge of dependency facts onto one logical identity (#156, #209). + +A dependency is one entity that several files describe. ``package.json`` and +``apps/admin/package.json`` may both *declare* ``react``; ``package-lock.json`` +may *resolve* it to an exact version; a nested npm tree may resolve it twice. +Every one of those is a fact about the same ``dep:npm:react`` node. + +The extractors cannot merge this themselves — each ``extract()`` call sees one +file — so each emits one ``ExtractedNode`` per fact. Left unmerged, two nodes +sharing a stable key reach :meth:`SnapshotStore.add_node` as two writes whose +``properties`` differ, which it correctly rejects as an internal conflict, +failing analysis for a repository shape that is not an error. + +This module folds them into one node per stable key carrying two explicitly +separate collections: + +``declarations`` + What a manifest asked for — a range or specifier such as ``^18.3.0``. +``resolutions`` + What a lockfile recorded as installed — an exact pin such as ``18.3.1``. + +Keeping them apart is the point. A caret range is not an installed version, and +a lockfile entry is not proof of a direct dependency, so neither collection is +allowed to stand in for the other. Both are declared *set* arrays, so canonical +serialization sorts and de-duplicates them and the graph hash cannot depend on +the order files happened to be read in. + +The merged node is emitted once **per contributing producer**, each carrying +only that producer's own evidence records. ``add_node`` creates the node on the +first write and unions evidence on the rest, so a single node ends up with +manifest evidence attributed to ``dependency-manifest`` and lockfile evidence +attributed to ``dependency-lockfile`` — no producer is credited with a span it +did not read. +""" + +from __future__ import annotations + +from collections import defaultdict +from dataclasses import replace + +from app.extraction.base import ExtractedEvidence, ExtractedNode, ExtractionResult +from app.extraction.lockfiles import LockfileExtractor +from app.extraction.manifests import DependencyManifestExtractor +from app.extraction.pipeline import ProducedExtraction + +#: Producer names allowed to contribute to one merged dependency node, in the +#: order their records are considered when picking the node's display name. A +#: stable key claimed by any producer outside this set is a genuine identity +#: conflict, not a multi-file description of one dependency. +DEPENDENCY_PRODUCERS: tuple[str, ...] = ( + DependencyManifestExtractor.name, + LockfileExtractor.name, +) + +#: Node ``properties`` keys whose arrays have set semantics (RFC §12.3). +DEPENDENCY_SET_ARRAY_KEYS = frozenset({"declarations", "resolutions"}) + +_DECLARATION_FIELDS = ("version", "dependency_type", "manifest_path", "workspace_path") +_RESOLUTION_FIELDS = ( + "resolved_version", + "dependency_scope", + "lockfile_path", + "lockfile_format", + "lockfile_entry", + "workspace_path", +) + + +def merge_dependency_facts(produced: tuple[ProducedExtraction, ...]) -> tuple[ProducedExtraction, ...]: + """Combine per-file dependency nodes sharing a stable key into one record. + + Returns the merged entries first, ahead of every other produced entry: a + per-file ``dependency``/``resolution`` observation references its node's + stable key, and ``add_observation`` enforces a same-snapshot foreign key to + an already-persisted node. Observations stay exactly where they were + produced; only the now-redundant per-file dependency nodes move. + """ + + groups: dict[str, list[tuple[ProducedExtraction, ExtractedNode]]] = defaultdict(list) + for item in produced: + for node in item.result.nodes: + if node.node_kind == "dependency": + groups[node.stable_key].append((item, node)) + if not groups: + return produced + + mergeable = { + stable_key: members + for stable_key, members in groups.items() + if all(item.producer_name in DEPENDENCY_PRODUCERS for item, _ in members) + } + if not mergeable: + return produced + + # {producer identity: [merged node carrying only that producer's evidence]} + by_producer: dict[tuple[str, str], list[ExtractedNode]] = defaultdict(list) + for stable_key in sorted(mergeable): + merged = _merged_node(stable_key, mergeable[stable_key]) + for identity, evidence in _evidence_by_producer(mergeable[stable_key]).items(): + by_producer[identity].append(replace(merged, evidence=evidence)) + merged_entries = [ + ProducedExtraction(producer_name, producer_version, ExtractionResult(nodes=tuple(nodes))) + for (producer_name, producer_version), nodes in sorted(by_producer.items()) + ] + + rewritten: list[ProducedExtraction] = [] + for item in produced: + kept = tuple( + node for node in item.result.nodes if not (node.node_kind == "dependency" and node.stable_key in mergeable) + ) + rewritten.append(item if kept == item.result.nodes else replace(item, result=replace(item.result, nodes=kept))) + return tuple(merged_entries) + tuple(rewritten) + + +def _member_order(pair: tuple[ProducedExtraction, ExtractedNode]) -> tuple[int, str, int, int]: + """Order members so the merged record is a function of content, not read order. + + Manifest declarations rank ahead of lockfile resolutions so a declared + dependency keeps the name its manifest spelled — the name a reader of the + repository would recognize — while a lockfile-only entry still gets one. + """ + + item, node = pair + properties = node.properties or {} + source_path = str(properties.get("manifest_path") or properties.get("lockfile_path") or "") + rank = DEPENDENCY_PRODUCERS.index(item.producer_name) + return (rank, source_path, node.evidence[0].start_line, node.evidence[0].end_line) + + +def _merged_node(stable_key: str, members: list[tuple[ProducedExtraction, ExtractedNode]]) -> ExtractedNode: + ordered = sorted(members, key=_member_order) + declarations: list[dict[str, object]] = [] + resolutions: list[dict[str, object]] = [] + for item, node in ordered: + properties = node.properties or {} + record: dict[str, object] = { + "name": node.name, + "start_line": node.evidence[0].start_line, + "end_line": node.evidence[0].end_line, + "extractor": item.producer_name, + "extractor_version": item.producer_version, + } + if item.producer_name == LockfileExtractor.name: + record.update({field: properties.get(field) for field in _RESOLUTION_FIELDS}) + resolutions.append(record) + else: + record.update({field: properties.get(field) for field in _DECLARATION_FIELDS}) + declarations.append(record) + return ExtractedNode( + node_kind="dependency", + stable_key=stable_key, + name=ordered[0][1].name, + language=None, + evidence=(), + properties={ + "ecosystem": (ordered[0][1].properties or {}).get("ecosystem"), + # Both keys are always present. An empty ``resolutions`` list states + # "no supported lockfile pinned this dependency" as a fact, which is + # the honest answer; omitting the key would make "not resolved" and + # "not looked at" indistinguishable. + "declarations": declarations, + "resolutions": resolutions, + }, + ) + + +def _evidence_by_producer( + members: list[tuple[ProducedExtraction, ExtractedNode]], +) -> dict[tuple[str, str], tuple[ExtractedEvidence, ...]]: + grouped: dict[tuple[str, str], list[ExtractedEvidence]] = defaultdict(list) + for item, node in sorted(members, key=_member_order): + grouped[(item.producer_name, item.producer_version)].extend(node.evidence) + return {identity: tuple(records) for identity, records in grouped.items()} diff --git a/apps/backend/app/extraction/http.py b/apps/backend/app/extraction/http.py new file mode 100644 index 00000000..3c927fba --- /dev/null +++ b/apps/backend/app/extraction/http.py @@ -0,0 +1,166 @@ +"""Shared truth rules for observed outbound HTTP call sites (#209). + +The Python and TypeScript extractors recognize different client libraries, but a +service interaction means the same thing in both: *this source line proves a +request to this destination*. The rules for turning a literal URL into a +deterministic identity therefore live here once, so the two extractors cannot +drift into disagreeing about what counts as a destination. + +Two decisions are deliberate and load-bearing: + +**Identity is the origin, not the URL.** ``scheme://host[:port]`` is what a +service *is*; ``/v1/users`` is what one call site asked it for. Folding the path +into the node key would produce one "service" per endpoint and make the graph +useless for impact questions, so the path travels on the call's own observation +instead. + +**The query string is dropped.** A literal URL in source can carry an API key or +token in its query (``?api_key=...``). Diagnostics and stored facts must not +embed secrets (RFC §13), and the query is not part of the destination's +identity, so it is discarded rather than recorded. Userinfo credentials +(``https://user:pass@host``) are stripped from the origin for the same reason. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from urllib.parse import urlsplit + +#: Client attribute names that name their own HTTP method (``requests.get``, +#: ``session.post``, ``axios.delete``). ``request``-style calls take the method +#: as an argument and are handled separately by each extractor. +HTTP_METHOD_ATTRIBUTES = frozenset({"delete", "get", "head", "options", "patch", "post", "put"}) + +#: Only these schemes describe an HTTP service interaction. Anything else +#: (``file:``, ``ws:``, ``data:``) is not one and is never recorded as one. +_SUPPORTED_SCHEMES = frozenset({"http", "https"}) +_DEFAULT_PORTS = {"http": "80", "https": "443"} + + +@dataclass(frozen=True) +class HttpDestination: + """A syntax-proven destination: an absolute origin plus a literal method.""" + + method: str + origin: str + path: str + + @property + def referent_text(self) -> str: + """The observation referent: ``||``. + + A single delimited field keeps the observation identity (RFC §6.4) exact + while still letting the resolver recover the origin without re-parsing + source, mirroring how ``import_binding`` stores its three parts. ``|`` + cannot appear in a normalized origin or in an HTTP method token. + """ + + return f"{self.method}|{self.origin}|{self.path}" + + +def normalize_method(raw: str) -> str | None: + """Return the canonical uppercase method token, or ``None`` if implausible. + + Methods are matched as whole ASCII tokens so a computed value that happens + to be a string (``"GET " + suffix`` folded by a compiler, a header blob) + cannot be mistaken for a proven method. + """ + + token = raw.strip().upper() + if not token or not token.isascii() or not token.isalpha(): + return None + return token + + +def _split_origin(url: str) -> tuple[str, str] | None: + """Parse ``url`` and return ``(origin, path)``, or ``None``. + + Shared by both entry points below: neither a fully literal URL nor a + literal f-string/template-literal prefix trusts a scheme, host, or origin + that don't come out of this one check. + """ + + try: + parts = urlsplit(url.strip()) + except ValueError: + return None + scheme = parts.scheme.lower() + if scheme not in _SUPPORTED_SCHEMES or not parts.hostname: + return None + host = parts.hostname.lower().rstrip(".") + if not host: + return None + try: + port = parts.port + except ValueError: + return None + origin = f"{scheme}://{host}" + if port is not None and str(port) != _DEFAULT_PORTS[scheme]: + origin = f"{origin}:{port}" + return origin, parts.path + + +def describe_destination(method: str, url: str) -> HttpDestination | None: + """Build a destination from a literal method and URL, or ``None``. + + ``None`` means the URL is not an absolute ``http``/``https`` address — a + relative path, a scheme-relative ``//host`` reference, a non-HTTP scheme, or + a host-less string. None of those identify a service on their own, and the + caller turns each into an explicit diagnostic rather than a guessed edge. + """ + + normalized_method = normalize_method(method) + if normalized_method is None: + return None + split = _split_origin(url) + if split is None: + return None + origin, path = split + return HttpDestination(method=normalized_method, origin=origin, path=path or "/") + + +def describe_destination_from_literal_prefix(method: str, literal_prefix: str) -> HttpDestination | None: + """Build a destination from an f-string/template-literal's leading literal + text -- everything from the start of the string up to its first + interpolation -- or ``None``. + + Origin identity is only ``scheme://host[:port]``, so a call like + ``f"https://api.example.com/users/{user_id}"`` proves its destination + origin from ``literal_prefix`` alone even though the full URL isn't a + compile-time constant: nothing after the authority can change what + service this is. + + That's only true once the authority is *closed off* within the literal + text, though -- ``literal_prefix`` must already contain the ``/`` that + starts the path (or the caller wouldn't have anything left to + interpolate into the URL at all). Without it, the interpolation could + still be extending the host itself (``f"https://{tenant}.example.com"``, + or the more dangerous ``f"https://api.example.com{suffix}"`` where a + ``suffix`` that doesn't start with ``/`` silently becomes part of the + hostname), and there is no origin here to trust. + """ + + normalized_method = normalize_method(method) + if normalized_method is None: + return None + split = _split_origin(literal_prefix) + if split is None: + return None + origin, path = split + if not path: + return None + return HttpDestination(method=normalized_method, origin=origin, path=path) + + +def parse_referent(referent: str) -> HttpDestination | None: + """Recover a destination from a stored ``http_call`` referent. + + Used by the resolver, which reads persisted observations and never re-reads + source. A referent that does not have exactly the three expected parts is + not silently repaired. + """ + + parts = referent.split("|") + if len(parts) != 3 or not all(parts): + return None + return HttpDestination(method=parts[0], origin=parts[1], path=parts[2]) diff --git a/apps/backend/app/extraction/iac.py b/apps/backend/app/extraction/iac.py new file mode 100644 index 00000000..95a622ae --- /dev/null +++ b/apps/backend/app/extraction/iac.py @@ -0,0 +1,272 @@ +"""Infrastructure-as-code resource extraction for supported manifests (#209). + +Supported format — this list is exact: + +Docker Compose (``compose.yaml``, ``compose.yml``, ``docker-compose.yaml``, +``docker-compose.yml``) + The top-level ``services``, ``volumes``, and ``networks`` mappings are read. + Each key in one of those mappings is a declared resource whose name is + written literally in the source, so its identity and span are facts about the + bytes rather than an inference. + +Deliberately **not** supported, and reported as such rather than guessed at: +Terraform/OpenTofu HCL, Kubernetes manifests, Helm charts, CloudFormation, +Pulumi, Ansible, and Compose's ``configs``/``secrets`` sections. Detecting +Kubernetes by scanning every ``*.yaml`` for an ``apiVersion`` key would make an +arbitrary YAML file a resource claim, which is exactly the fabricated fact the +truth contract forbids; a fixed, documented filename set is the boundary. + +Parsing goes through PyYAML's *composer* (``yaml.compose``) rather than +``safe_load``. The composer returns the node graph with each node's source +marks, which is what makes an exact one-based declaration line possible at all, +and it never constructs Python objects or expands aliases. + +Values are only recorded when the source proves them. A service ``image`` that +interpolates an environment variable (``${TAG}``) is a template, not an observed +image, so it becomes an ``RI-EXT-UNSUPPORTED`` disclosure and the property is +omitted. +""" + +from __future__ import annotations + +from dataclasses import dataclass +import posixpath +import re + +import yaml + +from app.extraction.base import ( + ExtractedDiagnostic, + ExtractedNode, + ExtractedObservation, + ExtractionResult, + RI_EXT_UNSUPPORTED, + RI_SEC_PATH_ESCAPE, + RI_SRC_MALFORMED, + assign_ordinals, + build_evidence, + decode_source, + logical_line_count, +) +from app.extraction.naming import iac_resource_stable_key +from app.extraction.structured import StructureError +from app.extraction.support_matrix import supported_iac_filenames +from app.intelligence import canonical + +SUPPORTED_IAC_FILENAMES = supported_iac_filenames() + +#: Top-level Compose mapping -> the singular resource type recorded for its keys. +COMPOSE_RESOURCE_SECTIONS = { + "networks": "network", + "services": "service", + "volumes": "volume", +} + +#: ``${VAR}``, ``${VAR:-default}``, and bare ``$VAR`` interpolation. Compose +#: escapes a literal dollar as ``$$``, so that form is not a template. +_INTERPOLATION = re.compile(r"(? str: + return f"{self.name}@{self.version}" + + def supports(self, path: str) -> bool: + return posixpath.basename(path) in SUPPORTED_IAC_FILENAMES + + def extract(self, path: str, source: bytes) -> ExtractionResult: + text, source_diagnostic = decode_source(path, source, producer=self.producer) + if text is None: + assert source_diagnostic is not None # decode_source pairs None text with a diagnostic + return ExtractionResult(diagnostics=(source_diagnostic,)) + try: + normalized_path = canonical.normalize_repo_path(path) + except canonical.PathEscapeError: + return ExtractionResult( + diagnostics=( + ExtractedDiagnostic( + code=RI_SEC_PATH_ESCAPE, + category="path escape", + severity="error", + message="source path is absolute or escapes the repository root", + ), + ) + ) + + line_count = logical_line_count(text) + file_subject = canonical.normalize_stable_key("file", f"file:{normalized_path}") + try: + resources = self._compose_resources(text) + except (yaml.YAMLError, StructureError): + return ExtractionResult( + diagnostics=( + ExtractedDiagnostic( + code=RI_SRC_MALFORMED, + category="malformed source", + severity="error", + message="IaC manifest could not be parsed or has an unsupported structure", + path=normalized_path, + subject=file_subject, + ), + ) + ) + + nodes: list[ExtractedNode] = [] + observations: list[ExtractedObservation] = [] + diagnostics: list[ExtractedDiagnostic] = [] + for resource in resources: + stable_key = iac_resource_stable_key(normalized_path, resource.resource_type, resource.name) + evidence, diagnostic = build_evidence( + normalized_path, + resource.line, + resource.line, + line_count, + producer=self.producer, + ) + if evidence is None: + if diagnostic is not None: + diagnostics.append(diagnostic) + continue + properties: dict[str, object] = { + "resource_type": resource.resource_type, + "manifest_format": "docker-compose", + "manifest_path": normalized_path, + } + if resource.image is not None: + properties["image"] = resource.image + nodes.append( + ExtractedNode( + node_kind="iac_resource", + stable_key=stable_key, + name=resource.name, + language=None, + evidence=(evidence,), + properties=properties, + ) + ) + observations.append( + ExtractedObservation( + observed_kind="iac_resource", + subject_kind="iac_resource", + subject_key=stable_key, + referent_text=f"{resource.resource_type}/{resource.name}", + ordinal=0, + evidence=evidence, + ) + ) + if resource.templated: + diagnostics.append( + ExtractedDiagnostic( + code=RI_EXT_UNSUPPORTED, + category="unsupported construct", + severity="info", + # Naming the construct only: the interpolated value can + # carry deployment detail, and RFC §13 keeps repository + # content out of diagnostic text. The span locates it. + message="templated IaC value is unsupported", + path=normalized_path, + span=(resource.line, resource.line), + subject=stable_key, + ) + ) + return ExtractionResult( + nodes=tuple(nodes), + observations=assign_ordinals(observations), + diagnostics=tuple(diagnostics), + ) + + @staticmethod + def _compose_resources(text: str) -> list[_Resource]: + """Read declared Compose resources with their exact declaration lines. + + ``yaml.compose`` yields the node graph rather than constructed objects, + so each mapping key keeps the source mark this needs. An empty document + is a valid file with no resources; anything whose root or section is not + a mapping is a structure this extractor refuses to interpret. + """ + + root = yaml.compose(text, Loader=yaml.SafeLoader) + if root is None: + return [] + if not isinstance(root, yaml.MappingNode): + raise StructureError("compose root is not a mapping") + + resources: list[_Resource] = [] + for key_node, value_node in root.value: + section = IacExtractor._scalar(key_node) + resource_type = COMPOSE_RESOURCE_SECTIONS.get(section) if section else None + if resource_type is None: + continue + if isinstance(value_node, yaml.ScalarNode) and value_node.value == "": + # ``volumes:`` with no entries is an empty section, not an error. + continue + if not isinstance(value_node, yaml.MappingNode): + raise StructureError(f"compose {section} is not a mapping") + for entry_key, entry_value in value_node.value: + name = IacExtractor._scalar(entry_key) + if not name: + raise StructureError(f"compose {section} entry has no literal name") + image, templated = IacExtractor._image(entry_value) if resource_type == "service" else (None, False) + resources.append( + _Resource( + resource_type=resource_type, + name=name, + # PyYAML marks are zero-based; RFC spans are one-based. + line=entry_key.start_mark.line + 1, + image=image, + templated=templated, + ) + ) + # Source order already varies with the file; sorting makes the emitted + # order a function of identity alone, so two byte-identical manifests in + # different key order still produce the same ordinals. + resources.sort(key=lambda item: (item.resource_type, item.name, item.line)) + return resources + + @staticmethod + def _image(service_node: yaml.Node) -> tuple[str | None, bool]: + """Return ``(literal image, was_templated)`` for one Compose service.""" + + if isinstance(service_node, yaml.ScalarNode) and service_node.value == "": + # ``web:`` with no body declares the service but nothing about it. + return None, False + if not isinstance(service_node, yaml.MappingNode): + raise StructureError("compose service is not a mapping") + for key_node, value_node in service_node.value: + if IacExtractor._scalar(key_node) != "image": + continue + if not isinstance(value_node, yaml.ScalarNode): + raise StructureError("compose service image is not a scalar") + value = value_node.value + if _INTERPOLATION.search(value): + return None, True + return value or None, False + return None, False + + @staticmethod + def _scalar(node: yaml.Node) -> str | None: + """Return a plain scalar key's text, or ``None`` for any other node. + + A non-scalar key (a YAML sequence or mapping used as a key) has no + literal name to record, so it is never treated as a resource. + """ + + return node.value if isinstance(node, yaml.ScalarNode) else None diff --git a/apps/backend/app/extraction/lockfiles.py b/apps/backend/app/extraction/lockfiles.py new file mode 100644 index 00000000..d7335910 --- /dev/null +++ b/apps/backend/app/extraction/lockfiles.py @@ -0,0 +1,369 @@ +"""Resolved dependency-version extraction from supported lockfiles (#209). + +A manifest says what a project *asked for* (``"react": "^18.3.0"``); a lockfile +says what was actually *installed* (``18.3.1``). Those are different facts about +the same logical dependency, and conflating them would let a caret range be +presented as an installed version. + +This extractor therefore emits a **resolution**, never a declaration. Both land +on the same ``dep::`` identity built by +:func:`app.extraction.naming.dependency_stable_key`, and +:mod:`app.extraction.dependencies` merges them into one node carrying separate +``declarations`` and ``resolutions`` collections. + +Supported formats — this list is exact, and anything outside it produces a +diagnostic rather than a guess: + +``package-lock.json`` with ``lockfileVersion`` 2 or 3 + The ``packages`` object is read. An entry is a resolved registry package + only when its key contains a ``node_modules/`` segment and it carries a + string ``version``. The logical name is the segment after the *last* + ``node_modules/``, so a nested ``node_modules/a/node_modules/b`` entry is a + second resolution of ``b`` — npm's real "two installed versions" shape — + rather than a separate package. Workspace link entries (``"link": true``) + describe a symlink into the repository, not a resolved registry version, and + are skipped. ``lockfileVersion`` 1 predates the ``packages`` table and is + explicitly unsupported. + +``poetry.lock`` with ``[metadata] lock-version`` major 1 or 2 + Each ``[[package]]`` table's ``name`` and ``version`` are read. ``tomllib`` + preserves array-of-tables order, so the i-th parsed table pairs positionally + with the i-th ``[[package]]`` header located in the source. + +Deliberately **not** supported, and reported as such: npm ``lockfileVersion`` 1, +``yarn.lock``, ``pnpm-lock.yaml``, ``Pipfile.lock``, ``uv.lock``, and +``pdm.lock``. No lockfile format is parsed by guessing at another's shape, and +no transitive dependency *graph* is reconstructed: an entry proves a version was +installed, not that the repository depends on it directly. +""" + +from __future__ import annotations + +from dataclasses import dataclass +import json +import posixpath +import re +import tomllib + +from app.extraction.base import ( + ExtractedDiagnostic, + ExtractedNode, + ExtractedObservation, + ExtractionResult, + RI_EXT_UNSUPPORTED, + RI_SEC_PATH_ESCAPE, + RI_SRC_MALFORMED, + assign_ordinals, + build_evidence, + decode_source, + logical_line_count, +) +from app.extraction.naming import dependency_stable_key +from app.extraction.structured import ( + StructureError, + json_object_member_lines, + toml_array_of_tables_lines, +) +from app.extraction.support_matrix import supported_lockfile_filenames +from app.intelligence import canonical + +SUPPORTED_LOCKFILE_FILENAMES = supported_lockfile_filenames() + +#: ``package-lock.json`` revisions whose ``packages`` table this extractor reads. +SUPPORTED_NPM_LOCKFILE_VERSIONS = (2, 3) +#: ``poetry.lock`` ``[metadata] lock-version`` majors whose ``[[package]]`` shape is read. +SUPPORTED_POETRY_LOCK_MAJORS = ("1", "2") + +_NPM_SEGMENT = "node_modules/" +_LOCK_VERSION_MAJOR = re.compile(r"^(\d+)(?:\.|$)") + + +class _UnsupportedLockfile(Exception): + """A lockfile is a supported *filename* in an unsupported *revision*. + + This is deliberately distinct from :class:`StructureError`: the file is not + malformed, it is a format this extractor has not implemented. It becomes an + ``RI-EXT-UNSUPPORTED`` disclosure so the blind spot is visible instead of + silently producing zero resolutions. + """ + + +@dataclass(frozen=True) +class _Resolution: + """One lockfile entry proving an installed version, with its source line.""" + + ecosystem: str + name: str + version: str + #: The lockfile's own key for this entry (npm's ``node_modules/...`` path, + #: or the poetry package name). Two resolutions of one logical dependency + #: differ here, so it is what keeps their merged records distinguishable. + entry: str + #: ``production`` / ``development`` / ``optional``, or ``None`` when the + #: format does not record the distinction (see the format notes above). + scope: str | None + line: int + + +class LockfileExtractor: + """Extract resolved dependency versions from supported lockfiles.""" + + name = "dependency-lockfile" + version = "1.0.0" + + @property + def producer(self) -> str: + return f"{self.name}@{self.version}" + + def supports(self, path: str) -> bool: + return posixpath.basename(path) in SUPPORTED_LOCKFILE_FILENAMES + + def extract(self, path: str, source: bytes) -> ExtractionResult: + text, source_diagnostic = decode_source(path, source, producer=self.producer) + if text is None: + assert source_diagnostic is not None # decode_source pairs None text with a diagnostic + return ExtractionResult(diagnostics=(source_diagnostic,)) + try: + normalized_path = canonical.normalize_repo_path(path) + except canonical.PathEscapeError: + return ExtractionResult( + diagnostics=( + ExtractedDiagnostic( + code=RI_SEC_PATH_ESCAPE, + category="path escape", + severity="error", + message="source path is absolute or escapes the repository root", + ), + ) + ) + + line_count = logical_line_count(text) + basename = posixpath.basename(normalized_path) + file_subject = canonical.normalize_stable_key("file", f"file:{normalized_path}") + try: + if basename == "package-lock.json": + resolutions = self._npm_resolutions(text) + lockfile_format = "npm-package-lock" + else: + resolutions = self._poetry_resolutions(text) + lockfile_format = "poetry-lock" + except _UnsupportedLockfile as exc: + return ExtractionResult( + diagnostics=( + ExtractedDiagnostic( + code=RI_EXT_UNSUPPORTED, + category="unsupported construct", + severity="info", + # The message names this module's own closed vocabulary of + # format revisions; it never quotes repository content + # (RFC §13). The path says where to look. + message=str(exc), + path=normalized_path, + subject=file_subject, + ), + ) + ) + except (json.JSONDecodeError, tomllib.TOMLDecodeError, StructureError, UnicodeDecodeError): + return ExtractionResult( + diagnostics=( + ExtractedDiagnostic( + code=RI_SRC_MALFORMED, + category="malformed source", + severity="error", + message="lockfile could not be parsed or has an unsupported structure", + path=normalized_path, + subject=file_subject, + ), + ) + ) + + workspace_path = posixpath.dirname(normalized_path) or "." + nodes: list[ExtractedNode] = [] + observations: list[ExtractedObservation] = [] + diagnostics: list[ExtractedDiagnostic] = [] + for resolution in resolutions: + stable_key = dependency_stable_key(resolution.ecosystem, resolution.name) + evidence, diagnostic = build_evidence( + normalized_path, + resolution.line, + resolution.line, + line_count, + producer=self.producer, + ) + if evidence is None: + if diagnostic is not None: + diagnostics.append(diagnostic) + continue + nodes.append( + ExtractedNode( + node_kind="dependency", + stable_key=stable_key, + name=resolution.name, + language=None, + evidence=(evidence,), + properties={ + "ecosystem": resolution.ecosystem, + "resolved_version": resolution.version, + "dependency_scope": resolution.scope, + "lockfile_path": normalized_path, + "lockfile_format": lockfile_format, + "lockfile_entry": resolution.entry, + "workspace_path": workspace_path, + }, + ) + ) + observations.append( + ExtractedObservation( + observed_kind="resolution", + subject_kind="dependency", + subject_key=stable_key, + # The pinned version is the fact this observation carries; + # the name is already the subject's identity. + referent_text=resolution.version, + ordinal=0, + evidence=evidence, + ) + ) + return ExtractionResult( + nodes=tuple(nodes), + observations=assign_ordinals(observations), + diagnostics=tuple(diagnostics), + ) + + # --- npm ---------------------------------------------------------------- + + @staticmethod + def _npm_resolutions(text: str) -> list[_Resolution]: + parsed = json.loads(text) + if not isinstance(parsed, dict): + raise StructureError("package-lock.json root is not an object") + lockfile_version = parsed.get("lockfileVersion") + if not isinstance(lockfile_version, int) or isinstance(lockfile_version, bool): + raise StructureError("package-lock.json has no integer lockfileVersion") + if lockfile_version not in SUPPORTED_NPM_LOCKFILE_VERSIONS: + raise _UnsupportedLockfile( + "package-lock.json lockfileVersion is outside the supported set " + f"{list(SUPPORTED_NPM_LOCKFILE_VERSIONS)} and no resolutions are claimed" + ) + packages = parsed.get("packages") + if not isinstance(packages, dict): + raise StructureError("package-lock.json has no packages object") + entry_lines = json_object_member_lines(text).get("packages", {}) + + resolutions: list[_Resolution] = [] + # Sorted so the emitted order depends on the lockfile's content, not on + # dict insertion order; the line lookup below keeps provenance exact. + for entry in sorted(packages): + record = packages[entry] + if not isinstance(record, dict): + raise StructureError("package-lock.json packages entry is not an object") + index = entry.rfind(_NPM_SEGMENT) + if index < 0: + # The root project ("") and workspace directory entries are not + # resolved registry packages. + continue + if record.get("link") is True: + continue + name = entry[index + len(_NPM_SEGMENT) :] + version = record.get("version") + if not name or not isinstance(version, str) or not version: + # An entry without a concrete installed version proves nothing; + # ``add_node`` would otherwise receive a null pin. + continue + line = entry_lines.get(entry) + if line is None: + raise StructureError("package-lock.json entry line could not be located") + resolutions.append( + _Resolution( + ecosystem="npm", + name=name, + version=version, + entry=entry, + scope=LockfileExtractor._npm_scope(record), + line=line, + ) + ) + return resolutions + + @staticmethod + def _npm_scope(record: dict) -> str: + """Map npm's tree flags onto the manifest ``dependency_type`` vocabulary. + + ``optional`` wins over ``dev`` because an optional install can be skipped + entirely, which is the stronger caveat. npm's ``devOptional`` (reachable + from both a dev and an optional edge) is reported as ``development``. + """ + + if record.get("optional") is True: + return "optional" + if record.get("dev") is True or record.get("devOptional") is True: + return "development" + return "production" + + # --- poetry ------------------------------------------------------------- + + @staticmethod + def _poetry_resolutions(text: str) -> list[_Resolution]: + parsed = tomllib.loads(text) + metadata = parsed.get("metadata") + if not isinstance(metadata, dict): + raise StructureError("poetry.lock has no [metadata] table") + lock_version = metadata.get("lock-version") + if not isinstance(lock_version, str): + raise StructureError("poetry.lock has no lock-version string") + major = _LOCK_VERSION_MAJOR.match(lock_version) + if major is None or major.group(1) not in SUPPORTED_POETRY_LOCK_MAJORS: + raise _UnsupportedLockfile( + "poetry.lock lock-version major is outside the supported set " + f"{list(SUPPORTED_POETRY_LOCK_MAJORS)} and no resolutions are claimed" + ) + packages = parsed.get("package", []) + if not isinstance(packages, list): + raise StructureError("poetry.lock package is not an array of tables") + if not packages: + return [] + header_lines = toml_array_of_tables_lines(text, "package") + # The scanner walks the same array the decoder did, in source order, so a + # count mismatch means one of them saw structure the other did not — fail + # closed rather than pair a version with the wrong line. + if len(header_lines) != len(packages): + raise StructureError("poetry.lock package headers could not be located") + + resolutions: list[_Resolution] = [] + for record, line in zip(packages, header_lines): + if not isinstance(record, dict): + raise StructureError("poetry.lock package entry is not a table") + name = record.get("name") + version = record.get("version") + if not isinstance(name, str) or not name or not isinstance(version, str) or not version: + raise StructureError("poetry.lock package entry has no name/version pair") + resolutions.append( + _Resolution( + ecosystem="pypi", + name=name, + version=version, + entry=name, + scope=LockfileExtractor._poetry_scope(record), + line=line, + ) + ) + return resolutions + + @staticmethod + def _poetry_scope(record: dict) -> str | None: + """Report poetry's group only when the lockfile actually records it. + + Poetry dropped the per-package ``category`` field in lock-version 2.0, so + a modern ``poetry.lock`` genuinely does not say whether a package is a + main or dev dependency. ``None`` states that honestly; guessing + ``production`` would invent the distinction. + """ + + if record.get("optional") is True: + return "optional" + category = record.get("category") + if category == "dev": + return "development" + if category == "main": + return "production" + return None diff --git a/apps/backend/app/extraction/manifests.py b/apps/backend/app/extraction/manifests.py new file mode 100644 index 00000000..d71bb1fa --- /dev/null +++ b/apps/backend/app/extraction/manifests.py @@ -0,0 +1,262 @@ +"""Observed direct-dependency extraction for the #91 resolver. + +The legacy intelligence engine reads manifests from a working directory. This +extractor instead receives the immutable source bytes selected for a snapshot, +so its dependency observations are safe inputs to ``RelationshipResolver``. +""" + +from __future__ import annotations + +from dataclasses import dataclass +import json +import posixpath +import re +import tomllib + +from app.extraction.base import ( + ExtractedDiagnostic, + ExtractedNode, + ExtractedObservation, + ExtractionResult, + RI_SRC_MALFORMED, + RI_SEC_PATH_ESCAPE, + assign_ordinals, + build_evidence, + decode_source, + logical_line_count, +) +from app.extraction.naming import dependency_stable_key +from app.extraction.structured import ( + StructureError as _ManifestStructureError, + json_object_member_lines, + toml_project_dependency_element_lines, +) +from app.intelligence import canonical +from app.extraction.support_matrix import supported_manifest_filenames + + +SUPPORTED_MANIFEST_FILENAMES = supported_manifest_filenames() +_NPM_SECTIONS = ("dependencies", "devDependencies", "peerDependencies", "optionalDependencies") +_NPM_DEPENDENCY_TYPES = { + "dependencies": "production", + "devDependencies": "development", + "peerDependencies": "peer", + "optionalDependencies": "optional", +} + + +@dataclass(frozen=True) +class _ManifestDeclaration: + ecosystem: str + name: str + version: str | None + dependency_type: str + line: int + + +class DependencyManifestExtractor: + """Extract direct npm/PyPI declarations as observed dependency facts.""" + + name = "dependency-manifest" + version = "1.2.0" + + @property + def producer(self) -> str: + return f"{self.name}@{self.version}" + + def supports(self, path: str) -> bool: + return posixpath.basename(path) in SUPPORTED_MANIFEST_FILENAMES + + def extract(self, path: str, source: bytes) -> ExtractionResult: + text, source_diagnostic = decode_source(path, source, producer=self.producer) + if text is None: + return ExtractionResult(diagnostics=(source_diagnostic,)) + try: + normalized_path = canonical.normalize_repo_path(path) + except canonical.PathEscapeError: + return ExtractionResult( + diagnostics=( + ExtractedDiagnostic( + code=RI_SEC_PATH_ESCAPE, + category="path escape", + severity="error", + message="source path is absolute or escapes the repository root", + ), + ) + ) + + line_count = logical_line_count(text) + basename = posixpath.basename(normalized_path) + try: + if basename == "package.json": + declarations = self._npm_declarations(text) + elif basename == "pyproject.toml": + declarations = self._pyproject_declarations(text) + else: + declarations = self._requirements_declarations(text) + except (json.JSONDecodeError, tomllib.TOMLDecodeError, _ManifestStructureError): + return ExtractionResult( + diagnostics=( + ExtractedDiagnostic( + code=RI_SRC_MALFORMED, + category="malformed source", + severity="error", + message="dependency manifest could not be parsed or has an unsupported structure", + path=normalized_path, + ), + ) + ) + + nodes: list[ExtractedNode] = [] + observations: list[ExtractedObservation] = [] + diagnostics: list[ExtractedDiagnostic] = [] + for declaration in declarations: + stable_key = self._dependency_key(declaration.ecosystem, declaration.name) + evidence, diagnostic = build_evidence( + normalized_path, declaration.line, declaration.line, line_count, producer=self.producer + ) + if evidence is None: + if diagnostic is not None: + diagnostics.append(diagnostic) + continue + nodes.append( + ExtractedNode( + node_kind="dependency", + stable_key=stable_key, + name=declaration.name, + language=None, + evidence=(evidence,), + properties={ + "ecosystem": declaration.ecosystem, + "version": declaration.version, + "dependency_type": declaration.dependency_type, + "manifest_path": normalized_path, + "workspace_path": posixpath.dirname(normalized_path) or ".", + }, + ) + ) + observations.append( + ExtractedObservation( + observed_kind="dependency", + subject_kind="dependency", + subject_key=stable_key, + referent_text=declaration.name, + ordinal=0, + evidence=evidence, + ) + ) + return ExtractionResult( + nodes=tuple(nodes), + observations=assign_ordinals(observations), + diagnostics=tuple(diagnostics), + ) + + @staticmethod + def _dependency_key(ecosystem: str, name: str) -> str: + return dependency_stable_key(ecosystem, name) + + @staticmethod + def _npm_declarations(text: str) -> list[_ManifestDeclaration]: + parsed = json.loads(text) + if not isinstance(parsed, dict): + raise _ManifestStructureError("package.json root is not an object") + member_lines = json_object_member_lines(text) + declarations: list[_ManifestDeclaration] = [] + for section in _NPM_SECTIONS: + if section not in parsed: + continue + dependencies = parsed[section] + if not isinstance(dependencies, dict): + raise _ManifestStructureError(f"npm {section} is not an object") + section_lines = member_lines.get(section, {}) + for name in sorted(dependencies): + if not isinstance(dependencies[name], str): + raise _ManifestStructureError(f"npm {section} entry is not a version string") + # The line must come from this section's own key token, never a + # first substring hit that a description, script, or the package + # name could have produced elsewhere in the manifest. + line = section_lines.get(name) + if line is None: + raise _ManifestStructureError(f"npm {section} declaration line could not be located") + declarations.append( + _ManifestDeclaration( + ecosystem="npm", + name=str(name), + version=dependencies[name], + dependency_type=_NPM_DEPENDENCY_TYPES[section], + line=line, + ) + ) + return declarations + + @staticmethod + def _pyproject_declarations(text: str) -> list[_ManifestDeclaration]: + parsed = tomllib.loads(text) + project = parsed.get("project", {}) + if not isinstance(project, dict): + raise _ManifestStructureError("pyproject [project] is not a table") + values = project.get("dependencies", []) + if not isinstance(values, list): + raise _ManifestStructureError("pyproject project.dependencies is not an array") + if not values: + return [] + for value in values: + if not isinstance(value, str): + raise _ManifestStructureError("pyproject dependency entry is not a string") + element_lines = toml_project_dependency_element_lines(text) + # tomllib preserves array order, and the scanner walks the same array in + # source order, so the i-th string value pairs with the i-th line span. + if element_lines is None or len(element_lines) != len(values): + raise _ManifestStructureError("pyproject project.dependencies lines could not be located") + declarations: list[_ManifestDeclaration] = [] + for value, line in zip(values, element_lines): + name = DependencyManifestExtractor._python_requirement_name(value) + if name: + declarations.append( + _ManifestDeclaration( + ecosystem="pypi", + name=name, + version=value[len(name) :].strip() or None, + dependency_type="production", + line=line, + ) + ) + return declarations + + @staticmethod + def _requirements_declarations(text: str) -> list[_ManifestDeclaration]: + declarations: list[_ManifestDeclaration] = [] + for line_number, raw in enumerate(text.splitlines(), start=1): + # A fragment is part of a direct-reference specifier (for example + # ``package @ https://host/archive.whl#sha256=...``). Only a hash + # preceded by whitespace starts a requirements-file comment. + value = raw.strip() + if not value or value.startswith(("#", "-", ".")): + continue + value = re.sub(r"\s+#.*$", "", value).strip() + name = DependencyManifestExtractor._python_requirement_name(value) + if name: + declarations.append( + _ManifestDeclaration( + ecosystem="pypi", + name=name, + version=value[len(name) :].strip() or None, + dependency_type="production", + line=line_number, + ) + ) + return declarations + + @staticmethod + def _python_requirement_name(value: str) -> str | None: + name = re.split(r"[\[<>=!~@\s]", value, maxsplit=1)[0].strip() + return name or None + + # --- Exact declaration spans (RFC §6) ---------------------------------- + # + # Provenance must point at the real declaration, so line lookup is + # structure-aware rather than a substring search: JSON keys are located + # inside their own section object, and TOML dependency strings are read as + # ordered array elements with brackets inside strings ignored. Both scanners + # live in ``app.extraction.structured`` so the lockfile extractor reads the + # same structure the same way. diff --git a/apps/backend/app/extraction/naming.py b/apps/backend/app/extraction/naming.py new file mode 100644 index 00000000..1023ec42 --- /dev/null +++ b/apps/backend/app/extraction/naming.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +import posixpath +import re +from collections import defaultdict +from collections.abc import Sequence + +from app.intelligence import canonical + + +def dependency_stable_key(ecosystem: str, name: str) -> str: + """Build the logical ``dep::`` identity (RFC §4.3). + + This is the single place a dependency identity is derived. A direct manifest + declaration and a lockfile resolution describe *one* logical dependency, so + both producers must land on a byte-identical key or they would create two + nodes for one entity. PyPI names are folded per PEP 503 (runs of ``-``, + ``_``, and ``.`` collapse to ``-``, lowercased) so ``Foo_Bar`` declared in a + manifest and ``foo-bar`` pinned in a lockfile share one node. npm names are + already canonical and are used verbatim. + """ + + if ecosystem == "pypi": + name = re.sub(r"[-_.]+", "-", name).lower() + return canonical.normalize_stable_key("dependency", f"dep:{ecosystem}:{name}") + + +def service_stable_key(origin: str) -> str: + """Build the ``svc:`` identity for an observed outbound service. + + Identity is the normalized origin only — scheme, host, and a non-default + port. Request paths, query strings, and methods vary per call site and are + recorded on the call's own observation, so folding them into the node key + would create one "service" per URL instead of one per destination. + """ + + return canonical.normalize_stable_key("service", f"svc:{origin}") + + +def iac_resource_stable_key(manifest_path: str, resource_type: str, name: str) -> str: + """Build the ``iac:::/`` identity (RFC §4.3). + + An IaC resource is only unique within the manifest that declares it: two + Compose files may both declare a ``db`` service and they are different + resources, so the manifest path is part of the identity rather than a + property alone. + """ + + normalized = canonical.normalize_repo_path(manifest_path) + return canonical.normalize_stable_key("iac_resource", f"iac:{normalized}::{resource_type}/{name}") + + +def symbol_stable_key(path: str, scope: Sequence[str], name: str) -> str: + """Build ``::`` (RFC §4.3), path normalized.""" + + normalized = canonical.normalize_repo_path(path) + qualified = ".".join([*scope, name]) + return f"{normalized}::{qualified}" + + +def module_stable_key(path: str) -> str: + """Build the directory-scoped ``mod:`` key (RFC §4.3).""" + + directory = posixpath.dirname(canonical.normalize_repo_path(path)) + return canonical.normalize_stable_key("module", f"mod:{directory}") + + +def package_root(specifier: str, source_path: str) -> str: + """The top-level package name a module specifier would be published under. + + Shared by the resolver (matching an import against a declared dependency + node) and the review layer (deciding whether an unresolved import's + target is a recognized external package rather than a broken internal + reference) so the two never independently drift on what "the package" + means for a given specifier. + + A Python specifier is always dotted (``django.core.wsgi``, + ``.models.User`` for a relative import) — the root is everything before + the first ``.``, which is empty for a relative import, correctly ruling + it out as an external package. A JS/TS specifier is slash-separated; a + scoped package's root is its first two segments (``@scope/name``), an + unscoped or relative one's is its first (``react``, ``./util``). + """ + + if source_path.endswith(".py"): + return specifier.split(".", 1)[0] + if specifier.startswith("@"): + return "/".join(specifier.split("/")[:2]) + return specifier.split("/", 1)[0] + + +def module_name(path: str) -> str | None: + """Name a module after its directory, not the file that evidenced it. + + The key is directory-scoped, so every file in a directory must produce a + byte-identical module record — naming it after the file would make sibling + modules conflicting records for one key and the snapshot would refuse to + seal. The repository root has no meaningful short name. + """ + + directory = posixpath.dirname(canonical.normalize_repo_path(path)) + return posixpath.basename(directory) or None + + +class DiscriminatorAssigner: + """Assigns RFC §4.3 ``#`` discriminators by source order within one file. + + The first occurrence of a base symbol key is returned unchanged; each later + occurrence gets ``#2``, ``#3``, … The second return value is ``True`` when a + discriminator was appended, so the caller can emit an ``RI-KEY-DUP-SYMBOL`` + diagnostic. Instantiate one per file so counters are revision-local. + """ + + def __init__(self) -> None: + self._counts: dict[str, int] = defaultdict(int) + + def key(self, base_symbol_key: str) -> tuple[str, bool]: + self._counts[base_symbol_key] += 1 + n = self._counts[base_symbol_key] + if n == 1: + return base_symbol_key, False + return f"{base_symbol_key}#{n}", True diff --git a/apps/backend/app/extraction/pipeline.py b/apps/backend/app/extraction/pipeline.py new file mode 100644 index 00000000..3a3401de --- /dev/null +++ b/apps/backend/app/extraction/pipeline.py @@ -0,0 +1,261 @@ +"""Repository-level source policy and real extractor dispatch. + +This module is the production boundary that turns a stored repository revision +into extractor runs. It does not parse source: Python and TypeScript parsing is +delegated exclusively to their real :class:`Extractor` implementations. The +pipeline owns the repository-wide concerns that no single language extractor +can own: inventory nodes, the mandatory repository root, path normalization, +and the output-affecting file-size budget required by RFC-0001 sections 4.3, +6.2, 8.2, and 12.7. +""" + +from __future__ import annotations + +import posixpath +from collections.abc import Callable, Iterable, Iterator, Mapping, Sequence +from dataclasses import dataclass + +from app.extraction.base import ( + ExtractedDiagnostic, + ExtractedEvidence, + ExtractedNode, + ExtractionResult, + Extractor, + RI_LIMIT_SKIP, + RI_SEC_PATH_ESCAPE, + decode_source, + logical_line_count, +) +from app.intelligence import canonical + +DEFAULT_MAX_SOURCE_BYTES = 512 * 1024 + + +@dataclass(frozen=True) +class ProducedExtraction: + """One result plus the exact producer identity that emitted it.""" + + producer_name: str + producer_version: str + result: ExtractionResult + + @property + def producer(self) -> str: + return f"{self.producer_name}@{self.producer_version}" + + +class ExtractionPipeline: + """Apply repository policy, then dispatch stored bytes to real extractors.""" + + inventory_name = "repository-inventory" + inventory_version = "1.1.0" + + def __init__( + self, + extractors: Sequence[Extractor], + *, + max_source_bytes: int = DEFAULT_MAX_SOURCE_BYTES, + ) -> None: + if max_source_bytes < 1: + raise ValueError("max_source_bytes must be at least 1") + self.extractors = tuple(extractors) + self.max_source_bytes = max_source_bytes + + def run( + self, + sources: Mapping[str, bytes], + *, + check_cancelled: Callable[[], None] | None = None, + ) -> tuple[ProducedExtraction, ...]: + """Compatibility wrapper for callers that already materialize sources.""" + + inventory_nodes: list[ExtractedNode] = [] + inventory_diagnostics: list[ExtractedDiagnostic] = [] + produced: list[ProducedExtraction] = [] + for item in self.iter_run(sorted(sources.items()), check_cancelled=check_cancelled): + if item.producer_name == self.inventory_name and item.producer_version == self.inventory_version: + inventory_nodes.extend(item.result.nodes) + inventory_diagnostics.extend(item.result.diagnostics) + else: + produced.append(item) + if inventory_nodes or inventory_diagnostics: + produced.insert(0, self._inventory_result(inventory_nodes, inventory_diagnostics)) + return tuple(produced) + + def iter_run( + self, + sources: Iterable[tuple[str, bytes]], + *, + check_cancelled: Callable[[], None] | None = None, + ) -> Iterator[ProducedExtraction]: + """Extract a deterministic source stream without retaining repository bytes.""" + + repository_evidence: ExtractedEvidence | None = None + + for raw_path, source in sources: + if check_cancelled is not None: + check_cancelled() + inventory_nodes: list[ExtractedNode] = [] + inventory_diagnostics: list[ExtractedDiagnostic] = [] + try: + path = canonical.normalize_repo_path(raw_path) + except canonical.PathEscapeError: + inventory_diagnostics.append( + ExtractedDiagnostic( + code=RI_SEC_PATH_ESCAPE, + category="path escape", + severity="error", + message="source path is absolute or escapes the repository root", + ) + ) + yield self._inventory_result(inventory_nodes, inventory_diagnostics) + continue + + # The mandatory repository entity is an observed fact, so it needs + # valid stored-source evidence even when language extraction later + # reports a non-fatal diagnostic and emits no file-level facts. + # Establish that evidence before the parser and size-policy branches + # to keep a diagnostics-only text repository sealable. + if repository_evidence is None: + evidence = self._whole_file_evidence(path, source) + if evidence is not None: + repository_evidence = self._repo_evidence(evidence) + inventory_nodes.append( + ExtractedNode( + node_kind="repository", + stable_key="repo:root", + name="repository", + language=None, + evidence=(repository_evidence,), + ) + ) + + file_key = canonical.normalize_stable_key("file", f"file:{path}") + if len(source) > self.max_source_bytes: + inventory_diagnostics.append( + ExtractedDiagnostic( + code=RI_LIMIT_SKIP, + category="resource-limit skip", + severity="info", + message="file exceeds the configured source-size budget", + path=path, + subject=file_key, + details={ + "budgetBytes": self.max_source_bytes, + "reportedBytes": len(source), + }, + ) + ) + yield self._inventory_result(inventory_nodes, inventory_diagnostics) + continue + + matches = [extractor for extractor in self.extractors if extractor.supports(path)] + if len(matches) > 1: + raise ValueError(f"multiple extractors support {path!r}") + + if matches: + extractor = matches[0] + result = extractor.extract(path, source) + if check_cancelled is not None: + check_cancelled() + # Python emits a directory module but no file node. Inventory + # supplies that shared entity only after successful extraction; + # TypeScript already emits its own file node. + if result.nodes and not any( + node.node_kind == "file" and node.stable_key == file_key for node in result.nodes + ): + evidence = self._whole_file_evidence(path, source) + if evidence is not None: + inventory_nodes.append(self._file_node(path, evidence, self._language(path), source)) + if inventory_nodes or inventory_diagnostics: + yield self._inventory_result(inventory_nodes, inventory_diagnostics) + yield ProducedExtraction(extractor.name, extractor.version, result) + continue + + text, diagnostic = decode_source( + path, + source, + producer=f"{self.inventory_name}@{self.inventory_version}", + ) + if diagnostic is not None: + inventory_diagnostics.append( + ExtractedDiagnostic( + code=diagnostic.code, + category=diagnostic.category, + severity=diagnostic.severity, + message=diagnostic.message, + path=diagnostic.path, + span=diagnostic.span, + subject=file_key, + details=diagnostic.details, + ) + ) + yield self._inventory_result(inventory_nodes, inventory_diagnostics) + continue + assert text is not None + evidence = ExtractedEvidence( + path=path, + start_line=1, + end_line=logical_line_count(text), + logical_line_count=logical_line_count(text), + granularity="file", + ) + inventory_nodes.append(self._file_node(path, evidence, self._language(path), source)) + + yield self._inventory_result(inventory_nodes, inventory_diagnostics) + + def _inventory_result( + self, + nodes: Sequence[ExtractedNode], + diagnostics: Sequence[ExtractedDiagnostic], + ) -> ProducedExtraction: + return ProducedExtraction( + self.inventory_name, + self.inventory_version, + ExtractionResult(nodes=tuple(nodes), diagnostics=tuple(diagnostics)), + ) + + def _whole_file_evidence(self, path: str, source: bytes) -> ExtractedEvidence | None: + text, _ = decode_source( + path, + source, + producer=f"{self.inventory_name}@{self.inventory_version}", + ) + if text is None: + return None + count = logical_line_count(text) + return ExtractedEvidence(path, 1, count, count, "file") + + @staticmethod + def _repo_evidence(evidence: ExtractedEvidence) -> ExtractedEvidence: + return ExtractedEvidence( + path=evidence.path, + start_line=1, + end_line=1, + logical_line_count=evidence.logical_line_count, + granularity="span", + ) + + @staticmethod + def _language(path: str) -> str | None: + if path.endswith(".py"): + return "python" + if path.endswith((".ts", ".tsx")): + return "typescript" + return None + + @staticmethod + def _file_node( + path: str, + evidence: ExtractedEvidence, + language: str | None, + source: bytes, + ) -> ExtractedNode: + return ExtractedNode( + node_kind="file", + stable_key=canonical.normalize_stable_key("file", f"file:{path}"), + name=posixpath.basename(path), + language=language, + evidence=(evidence,), + properties={"content_sha256": canonical.sha256_prefixed(source)}, + ) diff --git a/apps/backend/app/extraction/python.py b/apps/backend/app/extraction/python.py new file mode 100644 index 00000000..207ad6fc --- /dev/null +++ b/apps/backend/app/extraction/python.py @@ -0,0 +1,1160 @@ +from __future__ import annotations + +import ast +import builtins + +from app.extraction.http import ( + HTTP_METHOD_ATTRIBUTES, + describe_destination, + describe_destination_from_literal_prefix, +) +from app.extraction.base import ( + ExtractedDiagnostic, + ExtractedNode, + ExtractedObservation, + ExtractionResult, + RI_EXT_UNSUPPORTED, + RI_KEY_DUP_SYMBOL, + RI_SEC_PATH_ESCAPE, + RI_SRC_MALFORMED, + assign_ordinals, + build_evidence, + decode_source, + logical_line_count, +) +from app.extraction.naming import ( + DiscriminatorAssigner, + module_name, + module_stable_key, + service_stable_key, + symbol_stable_key, +) +from app.intelligence import canonical + +_ROUTE_METHODS = {"get", "post", "put", "patch", "delete", "options", "head"} +_REFLECTION_CALLS = {"getattr", "setattr", "delattr"} +_DEPENDENCY_MARKERS = {"Depends"} + +# A bare call to a language builtin (print, len, isinstance, sorted, ...) has +# no in-repo definition to resolve to, and is not a relationship worth a +# resolver diagnostic -- it scales with every call in the file, not with real +# coverage gaps. Excludes the reflection calls below, which keep their own +# explicit "unsupported" diagnostic instead of being silently dropped. +_PYTHON_BUILTIN_NAMES = frozenset(name for name in dir(builtins) if not name.startswith("_")) + +# --- Supported HTTP client surface (#209) ----------------------------------- +# +# Deliberately narrow: a name is only an HTTP client when an import in scope +# proves it is. There is no "looks like a session" heuristic. +_HTTP_CLIENT_MODULES = {"httpx", "requests"} +# Constructors whose return value is a client/session object with the same +# method surface as the module itself. +_HTTP_CLIENT_FACTORIES = { + "httpx": {"AsyncClient", "Client"}, + "requests": {"Session"}, +} +# ``requests.request("GET", url)`` / ``client.request(method="GET", url=...)``. +_HTTP_REQUEST_ATTRIBUTE = "request" + +_BINDING_HTTP_MODULE = "http-module" +_BINDING_HTTP_CLIENT = "http-client" +_BINDING_HTTP_FUNCTION = "http-function" +_BINDING_HTTP_FACTORY = "http-factory" + +# Collectors emit this; assign_ordinals sets the RFC §6.4 value on the way out. +# It is deliberately invalid (ordinals are one-based) so a result that skipped +# assignment fails loudly rather than persisting a wrong identity. +_UNASSIGNED_ORDINAL = 0 + +_BINDING_LOCAL = "local" +_BINDING_IMPORTED = "imported" +_BINDING_IMPORTLIB_MODULE = "importlib-module" +_BINDING_IMPORTLIB_FUNCTION = "importlib-import-module" + + +class _ScopeDeclarations(ast.NodeVisitor): + """Find bindings that Python makes local for an entire function scope.""" + + def __init__(self) -> None: + self.names: set[str] = set() + self.global_names: set[str] = set() + self.nonlocal_names: set[str] = set() + + def visit_Name(self, node) -> None: + if isinstance(node.ctx, (ast.Store, ast.Del)): + self.names.add(node.id) + + def visit_Import(self, node) -> None: + for alias in node.names: + self.names.add(alias.asname or alias.name.split(".", 1)[0]) + + def visit_ImportFrom(self, node) -> None: + for alias in node.names: + if alias.name != "*": + self.names.add(alias.asname or alias.name) + + def visit_FunctionDef(self, node) -> None: + self.names.add(node.name) + + visit_AsyncFunctionDef = visit_FunctionDef + + def visit_ClassDef(self, node) -> None: + self.names.add(node.name) + + def visit_Lambda(self, node) -> None: + # A lambda has its own lexical scope. + return + + def visit_Global(self, node) -> None: + self.global_names.update(node.names) + + def visit_Nonlocal(self, node) -> None: + self.nonlocal_names.update(node.names) + + +class _BindingScope: + """The subset of Python name resolution needed by blind-spot diagnostics.""" + + def __init__( + self, + parent: _BindingScope | None = None, + *, + kind: str = "module", + bindings: dict[str, str] | None = None, + global_names: set[str] | None = None, + nonlocal_names: set[str] | None = None, + ) -> None: + self.parent = parent + self.kind = kind + self.bindings = bindings or {} + self.global_names = global_names or set() + self.nonlocal_names = nonlocal_names or set() + + def _module_scope(self) -> _BindingScope: + scope = self + while scope.parent is not None: + scope = scope.parent + return scope + + def _nonlocal_parent(self) -> _BindingScope | None: + scope = self.parent + while scope is not None and scope.kind == "class": + scope = scope.parent + return scope + + def resolve(self, name: str) -> str | None: + if name in self.global_names: + return self._module_scope().bindings.get(name) + if name in self.nonlocal_names: + parent = self._nonlocal_parent() + return parent.resolve(name) if parent is not None else None + if name in self.bindings: + return self.bindings[name] + return self.parent.resolve(name) if self.parent is not None else None + + def bind(self, name: str, binding: str) -> None: + if name in self.global_names: + self._module_scope().bindings[name] = binding + elif name in self.nonlocal_names: + parent = self._nonlocal_parent() + if parent is not None: + parent.bind(name, binding) + else: + self.bindings[name] = binding + + +class PythonExtractor: + name = "python-ast" + version = "1.1.0" + + @property + def producer(self) -> str: + return f"{self.name}@{self.version}" + + def supports(self, path: str) -> bool: + return path.endswith(".py") + + def extract(self, path: str, source: bytes) -> ExtractionResult: + text, source_diag = decode_source(path, source, producer=self.producer) + if text is None: + return ExtractionResult(diagnostics=(source_diag,)) + + try: + canonical.normalize_repo_path(path) + except canonical.PathEscapeError: + return ExtractionResult( + diagnostics=( + ExtractedDiagnostic( + code=RI_SEC_PATH_ESCAPE, + category="path escape", + severity="error", + message="source path is absolute or escapes the repository root", + path=None, + ), + ) + ) + + line_count = logical_line_count(text) + try: + tree = ast.parse(text) + except SyntaxError: + return ExtractionResult( + diagnostics=( + ExtractedDiagnostic( + code=RI_SRC_MALFORMED, + category="malformed source", + severity="error", + message="file could not be parsed as Python", + path=canonical.normalize_repo_path(path), + subject=canonical.normalize_stable_key("file", f"file:{canonical.normalize_repo_path(path)}"), + ), + ) + ) + + nodes: list[ExtractedNode] = [] + observations: list[ExtractedObservation] = [] + diagnostics: list[ExtractedDiagnostic] = [] + + module_key = module_stable_key(path) + module_ev, module_ev_diag = build_evidence( + path, 1, line_count, line_count, producer=self.producer, granularity="file" + ) + if module_ev is not None: + nodes.append( + ExtractedNode( + node_kind="module", + stable_key=module_key, + name=module_name(path), + # A module is a directory, and a directory can hold more than + # one language. Its record must be language-neutral or the + # Python and TypeScript extractors emit conflicting records + # for one key and the snapshot refuses to seal. + language=None, + evidence=(module_ev,), + ) + ) + elif module_ev_diag is not None: + diagnostics.append(module_ev_diag) + + self._collect_imports(tree, path, line_count, module_key, observations, diagnostics) + self._collect_symbols(tree, path, line_count, nodes, observations, diagnostics) + self._collect_calls(tree, path, line_count, module_key, observations, diagnostics) + self._collect_service_interactions(tree, path, line_count, module_key, nodes, observations, diagnostics) + self._collect_blind_spots(tree, path, line_count, diagnostics) + + return ExtractionResult( + nodes=tuple(nodes), + observations=assign_ordinals(observations), + diagnostics=tuple(diagnostics), + ) + + def _collect_imports(self, tree, path, line_count, module_key, observations, diagnostics) -> None: + # Only direct module-level imports can safely act as file-wide name + # bindings. A function- or block-local binding must not leak into an + # unrelated call site during downstream resolution. + module_binding_statements = { + id(statement) for statement in tree.body if isinstance(statement, (ast.Import, ast.ImportFrom)) + } + for node in ast.walk(tree): + names: list[str] = [] + bindings: list[tuple[str, str, str]] = [] + if isinstance(node, ast.Import): + names = [alias.name for alias in node.names] + elif isinstance(node, ast.ImportFrom): + level_prefix = "." * node.level + base = node.module or "" + module_specifier = f"{level_prefix}{base}" + names = [ + f"{level_prefix}{base}.{alias.name}" if base else f"{level_prefix}{alias.name}" + for alias in node.names + if alias.name != "*" + ] + if id(node) in module_binding_statements: + bindings = [ + (module_specifier, alias.name, alias.asname or alias.name) + for alias in node.names + if alias.name != "*" and module_specifier + ] + else: + continue + for name in names: + ev, diag = build_evidence( + path, + node.lineno, + node.end_lineno or node.lineno, + line_count, + producer=self.producer, + ) + if ev is None: + if diag is not None: + diagnostics.append(diag) + continue + observations.append( + ExtractedObservation( + observed_kind="import", + subject_kind="module", + subject_key=module_key, + referent_text=name, + ordinal=_UNASSIGNED_ORDINAL, + evidence=ev, + ) + ) + for specifier, imported, local in bindings: + ev, diag = build_evidence( + path, + node.lineno, + node.end_lineno or node.lineno, + line_count, + producer=self.producer, + ) + if ev is None: + if diag is not None: + diagnostics.append(diag) + continue + observations.append( + ExtractedObservation( + observed_kind="import_binding", + subject_kind="module", + subject_key=module_key, + referent_text=f"{specifier}|{imported}|{local}", + ordinal=_UNASSIGNED_ORDINAL, + evidence=ev, + ) + ) + + _DEF_TYPES = (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef) + + def _collect_symbols(self, tree, path, line_count, nodes, observations, diagnostics) -> None: + assigner = DiscriminatorAssigner() + route_ordinal = 0 + + def visit(scope: list[str], body) -> None: + nonlocal route_ordinal + for child in body: + if not isinstance(child, self._DEF_TYPES): + continue + base_key = symbol_stable_key(path, scope, child.name) + final_key, duplicate = assigner.key(base_key) + ev, diag = build_evidence( + path, + child.lineno, + child.end_lineno or child.lineno, + line_count, + producer=self.producer, + ) + if ev is None: + if diag is not None: + diagnostics.append(diag) + else: + decorator_nodes = [(self._decorator_name(d), d) for d in getattr(child, "decorator_list", [])] + decorator_nodes = [(n, d) for n, d in decorator_nodes if n] + decorators = [n for n, _ in decorator_nodes] + properties = {"decorators": decorators} if decorators else None + nodes.append( + ExtractedNode( + node_kind="symbol", + stable_key=canonical.normalize_stable_key("symbol", final_key), + name=child.name, + language="python", + evidence=(ev,), + properties=properties, + ) + ) + observations.append( + ExtractedObservation( + observed_kind="definition", + subject_kind="symbol", + subject_key=canonical.normalize_stable_key("symbol", final_key), + referent_text=None, + ordinal=_UNASSIGNED_ORDINAL, + evidence=ev, + ) + ) + # A decorator sits above `def`/`class`, so it is outside the + # symbol's own span. Give each one provenance for its source + # lines rather than only a name in `properties` (#90). + for decorator_name, decorator_node in decorator_nodes: + dec_ev, dec_diag = build_evidence( + path, + decorator_node.lineno, + decorator_node.end_lineno or decorator_node.lineno, + line_count, + producer=self.producer, + ) + if dec_ev is None: + if dec_diag is not None: + diagnostics.append(dec_diag) + continue + observations.append( + ExtractedObservation( + observed_kind="decorator", + subject_kind="symbol", + subject_key=canonical.normalize_stable_key("symbol", final_key), + referent_text=decorator_name, + ordinal=_UNASSIGNED_ORDINAL, + evidence=dec_ev, + ) + ) + for route_path, route_node in self._route_paths(child): + route_ev, route_diag = build_evidence( + path, + route_node.lineno, + route_node.end_lineno or route_node.lineno, + line_count, + producer=self.producer, + ) + if route_ev is None: + if route_diag is not None: + diagnostics.append(route_diag) + continue + # A route is a source-level declaration with an identity + # distinct from its handler. The anonymous-key form is + # explicitly revision-local and source ordered (RFC §4.3), + # while the resolver later connects it to this function. + route_ordinal += 1 + route_key = canonical.normalize_stable_key( + "symbol", + symbol_stable_key(path, [], f"(anonymous:route#{route_ordinal})"), + ) + nodes.append( + ExtractedNode( + node_kind="symbol", + stable_key=route_key, + name="route", + language="python", + evidence=(route_ev,), + properties={"route_path": route_path}, + ) + ) + observations.append( + ExtractedObservation( + observed_kind="route", + subject_kind="symbol", + subject_key=route_key, + referent_text=route_path, + ordinal=_UNASSIGNED_ORDINAL, + evidence=route_ev, + ) + ) + observations.append( + ExtractedObservation( + observed_kind="route_handler", + subject_kind="symbol", + subject_key=route_key, + referent_text=canonical.normalize_stable_key("symbol", final_key), + ordinal=_UNASSIGNED_ORDINAL, + evidence=route_ev, + ) + ) + if duplicate: + diagnostics.append( + ExtractedDiagnostic( + code=RI_KEY_DUP_SYMBOL, + category="duplicate symbol", + severity="info", + # The key lives in `subject`, the field meant for it; + # repeating it here would put source-derived text in + # `message`, which RFC §13 reserves from content. + message="duplicate symbol name resolved with a discriminator", + path=canonical.normalize_repo_path(path), + subject=canonical.normalize_stable_key("symbol", final_key), + ) + ) + visit([*scope, child.name], child.body) + + visit([], tree.body) + + def _collect_calls(self, tree, path, line_count, module_key, observations, diagnostics) -> None: + """Record direct named call occurrences for the downstream resolver. + + The extractor only records the exact call spelling and its source span. + Selecting a definition (including deciding whether a same-named symbol is + local, imported, or ambiguous) is deliberately deferred to #91. + """ + + def is_lexically_local(name: str, scope: _BindingScope) -> bool: + if name in scope.global_names: + return False + if name in scope.nonlocal_names: + parent = scope._nonlocal_parent() + return parent is not None and is_lexically_local(name, parent) + if name in scope.bindings: + return scope.kind != "module" + return scope.parent is not None and is_lexically_local(name, scope.parent) + + def emit(node: ast.Call, scope: _BindingScope) -> None: + if not isinstance(node.func, ast.Name): + return + # These direct calls are already declared unsupported by the source + # support matrix. They retain their explicit diagnostic, but must + # not become resolver input (or an observed relationship fact). + if node.func.id in {"dir", "getattr", "hasattr", "setattr", "vars"}: + return + # Skip builtins the user hasn't shadowed. ``scope.resolve`` walks + # the full chain including module scope, so a user's own + # ``def print(...)`` -- whether module-level or nested -- still + # produces a real binding and this does not skip the call. + if node.func.id in _PYTHON_BUILTIN_NAMES and scope.resolve(node.func.id) is None: + return + evidence, diagnostic = build_evidence( + path, + node.lineno, + node.end_lineno or node.lineno, + line_count, + producer=self.producer, + ) + if evidence is None: + if diagnostic is not None: + diagnostics.append(diagnostic) + return + observations.append( + ExtractedObservation( + observed_kind="call", + subject_kind="module", + subject_key=module_key, + referent_text=node.func.id, + ordinal=_UNASSIGNED_ORDINAL, + evidence=evidence, + ) + ) + if is_lexically_local(node.func.id, scope): + observations.append( + ExtractedObservation( + observed_kind="call_shadowed", + subject_kind="module", + subject_key=module_key, + referent_text=node.func.id, + ordinal=_UNASSIGNED_ORDINAL, + evidence=evidence, + ) + ) + # A ``Depends(name)`` argument is a bare reference, not itself a + # call, so the generic call-emission above never records it. This + # is the one dependency-injection idiom (#95) worth recording: the + # containing function (resolved the same way a bare ``call`` is, + # via its enclosing symbol span) injects whatever ``name`` names. + if node.func.id in _DEPENDENCY_MARKERS: + for argument in node.args: + if isinstance(argument, ast.Name): + observations.append( + ExtractedObservation( + observed_kind="injects", + subject_kind="module", + subject_key=module_key, + referent_text=argument.id, + ordinal=_UNASSIGNED_ORDINAL, + evidence=evidence, + ) + ) + + def scan_signature(node, scope: _BindingScope) -> None: + for decorator in node.decorator_list: + scan(decorator, scope) + for default in (*node.args.defaults, *node.args.kw_defaults): + if default is not None: + scan(default, scope) + for argument in ( + *node.args.posonlyargs, + *node.args.args, + *node.args.kwonlyargs, + ): + if argument.annotation is not None: + scan(argument.annotation, scope) + if node.args.vararg is not None and node.args.vararg.annotation is not None: + scan(node.args.vararg.annotation, scope) + if node.args.kwarg is not None and node.args.kwarg.annotation is not None: + scan(node.args.kwarg.annotation, scope) + if getattr(node, "returns", None) is not None: + scan(node.returns, scope) + + def class_scope(node: ast.ClassDef, parent: _BindingScope) -> _BindingScope: + declarations = _ScopeDeclarations() + for statement in node.body: + declarations.visit(statement) + return _BindingScope( + parent, + kind="class", + bindings={name: _BINDING_LOCAL for name in declarations.names}, + ) + + def scan(node, scope: _BindingScope) -> None: + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + scan_signature(node, scope) + function_scope = self._function_scope(node, self._function_parent(scope)) + for statement in node.body: + scan(statement, function_scope) + return + if isinstance(node, ast.Lambda): + for default in (*node.args.defaults, *node.args.kw_defaults): + if default is not None: + scan(default, scope) + lambda_scope = self._function_scope(node, self._function_parent(scope)) + scan(node.body, lambda_scope) + return + if isinstance(node, ast.ClassDef): + for decorator in node.decorator_list: + scan(decorator, scope) + for base in node.bases: + scan(base, scope) + for keyword in node.keywords: + scan(keyword.value, scope) + nested_scope = class_scope(node, scope) + for statement in node.body: + scan(statement, nested_scope) + return + if isinstance(node, ast.Call): + emit(node, scope) + for child in ast.iter_child_nodes(node): + scan(child, scope) + + module_scope = _BindingScope() + # A module-level def/class/import/assignment is a real, resolvable + # symbol -- pre-bind it the same way a function or class scope's own + # declarations are, so a call to it (including one that happens to + # share a builtin's name) is never mistaken for an unbound name. + module_declarations = _ScopeDeclarations() + for statement in tree.body: + module_declarations.visit(statement) + for name in module_declarations.names: + module_scope.bind(name, _BINDING_LOCAL) + for statement in tree.body: + scan(statement, module_scope) + + def _collect_service_interactions( + self, tree, path, line_count, module_key, nodes, observations, diagnostics + ) -> None: + """Record outbound HTTP call sites that syntax proves (#209). + + A call becomes a service-interaction fact only when three things are + visible in the source at once: an import that proves the receiver is a + ``requests``/``httpx`` module or a client constructed from one, a literal + HTTP method, and an absolute literal URL. Anything less — a computed URL, + a relative path, a method read from a variable, or a client name a local + binding has shadowed — produces an ``RI-EXT-UNSUPPORTED`` disclosure and + no destination fact. + + Only attribute-form calls are recognized. ``from requests import get`` + followed by ``get(url)`` is a bare identifier call that + :meth:`_collect_calls` already records as a generic ``call``; emitting a + second fact for the same call site would double-count it, so that form + is disclosed as unsupported instead. + """ + + normalized = canonical.normalize_repo_path(path) + file_subject = canonical.normalize_stable_key("file", f"file:{normalized}") + # Names an import proved are HTTP clients somewhere in the file. Used + # only to tell "shadowed client" apart from "unrelated local name", so a + # genuine blind spot is reported and ordinary code stays quiet. + client_names: set[str] = set() + + def flag(node, message: str) -> None: + diagnostics.append( + ExtractedDiagnostic( + code=RI_EXT_UNSUPPORTED, + category="unsupported construct", + severity="info", + # Names the construct only. A URL argument can carry tokens + # or internal hostnames, and RFC §13 keeps repository content + # out of diagnostic text; the span says where to look. + message=message, + path=normalized, + span=(node.lineno, node.end_lineno or node.lineno), + subject=file_subject, + ) + ) + + def literal_string(node) -> str | None: + if isinstance(node, ast.Constant) and isinstance(node.value, str): + return node.value + return None + + def leading_fstring_literal(node) -> str | None: + """The literal text an f-string opens with, up to its first + interpolation -- or ``None`` if it isn't an f-string, is empty, or + starts with one (``f"{x}..."``, where there is no leading literal + text at all). + """ + + if not isinstance(node, ast.JoinedStr) or not node.values: + return None + first = node.values[0] + if not isinstance(first, ast.Constant) or not isinstance(first.value, str): + return None + return first.value + + def keyword_value(call: ast.Call, name: str): + for keyword in call.keywords: + if keyword.arg == name: + return keyword.value + return None + + def bind_imports(node, scope: _BindingScope) -> None: + if isinstance(node, ast.Import): + for alias in node.names: + root = alias.name.split(".", 1)[0] + local = alias.asname or root + if alias.name in _HTTP_CLIENT_MODULES or (alias.asname is None and root in _HTTP_CLIENT_MODULES): + scope.bind(local, f"{_BINDING_HTTP_MODULE}:{root}") + client_names.add(local) + else: + scope.bind(local, _BINDING_LOCAL) + return + module = node.module or "" + if node.level or module not in _HTTP_CLIENT_MODULES: + for alias in node.names: + if alias.name != "*": + scope.bind(alias.asname or alias.name, _BINDING_LOCAL) + return + for alias in node.names: + if alias.name == "*": + continue + local = alias.asname or alias.name + if alias.name in _HTTP_CLIENT_FACTORIES[module]: + scope.bind(local, f"{_BINDING_HTTP_FACTORY}:{module}") + elif alias.name in HTTP_METHOD_ATTRIBUTES or alias.name == _HTTP_REQUEST_ATTRIBUTE: + scope.bind(local, _BINDING_HTTP_FUNCTION) + client_names.add(local) + else: + scope.bind(local, _BINDING_LOCAL) + + def constructs_client(value, scope: _BindingScope) -> bool: + """True when ``value`` is a direct ``Session()``/``Client()`` call.""" + + if not isinstance(value, ast.Call): + return False + function = value.func + if isinstance(function, ast.Attribute) and isinstance(function.value, ast.Name): + binding = scope.resolve(function.value.id) or "" + module, _, library = binding.partition(":") + return module == _BINDING_HTTP_MODULE and function.attr in _HTTP_CLIENT_FACTORIES.get(library, ()) + if isinstance(function, ast.Name): + binding = scope.resolve(function.id) or "" + return binding.split(":", 1)[0] == _BINDING_HTTP_FACTORY + return False + + def bind_assignment(targets, value, scope: _BindingScope) -> None: + for target in targets: + for name in self._target_names(target): + if constructs_client(value, scope): + scope.bind(name, _BINDING_HTTP_CLIENT) + client_names.add(name) + else: + scope.bind(name, _BINDING_LOCAL) + + def emit_destination(call: ast.Call, destination) -> None: + evidence, diagnostic = build_evidence( + path, + call.lineno, + call.end_lineno or call.lineno, + line_count, + producer=self.producer, + ) + if evidence is None: + if diagnostic is not None: + diagnostics.append(diagnostic) + return + service_key = service_stable_key(destination.origin) + nodes.append( + ExtractedNode( + node_kind="service", + stable_key=service_key, + name=destination.origin, + # A destination is not a source language; several languages in + # one repository can call the same origin and must produce a + # byte-identical record or the snapshot refuses to seal. + language=None, + evidence=(evidence,), + properties={"origin": destination.origin}, + ) + ) + observations.append( + ExtractedObservation( + observed_kind="http_call", + subject_kind="module", + subject_key=module_key, + referent_text=destination.referent_text, + ordinal=_UNASSIGNED_ORDINAL, + evidence=evidence, + ) + ) + + def resolve_destination(call: ast.Call, method: str, url_node) -> None: + url = literal_string(url_node) if url_node is not None else None + if url is not None: + destination = describe_destination(method, url) + if destination is None: + flag(call, "HTTP destination without an absolute http(s) URL is unsupported") + return + emit_destination(call, destination) + return + # The URL wasn't a plain literal -- an f-string still proves the + # origin when everything up to its first interpolation is literal + # text and that text already closes off the authority (#408). + prefix = leading_fstring_literal(url_node) if url_node is not None else None + if prefix is not None: + destination = describe_destination_from_literal_prefix(method, prefix) + if destination is not None: + emit_destination(call, destination) + return + flag(call, "dynamic HTTP destination is unsupported") + + def visit_client_call(call: ast.Call, attribute: str) -> None: + if attribute in HTTP_METHOD_ATTRIBUTES: + url_node = call.args[0] if call.args else keyword_value(call, "url") + method: str | None = attribute + elif attribute == _HTTP_REQUEST_ATTRIBUTE: + method_node = call.args[0] if call.args else keyword_value(call, "method") + method = literal_string(method_node) if method_node is not None else None + if method is None: + flag(call, "computed HTTP method is unsupported") + return + url_node = call.args[1] if len(call.args) > 1 else keyword_value(call, "url") + else: + # ``requests.codes``, ``client.close()``, and friends are not + # request call sites at all. + return + resolve_destination(call, method, url_node) + + def visit_call(call: ast.Call, scope: _BindingScope) -> None: + function = call.func + if isinstance(function, ast.Name): + if scope.resolve(function.id) == _BINDING_HTTP_FUNCTION: + flag(call, "bare imported HTTP client function is unsupported") + elif function.id in client_names and scope.resolve(function.id) == _BINDING_LOCAL: + flag(call, "HTTP client name is shadowed by a local binding") + return + if not isinstance(function, ast.Attribute) or not isinstance(function.value, ast.Name): + return + receiver = function.value.id + binding = scope.resolve(receiver) or "" + kind = binding.split(":", 1)[0] + if kind in (_BINDING_HTTP_MODULE, _BINDING_HTTP_CLIENT): + visit_client_call(call, function.attr) + elif receiver in client_names and ( + function.attr in HTTP_METHOD_ATTRIBUTES or function.attr == _HTTP_REQUEST_ATTRIBUTE + ): + flag(call, "HTTP client name is shadowed by a local binding") + + def scan(node, scope: _BindingScope) -> None: + if isinstance(node, (ast.Import, ast.ImportFrom)): + bind_imports(node, scope) + return + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + scope.bind(node.name, _BINDING_LOCAL) + function_scope = self._function_scope(node, self._function_parent(scope)) + for statement in node.body: + scan(statement, function_scope) + return + if isinstance(node, ast.Lambda): + scan(node.body, self._function_scope(node, self._function_parent(scope))) + return + if isinstance(node, ast.ClassDef): + scope.bind(node.name, _BINDING_LOCAL) + class_scope = _BindingScope(scope, kind="class") + for statement in node.body: + scan(statement, class_scope) + return + if isinstance(node, ast.Assign): + scan(node.value, scope) + bind_assignment(node.targets, node.value, scope) + return + if isinstance(node, (ast.AnnAssign, ast.AugAssign)): + if node.value is not None: + scan(node.value, scope) + bind_assignment([node.target], node.value, scope) + return + if isinstance(node, (ast.With, ast.AsyncWith)): + for item in node.items: + scan(item.context_expr, scope) + if item.optional_vars is not None: + bind_assignment([item.optional_vars], item.context_expr, scope) + for statement in node.body: + scan(statement, scope) + return + if isinstance(node, ast.Call): + visit_call(node, scope) + for child in ast.iter_child_nodes(node): + scan(child, scope) + + module_scope = _BindingScope() + for statement in tree.body: + scan(statement, module_scope) + + def _attribute_root(self, node): + """Resolve ``a.b.c`` to its root ``Name``, or None if not name-rooted.""" + + while isinstance(node, ast.Attribute): + node = node.value + return node if isinstance(node, ast.Name) else None + + def _function_scope(self, node, parent: _BindingScope) -> _BindingScope: + declarations = _ScopeDeclarations() + body = node.body if isinstance(node.body, list) else [] + for statement in body: + declarations.visit(statement) + + arguments = node.args + parameters = [ + *(argument.arg for argument in arguments.posonlyargs), + *(argument.arg for argument in arguments.args), + *(argument.arg for argument in arguments.kwonlyargs), + ] + if arguments.vararg is not None: + parameters.append(arguments.vararg.arg) + if arguments.kwarg is not None: + parameters.append(arguments.kwarg.arg) + + local_names = (declarations.names | set(parameters)) - declarations.global_names - declarations.nonlocal_names + return _BindingScope( + parent, + kind="function", + bindings={name: _BINDING_LOCAL for name in local_names}, + global_names=declarations.global_names, + nonlocal_names=declarations.nonlocal_names, + ) + + @staticmethod + def _function_parent(scope: _BindingScope) -> _BindingScope: + # A method's unqualified names do not resolve through its class body. + while scope.kind == "class" and scope.parent is not None: + scope = scope.parent + return scope + + @staticmethod + def _assignment_attributes(target): + """Yield attributes from assignment targets, including unpacking.""" + + if isinstance(target, ast.Attribute): + yield target + elif isinstance(target, (ast.Tuple, ast.List)): + for element in target.elts: + yield from PythonExtractor._assignment_attributes(element) + elif isinstance(target, ast.Starred): + yield from PythonExtractor._assignment_attributes(target.value) + + @staticmethod + def _target_names(target): + if isinstance(target, ast.Name): + yield target.id + elif isinstance(target, (ast.Tuple, ast.List)): + for element in target.elts: + yield from PythonExtractor._target_names(element) + elif isinstance(target, ast.Starred): + yield from PythonExtractor._target_names(target.value) + + def _collect_blind_spots(self, tree, path, line_count, diagnostics) -> None: + normalized = canonical.normalize_repo_path(path) + file_subject = canonical.normalize_stable_key("file", f"file:{normalized}") + + def flag(node, message: str) -> None: + # `message` names the construct; it never quotes source. Diagnostics + # are stored and surfaced, and RFC §13 forbids embedding repository + # content or secrets in `message`/`details` — the path and span + # already say exactly where to look. + diagnostics.append( + ExtractedDiagnostic( + code=RI_EXT_UNSUPPORTED, + category="unsupported construct", + severity="info", + message=message, + path=normalized, + span=(node.lineno, node.end_lineno or node.lineno), + subject=file_subject, + ) + ) + + def bind_import(node, scope: _BindingScope) -> None: + if isinstance(node, ast.Import): + for alias in node.names: + name = alias.asname or alias.name.split(".", 1)[0] + is_importlib_module = alias.name == "importlib" or ( + alias.asname is None and alias.name.startswith("importlib.") + ) + scope.bind( + name, + _BINDING_IMPORTLIB_MODULE if is_importlib_module else _BINDING_IMPORTED, + ) + else: + for alias in node.names: + if alias.name == "*": + continue + name = alias.asname or alias.name + is_import_module = node.module == "importlib" and alias.name == "import_module" + scope.bind( + name, + _BINDING_IMPORTLIB_FUNCTION if is_import_module else _BINDING_IMPORTED, + ) + + def bind_target_names(target, scope: _BindingScope) -> None: + for name in self._target_names(target): + scope.bind(name, _BINDING_LOCAL) + + def is_imported_name(name: str, scope: _BindingScope) -> bool: + return scope.resolve(name) in { + _BINDING_IMPORTED, + _BINDING_IMPORTLIB_MODULE, + _BINDING_IMPORTLIB_FUNCTION, + } + + def flag_monkeypatch(node, targets, scope: _BindingScope) -> None: + for target in targets: + for attribute in self._assignment_attributes(target): + root = self._attribute_root(attribute) + if root is not None and is_imported_name(root.id, scope): + flag(node, "monkey-patching an imported name is unsupported") + return + + def is_dynamic_import_call(func, scope: _BindingScope) -> str | None: + if isinstance(func, ast.Name): + if func.id == "__import__": + # It is built in only while this scope has not rebound it. + return func.id if scope.resolve(func.id) is None else None + if scope.resolve(func.id) == _BINDING_IMPORTLIB_FUNCTION: + return "import_module" + elif ( + isinstance(func, ast.Attribute) + and func.attr == "import_module" + and isinstance(func.value, ast.Name) + and scope.resolve(func.value.id) == _BINDING_IMPORTLIB_MODULE + ): + return "import_module" + return None + + def scan_function_signature(node, scope: _BindingScope) -> None: + for decorator in node.decorator_list: + scan(decorator, scope) + for default in (*node.args.defaults, *node.args.kw_defaults): + if default is not None: + scan(default, scope) + for argument in ( + *node.args.posonlyargs, + *node.args.args, + *node.args.kwonlyargs, + ): + if argument.annotation is not None: + scan(argument.annotation, scope) + if node.args.vararg is not None and node.args.vararg.annotation is not None: + scan(node.args.vararg.annotation, scope) + if node.args.kwarg is not None and node.args.kwarg.annotation is not None: + scan(node.args.kwarg.annotation, scope) + if node.returns is not None: + scan(node.returns, scope) + + def scan(node, scope: _BindingScope) -> None: + if isinstance(node, ast.ImportFrom): + if any(alias.name == "*" for alias in node.names): + flag(node, "star-import is unsupported") + bind_import(node, scope) + elif isinstance(node, ast.Import): + bind_import(node, scope) + elif isinstance(node, ast.ClassDef): + if any(keyword.arg == "metaclass" for keyword in node.keywords): + # The class itself is still extracted; what a metaclass does to it + # at runtime is not modelled, so say so rather than imply we know. + flag(node, "metaclass is unsupported") + for decorator in node.decorator_list: + scan(decorator, scope) + for base in node.bases: + scan(base, scope) + for keyword in node.keywords: + scan(keyword.value, scope) + scope.bind(node.name, _BINDING_LOCAL) + class_scope = _BindingScope(scope, kind="class") + for statement in node.body: + scan(statement, class_scope) + elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + scan_function_signature(node, scope) + scope.bind(node.name, _BINDING_LOCAL) + function_scope = self._function_scope(node, self._function_parent(scope)) + for statement in node.body: + scan(statement, function_scope) + elif isinstance(node, ast.Lambda): + for default in (*node.args.defaults, *node.args.kw_defaults): + if default is not None: + scan(default, scope) + lambda_scope = self._function_scope(node, self._function_parent(scope)) + scan(node.body, lambda_scope) + elif isinstance(node, ast.Assign): + scan(node.value, scope) + flag_monkeypatch(node, node.targets, scope) + for target in node.targets: + bind_target_names(target, scope) + elif isinstance(node, ast.AugAssign): + scan(node.value, scope) + flag_monkeypatch(node, [node.target], scope) + bind_target_names(node.target, scope) + elif isinstance(node, ast.AnnAssign): + if node.annotation is not None: + scan(node.annotation, scope) + if node.value is not None: + scan(node.value, scope) + flag_monkeypatch(node, [node.target], scope) + bind_target_names(node.target, scope) + elif isinstance(node, (ast.For, ast.AsyncFor)): + scan(node.iter, scope) + flag_monkeypatch(node, [node.target], scope) + bind_target_names(node.target, scope) + for statement in node.body: + scan(statement, scope) + for statement in node.orelse: + scan(statement, scope) + elif isinstance(node, (ast.With, ast.AsyncWith)): + for item in node.items: + scan(item.context_expr, scope) + if item.optional_vars is not None: + flag_monkeypatch(node, [item.optional_vars], scope) + bind_target_names(item.optional_vars, scope) + for statement in node.body: + scan(statement, scope) + elif isinstance(node, ast.ExceptHandler): + if node.type is not None: + scan(node.type, scope) + if node.name is not None: + scope.bind(node.name, _BINDING_LOCAL) + for statement in node.body: + scan(statement, scope) + elif isinstance(node, ast.NamedExpr): + scan(node.value, scope) + bind_target_names(node.target, scope) + elif isinstance(node, ast.Call): + func = node.func + # These names come from this module's own closed vocabulary, not + # from arbitrary source text, so naming them leaks nothing. + if isinstance(func, ast.Name) and func.id in _REFLECTION_CALLS: + flag(node, f"reflection via {func.id}() is unsupported") + else: + dynamic_import = is_dynamic_import_call(func, scope) + if dynamic_import is not None: + flag(node, f"dynamic import via {dynamic_import}() is unsupported") + for child in ast.iter_child_nodes(node): + scan(child, scope) + else: + for child in ast.iter_child_nodes(node): + scan(child, scope) + + module_scope = _BindingScope() + for statement in tree.body: + scan(statement, module_scope) + + def _decorator_name(self, decorator) -> str | None: + target = decorator.func if isinstance(decorator, ast.Call) else decorator + parts: list[str] = [] + while isinstance(target, ast.Attribute): + parts.append(target.attr) + target = target.value + if isinstance(target, ast.Name): + parts.append(target.id) + return ".".join(reversed(parts)) if parts else None + + def _route_paths(self, symbol): + for decorator in getattr(symbol, "decorator_list", []): + if not isinstance(decorator, ast.Call) or not isinstance(decorator.func, ast.Attribute): + continue + if decorator.func.attr not in _ROUTE_METHODS: + continue + if ( + decorator.args + and isinstance(decorator.args[0], ast.Constant) + and isinstance(decorator.args[0].value, str) + ): + yield decorator.args[0].value, decorator diff --git a/apps/backend/app/extraction/structured.py b/apps/backend/app/extraction/structured.py new file mode 100644 index 00000000..52c83aae --- /dev/null +++ b/apps/backend/app/extraction/structured.py @@ -0,0 +1,292 @@ +"""Structure-aware source scanners shared by the manifest-family extractors. + +Provenance must point at the *real* declaration, so every extractor that reads a +structured configuration file needs the exact source line of a decoded value. +Neither :mod:`json` nor :mod:`tomllib` reports line numbers, and a substring +search would happily match a description, a script, or a package name elsewhere +in the file. + +These scanners are therefore the single implementation of "where in the source +did this decoded value come from" for ``package.json``, ``pyproject.toml``, +``requirements.txt``, ``package-lock.json``, and ``poetry.lock``. They run +*after* the real decoder has accepted the text, so they locate structure rather +than re-validate it, and they are string-aware: braces, brackets, and ``#`` +inside a string literal never affect nesting. + +Adding a second copy of this logic inside another extractor is the change most +likely to make two producers disagree about the same file. +""" + +from __future__ import annotations + +import json +import re + + +class StructureError(Exception): + """A file decoded cleanly but is not a supported structure. + + Raised when a root, a section, or an individual entry has an unexpected + type, or when an exact declaration line cannot be located. Callers catch it + alongside the decoder errors so every such case fails closed as + ``RI-SRC-MALFORMED`` rather than escaping as an ``AttributeError`` or + degrading to a silent empty result. + """ + + +class JsonNode: + """A parsed JSON value carrying the source line of each object key. + + ``value`` is a ``dict`` of ``{key: JsonNode}`` for objects, a ``list`` of + ``JsonNode`` for arrays, or the decoded scalar otherwise. ``key_lines`` maps + an object's own keys to their 1-based declaration line. + """ + + __slots__ = ("value", "key_lines", "line") + + def __init__(self, value: object, key_lines: dict[str, int], line: int | None = None) -> None: + self.value = value + self.key_lines = key_lines + self.line = line + + +# --- JSON ------------------------------------------------------------------- + + +def json_object_member_lines(text: str) -> dict[str, dict[str, int]]: + """Map ``{section: {member_key: line}}`` for each object-valued member. + + Only the top-level object and its object-valued members are indexed — the + npm dependency sections and ``package-lock.json``'s ``packages`` table. + ``text`` has already parsed as JSON (``json.loads`` gates malformed input), + so the scan is string-aware and does not re-validate. + """ + + tokens = json_tokens(text) + root, _ = _json_parse(tokens, 0) + result: dict[str, dict[str, int]] = {} + if isinstance(root.value, dict): + for key, child in root.value.items(): + if isinstance(child.value, dict): + result[key] = dict(child.key_lines) + return result + + +def json_tokens(text: str) -> list[tuple[str, object, int]]: + """Tokenize valid JSON into ``(kind, value, line)`` triples. + + ``kind`` is ``"str"`` (decoded string), ``"punct"`` (one of ``{}[]:,``), or + ``"other"`` (number/true/false/null). Line numbers are 1-based. + """ + + tokens: list[tuple[str, object, int]] = [] + i, n, line = 0, len(text), 1 + while i < n: + c = text[i] + if c == "\n": + line += 1 + i += 1 + elif c in " \t\r": + i += 1 + elif c == '"': + start, start_line, j = i, line, i + 1 + while j < n: + if text[j] == "\\": + j += 2 + continue + if text[j] == '"': + j += 1 + break + if text[j] == "\n": + line += 1 + j += 1 + try: + value: object = json.loads(text[start:j]) + except json.JSONDecodeError: + value = text[start + 1 : max(start + 1, j - 1)] + tokens.append(("str", value, start_line)) + i = j + elif c in "{}[]:,": + tokens.append(("punct", c, line)) + i += 1 + else: + start = i + while i < n and text[i] not in ' \t\r\n{}[]:,"': + i += 1 + tokens.append(("other", text[start:i], line)) + return tokens + + +def _json_parse(tokens: list[tuple[str, object, int]], pos: int) -> tuple[JsonNode, int]: + kind, value, line = tokens[pos] + if kind == "punct" and value == "{": + pos += 1 + members: dict[str, JsonNode] = {} + key_lines: dict[str, int] = {} + while not (tokens[pos][0] == "punct" and tokens[pos][1] == "}"): + key_value, key_line = tokens[pos][1], tokens[pos][2] + pos += 2 # consume the key string and its ':' + child, pos = _json_parse(tokens, pos) + if isinstance(key_value, str): + members[key_value] = child + key_lines[key_value] = key_line + if tokens[pos][0] == "punct" and tokens[pos][1] == ",": + pos += 1 + return JsonNode(members, key_lines), pos + 1 + if kind == "punct" and value == "[": + pos += 1 + items: list[JsonNode] = [] + while not (tokens[pos][0] == "punct" and tokens[pos][1] == "]"): + child, pos = _json_parse(tokens, pos) + items.append(child) + if tokens[pos][0] == "punct" and tokens[pos][1] == ",": + pos += 1 + return JsonNode(items, {}), pos + 1 + return JsonNode(value, {}, line), pos + 1 + + +# --- TOML ------------------------------------------------------------------- + + +def toml_skip_string(text: str, i: int, line: int) -> tuple[int, int]: + """Advance past a TOML basic/literal string (single or triple quoted).""" + + quote = text[i] + triple = text[i : i + 3] in ('"""', "'''") + if triple: + delimiter = text[i : i + 3] + j = i + 3 + while j < len(text) and text[j : j + 3] != delimiter: + if text[j] == "\n": + line += 1 + j += 1 + return j + 3, line + literal = quote == "'" + j = i + 1 + while j < len(text): + if not literal and text[j] == "\\": + j += 2 + continue + if text[j] == quote: + j += 1 + break + if text[j] == "\n": + line += 1 + j += 1 + return j, line + + +def toml_array_of_tables_lines(text: str, name: str) -> list[int]: + """Return the 1-based line of each ``[[name]]`` array-of-tables header. + + Headers are returned in source order, so they pair positionally with the + list ``tomllib`` parsed for that key. The scan is string- and comment-aware: + a ``[[package]]`` sequence inside a triple-quoted description or after a + ``#`` never counts, and ``[[package.files]]`` is not ``[[package]]``. + """ + + lines: list[int] = [] + i, n, line = 0, len(text), 1 + at_line_start = True + while i < n: + c = text[i] + if c == "\n": + line += 1 + i += 1 + at_line_start = True + continue + if c in " \t\r": + i += 1 + continue + if c == "#": + while i < n and text[i] != "\n": + i += 1 + continue + if c in "\"'": + i, line = toml_skip_string(text, i, line) + at_line_start = False + continue + if at_line_start and text.startswith("[[", i): + end = text.find("]]", i + 2) + newline = text.find("\n", i) + if end >= 0 and (newline < 0 or end < newline) and text[i + 2 : end].strip().strip("\"'") == name: + lines.append(line) + i = end + 2 + at_line_start = False + continue + at_line_start = False + i += 1 + return lines + + +def toml_project_dependency_element_lines(text: str) -> list[int] | None: + """Return the source line of each ``[project].dependencies`` array element. + + Elements are returned in source order so they pair positionally with the + values ``tomllib`` parsed. Scanning is string-aware: ``[`` / ``]`` inside a + dependency string (``"httpx[socks]"``) do not change array nesting, and both + inline and multi-line arrays are supported. Returns ``None`` when the array + cannot be located, so the caller fails closed instead of guessing. + """ + + start = _toml_dependencies_offset(text) + if start is None: + return None + offset, line = start + n = len(text) + depth = 0 + entered = False + element_lines: list[int] = [] + i = offset + while i < n: + c = text[i] + if c == "\n": + line += 1 + i += 1 + elif c == "#": + while i < n and text[i] != "\n": + i += 1 + elif c == "[": + depth += 1 + entered = True + i += 1 + elif c == "]": + depth -= 1 + i += 1 + if depth == 0: + break + elif c in "\"'" and depth == 1: + element_lines.append(line) + i, line = toml_skip_string(text, i, line) + elif c in "\"'": + i, line = toml_skip_string(text, i, line) + else: + i += 1 + if not entered: + return None + return element_lines + + +def _toml_dependencies_offset(text: str) -> tuple[int, int] | None: + """Find the offset/line just past the ``project.dependencies`` ``=``. + + Tracks the active TOML table so only the ``[project]`` table's + ``dependencies`` key (or a top-level ``project.dependencies`` dotted key) is + matched, never ``[tool.poetry.dependencies]`` or an unrelated table. + """ + + offset = 0 + current_table = "" + for raw in text.split("\n"): + stripped = raw.strip() + header = re.match(r"\[\[?\s*(.+?)\s*\]\]?\s*(?:#.*)?$", stripped) + if header and not stripped.startswith("#"): + current_table = header.group(1).strip().strip("\"'") + else: + key = re.match(r"(?:(project)\s*\.\s*)?dependencies\s*=", stripped) + if key is not None and (key.group(1) == "project" or current_table == "project"): + equals = raw.index("=", raw.find("dependencies")) + line = text.count("\n", 0, offset) + 1 + return offset + equals + 1, line + offset += len(raw) + 1 + return None diff --git a/apps/backend/app/extraction/support_matrix.py b/apps/backend/app/extraction/support_matrix.py new file mode 100644 index 00000000..7b51a4bd --- /dev/null +++ b/apps/backend/app/extraction/support_matrix.py @@ -0,0 +1,1087 @@ +"""The authoritative, typed capability registry for Repository Intelligence. + +The registry deliberately lives beside the production extractors. Benchmark +taxonomy is allowed to describe fixture variants, but it does not decide what +the product supports. ``SUPPORT_MATRIX`` remains as a compatibility view for +existing extractor tests and callers; its contents are derived from the +registry below. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import StrEnum +from pathlib import Path +from typing import Iterable + + +REGISTRY_SCHEMA_VERSION = "ri-capability-registry.v1" +README_CAPABILITIES_START = "" +README_CAPABILITIES_END = "" + + +class SupportStatus(StrEnum): + SUPPORTED = "supported" + UNSUPPORTED = "unsupported" + LIMITED = "limited" + PARTIAL = "partial" + NOT_ASSESSED = "not_assessed" + + +class PublicStatus(StrEnum): + """Roadmap truth-boundary vocabulary used by public product claims.""" + + IMPLEMENTED = "Implemented" + IMPLEMENTED_WITH_DISCLOSED_LIMITS = "Implemented with disclosed limits" + PLANNED = "Planned" + REJECTED = "Rejected" + + +@dataclass(frozen=True) +class Capability: + """A production construct or explicitly disclosed production limitation.""" + + id: str + language: str + construct: str + status: SupportStatus + description: str + limitation: str + benchmark_ids: tuple[str, ...] = () + benchmark_disclosure: str | None = None + expected_diagnostic: str | None = None + + +@dataclass(frozen=True) +class PublicCapability: + """One row in README's public capability assessment.""" + + id: str + name: str + status: PublicStatus + statement: str + capability_ids: tuple[str, ...] + + +def _capability( + id: str, + language: str, + construct: str, + status: SupportStatus, + description: str, + limitation: str, + *benchmark_ids: str, + benchmark_disclosure: str | None = None, + expected_diagnostic: str | None = None, +) -> Capability: + return Capability( + id=id, + language=language, + construct=construct, + status=status, + description=description, + limitation=limitation, + benchmark_ids=benchmark_ids, + benchmark_disclosure=benchmark_disclosure, + expected_diagnostic=expected_diagnostic, + ) + + +# This is the only hand-maintained construct/status declaration. Keep entries +# sorted by stable id so registry serialization and all derived views are byte- +# stable. +CONSTRUCT_CAPABILITIES: tuple[Capability, ...] = ( + _capability( + "python.class", + "python", + "class", + SupportStatus.SUPPORTED, + "Class definitions.", + "Base-class semantics and metaclass behavior are not inferred.", + "py.class.def", + ), + _capability( + "python.decorator", + "python", + "decorator", + SupportStatus.SUPPORTED, + "Decorator observations and symbol properties.", + "Only syntax-visible decorator names are recorded; runtime decorator semantics are not evaluated.", + "py.decorator", + ), + _capability( + "python.dynamic-import", + "python", + "dynamic-import", + SupportStatus.UNSUPPORTED, + "Dynamic imports.", + "Dynamic import targets are not resolved.", + "py.dynamic_import", + expected_diagnostic="RI-EXT-UNSUPPORTED", + ), + _capability( + "python.function", + "python", + "function", + SupportStatus.SUPPORTED, + "Function definitions, including async and nested definitions.", + "Signatures and runtime-generated functions are not modelled.", + "py.function.def", + "py.async_function.def", + "py.nested_function", + "py.duplicate_symbol", + ), + _capability( + "python.http-client", + "python", + "http-client", + SupportStatus.SUPPORTED, + "Outbound HTTP call sites on requests/httpx attribute calls, including module aliases and client/session objects.", + ( + "Only attribute-form calls on a module-level requests/httpx binding, or on a local name assigned directly " + "from requests.Session()/httpx.Client()/httpx.AsyncClient(), are recognized. Both the method and an " + "absolute literal URL must be syntax-visible; nothing is inferred from configuration, base URLs, or " + "runtime values." + ), + "py.http_requests", + "py.http_httpx", + "py.http_session", + ), + _capability( + "python.http-dynamic-destination", + "python", + "http-dynamic-destination", + SupportStatus.UNSUPPORTED, + "HTTP call sites whose method or destination is not a syntax-visible absolute literal.", + "Computed URLs, f-strings, relative paths, computed methods, shadowed client names, and bare imported client functions produce a diagnostic and no destination fact.", + "py.http_dynamic", + expected_diagnostic="RI-EXT-UNSUPPORTED", + ), + _capability( + "python.import", + "python", + "import", + SupportStatus.SUPPORTED, + "Python import and from-import observations.", + "Resolution is deliberately conservative and does not execute imports.", + "py.import", + "py.import_alias", + "py.from_import", + ), + _capability( + "python.metaclass", + "python", + "metaclass", + SupportStatus.UNSUPPORTED, + "Metaclass declarations.", + "Metaclass runtime behavior is not modelled.", + "py.metaclass", + expected_diagnostic="RI-EXT-UNSUPPORTED", + ), + _capability( + "python.method", + "python", + "method", + SupportStatus.SUPPORTED, + "Methods defined inside classes.", + "Dynamic attributes and runtime method replacement are not modelled.", + "py.method.def", + ), + _capability( + "python.module", + "python", + "module", + SupportStatus.SUPPORTED, + "Directory-scoped Python module nodes.", + "Module identity is path-based.", + "py.module", + ), + _capability( + "python.monkeypatch", + "python", + "monkeypatch", + SupportStatus.UNSUPPORTED, + "Assignments that monkey-patch imported names.", + "Imported-name rebinding is reported but not interpreted as a stable relationship.", + "py.monkeypatch", + expected_diagnostic="RI-EXT-UNSUPPORTED", + ), + _capability( + "python.reflection", + "python", + "reflection", + SupportStatus.UNSUPPORTED, + "Reflection calls such as getattr().", + "Reflection targets are not resolved.", + "py.reflection", + expected_diagnostic="RI-EXT-UNSUPPORTED", + ), + _capability( + "python.route", + "python", + "route", + SupportStatus.SUPPORTED, + "Literal route paths on supported decorator forms.", + "The route contract covers syntax-visible literal paths only.", + "py.fastapi_route", + ), + _capability( + "python.star-import", + "python", + "star-import", + SupportStatus.UNSUPPORTED, + "Wildcard imports.", + "Star-import bindings are not expanded.", + "py.star_import", + expected_diagnostic="RI-EXT-UNSUPPORTED", + ), + _capability( + "source.binary-file", + "source", + "binary-file", + SupportStatus.UNSUPPORTED, + "NUL-containing source files.", + "Binary files do not receive source line evidence.", + "src.binary_file", + expected_diagnostic="RI-SRC-BINARY", + ), + _capability( + "source.empty-file", + "source", + "empty-file", + SupportStatus.SUPPORTED, + "Empty text files with one logical line.", + "Empty files have whole-file evidence at logical line 1.", + "src.empty_file", + ), + _capability( + "source.file", + "source", + "file", + SupportStatus.SUPPORTED, + "Repository inventory file nodes.", + "Inventory is not semantic extraction for unsupported languages.", + "src.file", + ), + _capability( + "source.large-file", + "source", + "large-file", + SupportStatus.UNSUPPORTED, + "Files above the configured source budget.", + "Oversized source is skipped rather than retained for extraction.", + "src.large_file", + expected_diagnostic="RI-LIMIT-SKIP", + ), + _capability( + "source.malformed-source", + "source", + "malformed-source", + SupportStatus.UNSUPPORTED, + "Undecodable or malformed source.", + "Malformed input produces a diagnostic and no fabricated facts.", + "py.syntax_error", + "ts.syntax_error", + "src.malformed_source", + expected_diagnostic="RI-SRC-MALFORMED", + ), + _capability( + "source.path-escape", + "source", + "path-escape", + SupportStatus.UNSUPPORTED, + "Paths that are absolute or escape the repository root.", + "Unsafe paths are rejected before extraction.", + "src.path_escape", + expected_diagnostic="RI-SEC-PATH-ESCAPE", + ), + _capability( + "source.repository", + "source", + "repository", + SupportStatus.SUPPORTED, + "The repository root inventory node.", + "The node identifies the analyzed revision; it is not a source-language claim.", + "src.repository", + ), + _capability( + "source.trailing-newline", + "source", + "trailing-newline", + SupportStatus.SUPPORTED, + "Logical line accounting for trailing newlines.", + "Line accounting follows the ri.v1 empty/trailing-newline rules.", + "src.trailing_newline", + ), + _capability( + "typescript.ambient-module", + "typescript", + "ambient-module", + SupportStatus.UNSUPPORTED, + "Ambient module declarations.", + "Ambient module semantics are not extracted.", + "ts.ambient_module", + expected_diagnostic="RI-EXT-UNSUPPORTED", + ), + _capability( + "typescript.class", + "typescript", + "class", + SupportStatus.SUPPORTED, + "TypeScript class declarations.", + "Type information and runtime inheritance are not inferred beyond emitted syntax facts.", + "ts.class", + ), + _capability( + "typescript.commonjs-require", + "typescript", + "commonjs-require", + SupportStatus.UNSUPPORTED, + "CommonJS require() calls.", + "CommonJS binding semantics are not resolved by the TypeScript extractor.", + "ts.commonjs_require", + expected_diagnostic="RI-EXT-UNSUPPORTED", + ), + _capability( + "typescript.const", + "typescript", + "const", + SupportStatus.SUPPORTED, + "Top-level const bindings.", + "Only the supported declaration shape is emitted.", + "ts.const", + ), + _capability( + "typescript.decorator", + "typescript", + "decorator", + SupportStatus.UNSUPPORTED, + "TypeScript decorators.", + "Decorator runtime semantics are not extracted.", + "ts.decorator", + expected_diagnostic="RI-EXT-UNSUPPORTED", + ), + _capability( + "typescript.dynamic-import", + "typescript", + "dynamic-import", + SupportStatus.UNSUPPORTED, + "Dynamic import expressions.", + "Dynamic import targets are not resolved.", + "ts.dynamic_import", + expected_diagnostic="RI-EXT-UNSUPPORTED", + ), + _capability( + "typescript.enum", + "typescript", + "enum", + SupportStatus.SUPPORTED, + "Enum declarations.", + "Enum runtime expansion is not inferred.", + "ts.enum", + ), + _capability( + "typescript.export", + "typescript", + "export", + SupportStatus.SUPPORTED, + "Export observations.", + "Exports are recorded syntactically without module execution.", + "ts.export", + "ts.reexport", + ), + _capability( + "typescript.file", + "typescript", + "file", + SupportStatus.SUPPORTED, + "TypeScript file nodes.", + "File nodes carry inventory and language facts; semantic extraction remains construct-specific.", + "ts.module", + ), + _capability( + "typescript.function", + "typescript", + "function", + SupportStatus.SUPPORTED, + "Function declarations, including async functions.", + "Type inference and runtime-generated functions are not modelled.", + "ts.function", + "ts.async_function", + ), + _capability( + "typescript.http-client", + "typescript", + "http-client", + SupportStatus.SUPPORTED, + "Outbound HTTP call sites on fetch and axios, including a default-import axios alias.", + ( + "Only a global fetch() call or an attribute call on a module-level axios default import is recognized. " + "The destination must be an absolute literal URL, and the method must be either fetch's specified GET " + "default, a literal 'method' in an inline init object, or the axios method name." + ), + "ts.http_fetch", + "ts.http_axios", + ), + _capability( + "typescript.http-dynamic-destination", + "typescript", + "http-dynamic-destination", + SupportStatus.UNSUPPORTED, + "HTTP call sites whose method or destination is not a syntax-visible absolute literal.", + "Template literals with substitutions, variable URLs, relative paths, computed init objects, and shadowed fetch/axios bindings produce a diagnostic and no destination fact.", + "ts.http_dynamic", + expected_diagnostic="RI-EXT-UNSUPPORTED", + ), + _capability( + "typescript.import", + "typescript", + "import", + SupportStatus.SUPPORTED, + "TypeScript import observations and bindings.", + "Resolution is syntax- and snapshot-fact-based; source is not executed.", + "ts.import", + "ts.import_alias", + ), + _capability( + "typescript.interface", + "typescript", + "interface", + SupportStatus.SUPPORTED, + "Interface declarations.", + "Structural type checking is not performed.", + "ts.interface", + ), + _capability( + "typescript.method", + "typescript", + "method", + SupportStatus.SUPPORTED, + "Class methods.", + "Dynamic dispatch and runtime replacement are not modelled.", + "ts.method", + ), + _capability( + "typescript.module", + "typescript", + "module", + SupportStatus.SUPPORTED, + "Directory-scoped TypeScript module nodes.", + "Module identity is path-based.", + "ts.directory_module", + ), + _capability( + "typescript.namespace", + "typescript", + "namespace", + SupportStatus.UNSUPPORTED, + "Namespace/module declarations.", + "Namespace runtime and merge semantics are not extracted.", + "ts.namespace", + expected_diagnostic="RI-EXT-UNSUPPORTED", + ), + _capability( + "typescript.route", + "typescript", + "route", + SupportStatus.SUPPORTED, + "Literal routes in supported react-router forms.", + "Dynamic route paths and unrelated path-shaped objects are not treated as routes.", + "ts.route", + ), + _capability( + "typescript.type", + "typescript", + "type", + SupportStatus.SUPPORTED, + "Type alias declarations.", + "Type evaluation is not performed.", + "ts.type", + ), +) + + +MANIFEST_CAPABILITIES: tuple[Capability, ...] = ( + _capability( + "dependency.package-json", + "source", + "manifest:package.json", + SupportStatus.SUPPORTED, + "Direct npm declarations in package.json.", + "Only direct declarations are extracted; lockfiles, transitive resolution, vulnerability scanning, and outdated-version scanning are not implemented.", + benchmark_disclosure="No benchmark fixture currently exercises dependency-manifest extraction; production integration tests cover this extractor.", + ), + _capability( + "dependency.pyproject", + "source", + "manifest:pyproject.toml", + SupportStatus.SUPPORTED, + "Direct PyPI declarations in pyproject.toml.", + "Only supported project.dependencies declarations are extracted; transitive resolution and scanning are not implemented.", + benchmark_disclosure="No benchmark fixture currently exercises dependency-manifest extraction; production integration tests cover this extractor.", + ), + _capability( + "dependency.requirements", + "source", + "manifest:requirements.txt", + SupportStatus.SUPPORTED, + "Direct PyPI declarations in requirements.txt.", + "Only direct requirement lines are extracted; transitive resolution and scanning are not implemented.", + benchmark_disclosure="No benchmark fixture currently exercises dependency-manifest extraction; production integration tests cover this extractor.", + ), +) + + +LOCKFILE_CAPABILITIES: tuple[Capability, ...] = ( + _capability( + "lockfile.npm-package-lock", + "source", + "lockfile:package-lock.json", + SupportStatus.SUPPORTED, + "Resolved npm versions from package-lock.json lockfileVersion 2 and 3.", + ( + "Only entries under a node_modules/ path with a concrete version string are read; workspace link entries " + "are skipped. A resolution records that a version was installed, not that the repository depends on the " + "package directly, so no transitive dependency graph or depends_on edge is derived from a lockfile alone. " + "yarn.lock, pnpm-lock.yaml, and npm-shrinkwrap.json are not read." + ), + "src.npm_lockfile", + "src.npm_lockfile_nested", + ), + _capability( + "lockfile.npm-package-lock-v1", + "source", + "lockfile:package-lock.json@v1", + SupportStatus.UNSUPPORTED, + "package-lock.json lockfileVersion 1.", + "Version 1 predates the packages table this extractor reads; it is disclosed rather than parsed from the legacy dependencies tree.", + "src.npm_lockfile_v1", + expected_diagnostic="RI-EXT-UNSUPPORTED", + ), + _capability( + "lockfile.poetry-lock", + "source", + "lockfile:poetry.lock", + SupportStatus.SUPPORTED, + "Resolved PyPI versions from poetry.lock with lock-version major 1 or 2.", + ( + "Only each [[package]] table's name and version are read. Lock-version 2 removed the per-package category " + "field, so the production/development split is reported as unknown rather than guessed. Pipfile.lock, " + "uv.lock, and pdm.lock are not read." + ), + "src.poetry_lockfile", + ), +) + + +IAC_CAPABILITIES: tuple[Capability, ...] = ( + _capability( + "iac.docker-compose", + "source", + "iac:docker-compose", + SupportStatus.SUPPORTED, + "Declared services, volumes, and networks in Docker Compose manifests.", + ( + "Only the four canonical Compose filenames are read, and only the services, volumes, and networks " + "sections. Provenance is the resource's declaration line, not its whole block. Deployment topology, " + "profiles, and the configs/secrets sections are not modelled, and Terraform, Kubernetes, Helm, " + "CloudFormation, Pulumi, and Ansible are not read at all." + ), + "src.compose_service", + "src.compose_volume", + "src.compose_network", + ), + _capability( + "iac.templated-value", + "source", + "iac:templated-value", + SupportStatus.UNSUPPORTED, + "Interpolated Compose values such as image: ${TAG}.", + "A templated value is not an observed concrete value; the property is omitted and the blind spot is disclosed at the resource's span.", + "src.compose_templated", + expected_diagnostic="RI-EXT-UNSUPPORTED", + ), +) + + +def _product_capability( + id: str, + status: SupportStatus, + description: str, + limitation: str, +) -> Capability: + return _capability( + f"product.{id}", + "product", + id, + status, + description, + limitation, + benchmark_disclosure=( + "This product-level claim is verified by its focused integration or acceptance " + "tests rather than the Repository Intelligence golden benchmark." + ), + ) + + +PRODUCT_CAPABILITIES: tuple[Capability, ...] = ( + _product_capability( + "ai-provider", + SupportStatus.LIMITED, + "AI provider integration.", + "Provider answers receive bounded structural context and do not automatically carry source citations.", + ), + _product_capability( + "analysis-lifecycle", + SupportStatus.SUPPORTED, + "Database-backed analysis lifecycle.", + "Execution remains bounded by the configured worker and retry policy.", + ), + _product_capability( + "architecture-authentication", + SupportStatus.LIMITED, + "Architecture and authentication explanation.", + "Classification and authentication coverage are deliberately heuristic and pattern-bounded.", + ), + _product_capability( + "archive-import", + SupportStatus.SUPPORTED, + "Archive upload and public GitHub import.", + "Private GitHub cloning and non-GitHub repository hosts are not supported.", + ), + _product_capability( + "async-processing", + SupportStatus.PARTIAL, + "Asynchronous analysis processing.", + "Import, initial extraction, and file-tree parsing remain synchronous.", + ), + _product_capability( + "authentication-isolation", + SupportStatus.SUPPORTED, + "Authentication and owner isolation.", + "Protected resources deliberately conceal non-owner existence with 404 responses.", + ), + _product_capability( + "change-impact", + SupportStatus.LIMITED, + "Change-impact traversal.", + "Traversal covers one sealed snapshot and does not compare revisions.", + ), + _product_capability( + "dependency-graph", + SupportStatus.LIMITED, + "Direct dependency graph with supported lockfile resolutions.", + ( + "Declarations come from supported manifests and resolved pins from supported lockfiles only. A lockfile " + "resolution never becomes a depends_on edge on its own, so transitive resolution is still not claimed, " + "and vulnerability and outdated-version scanning are not implemented." + ), + ), + _product_capability( + "dependency-scanning", + SupportStatus.NOT_ASSESSED, + "Vulnerability and outdated-dependency scanning.", + "This capability is planned; current responses explicitly publish not-computed or not-assessed states.", + ), + _product_capability( + "documentation-export", + SupportStatus.LIMITED, + "Documentation and report export.", + "Documentation uses current-revision structural facts.", + ), + _product_capability( + "engineering-review", + SupportStatus.LIMITED, + "Evidence-addressed engineering review.", + "No overall score, grade, health percentage, vulnerability result, or generated roadmap is produced.", + ), + _product_capability( + "iac-resources", + SupportStatus.LIMITED, + "Infrastructure-as-code resource inventory.", + "Only Docker Compose services, volumes, and networks are extracted; no other IaC format is read and no deployment behaviour is inferred.", + ), + _product_capability( + "grounded-ai", + SupportStatus.NOT_ASSESSED, + "Grounded, cited free-form AI answers.", + "This capability is planned; current provider answers intentionally remain uncited.", + ), + _product_capability( + "repository-explorer", + SupportStatus.SUPPORTED, + "Owner-scoped repository explorer.", + "Preview is bounded and binary-aware.", + ), + _product_capability( + "repository-insights", + SupportStatus.LIMITED, + "Repository insights.", + "Insights describe one snapshot and make no change-over-time claims.", + ), + _product_capability( + "repository-intelligence", + SupportStatus.LIMITED, + "Revision-addressed Repository Intelligence snapshots.", + "Semantic extraction is strongest for supported Python and TypeScript/JavaScript constructs.", + ), + _product_capability( + "repository-lineage", + SupportStatus.PARTIAL, + "Durable lineage grouping repeated imports of the same repository (RFC-0002).", + "The `repository_lineages` table, owner-scoped grouping, and duplicate-revision detection run on every import. No read API or UI for browsing that history exists yet.", + ), + _product_capability( + "service-interactions", + SupportStatus.LIMITED, + "Outbound service-interaction discovery.", + "Only syntax-proven absolute literal destinations on supported Python and TS/JS HTTP clients become edges; every other call site stays a diagnostic.", + ), + _product_capability( + "revision-comparison", + SupportStatus.NOT_ASSESSED, + "Incremental re-analysis and revision comparison.", + "This capability is planned; the current workflow re-analyses the full repository.", + ), +) + + +def _supported_filenames(capabilities: tuple[Capability, ...], prefix: str) -> tuple[str, ...]: + """Derive the accepted filename set from the registry's supported entries. + + Source selection must never drift from what the registry claims, so the + extractors read their ``supports()`` filenames from here rather than keeping + a second hand-maintained list. A construct id carrying a format qualifier + (``lockfile:package-lock.json@v1``) contributes no filename of its own — it + describes an unsupported revision of a filename already listed. + """ + + return tuple( + sorted( + { + item.construct.removeprefix(prefix) + for item in capabilities + if item.status == SupportStatus.SUPPORTED and "@" not in item.construct + } + ) + ) + + +_SUPPORTED_MANIFEST_FILENAMES = _supported_filenames(MANIFEST_CAPABILITIES, "manifest:") +_SUPPORTED_LOCKFILE_FILENAMES = _supported_filenames(LOCKFILE_CAPABILITIES, "lockfile:") + +# Compose accepts four canonical filenames for one format, so the registry +# carries the format id and the filename set is spelled out beside it. +_SUPPORTED_IAC_FILENAMES: tuple[str, ...] = ( + "compose.yaml", + "compose.yml", + "docker-compose.yaml", + "docker-compose.yml", +) + + +def _code_list(items: tuple[str, ...]) -> str: + rendered = [f"`{item}`" for item in items] + if len(rendered) == 1: + return rendered[0] + # Two items take a plain "a and b"; the serial comma only applies from three. + if len(rendered) == 2: + return f"{rendered[0]} and {rendered[1]}" + return f"{', '.join(rendered[:-1])}, and {rendered[-1]}" + + +PUBLIC_CAPABILITIES: tuple[PublicCapability, ...] = ( + PublicCapability( + "archive-import", + "Archive upload and public GitHub import", + PublicStatus.IMPLEMENTED, + "ZIP/TAR-family archives and shallow public GitHub HTTPS clones; size and path-safety limits apply. Private GitHub cloning and other repository hosts are not supported.", + ("product.archive-import",), + ), + PublicCapability( + "repository-explorer", + "Repository explorer", + PublicStatus.IMPLEMENTED, + "Owner-scoped file tree plus bounded text/image preview, binary detection, and truncation.", + ("product.repository-explorer",), + ), + PublicCapability( + "authentication-isolation", + "Authentication and owner isolation", + PublicStatus.IMPLEMENTED, + "Email/password, Argon2, short-lived access tokens, rotating refresh tokens with reuse detection. Protected resources are owner-scoped; non-owner access returns 404.", + ("product.authentication-isolation",), + ), + PublicCapability( + "analysis-lifecycle", + "Analysis lifecycle", + PublicStatus.IMPLEMENTED, + "Database-backed, cancellable job with progress, bounded retry, lease renewal, and stale-worker recovery.", + ("product.analysis-lifecycle",), + ), + PublicCapability( + "repository-intelligence", + "Repository Intelligence", + PublicStatus.IMPLEMENTED_WITH_DISCLOSED_LIMITS, + "Immutable, revision-addressed `ri.v1` snapshots with normalized facts, evidence, query APIs, and a total canonical graph hash. Semantic extraction is strongest for supported Python and TypeScript/JavaScript constructs.", + ("product.repository-intelligence",), + ), + PublicCapability( + "repository-lineage", + "Repository lineage", + PublicStatus.IMPLEMENTED_WITH_DISCLOSED_LIMITS, + "Repeated imports of the same repository and branch are grouped into a durable, owner-scoped lineage with duplicate-revision detection (RFC-0002). `GET /repositories/{id}/lineage` returns the ordered history and the repository detail page renders it. Refresh and cross-revision comparison on top of a lineage are not built.", + ("product.repository-lineage",), + ), + PublicCapability( + "architecture-authentication", + "Architecture and authentication explanation", + PublicStatus.IMPLEMENTED_WITH_DISCLOSED_LIMITS, + "Interactive snapshot-backed graph. Module/layer classification is heuristic. The cited authentication subgraph covers supported Python/FastAPI patterns only.", + ("product.architecture-authentication",), + ), + PublicCapability( + "dependency-graph", + "Dependency Graph", + PublicStatus.IMPLEMENTED_WITH_DISCLOSED_LIMITS, + ( + f"Direct declarations from {_code_list(_SUPPORTED_MANIFEST_FILENAMES)} plus resolved pins from " + f"{_code_list(_SUPPORTED_LOCKFILE_FILENAMES)}, merged onto one dependency identity with repeated " + "workspace declarations and exact spans. A lockfile pin is recorded as a resolution, never as a direct " + "dependency edge, so transitive resolution is still not claimed." + ), + ( + "product.dependency-graph", + *(item.id for item in MANIFEST_CAPABILITIES), + *(item.id for item in LOCKFILE_CAPABILITIES if item.status == SupportStatus.SUPPORTED), + ), + ), + PublicCapability( + "service-interactions", + "Service-interaction discovery", + PublicStatus.IMPLEMENTED_WITH_DISCLOSED_LIMITS, + "Outbound HTTP call sites on `requests`, `httpx`, `fetch`, and `axios` resolve to a service node identified by its absolute origin, with the literal method and path on the call's own observation. A computed, relative, or shadowed destination is a diagnostic, never an edge.", + ("product.service-interactions", "python.http-client", "typescript.http-client"), + ), + PublicCapability( + "iac-resources", + "Infrastructure-as-code resources", + PublicStatus.IMPLEMENTED_WITH_DISCLOSED_LIMITS, + "Declared Docker Compose services, volumes, and networks with their exact declaration spans. Templated values are disclosed rather than reported as observed, and no other IaC format is read.", + ("product.iac-resources", "iac.docker-compose"), + ), + PublicCapability( + "engineering-review", + "Engineering Review", + PublicStatus.IMPLEMENTED_WITH_DISCLOSED_LIMITS, + "`engineering-review.v2`; evidence-addressed findings and explicit category states. No overall score, grade, health percentage, vulnerability result, or generated roadmap.", + ("product.engineering-review",), + ), + PublicCapability( + "repository-insights", + "Repository Insights", + PublicStatus.IMPLEMENTED_WITH_DISCLOSED_LIMITS, + "`repository-insights.v1`; defined counts, ratios, diagnostics, language breakdowns, and extraction coverage from one snapshot. No change-over-time claims.", + ("product.repository-insights",), + ), + PublicCapability( + "documentation-export", + "Documentation and report export", + PublicStatus.IMPLEMENTED_WITH_DISCLOSED_LIMITS, + "Documentation uses current-revision structural facts. Review, Documentation, Architecture, and Dependencies export through one JSON/Markdown/HTML/PDF pipeline.", + ("product.documentation-export",), + ), + PublicCapability( + "ai-provider", + "AI provider integration", + PublicStatus.IMPLEMENTED_WITH_DISCLOSED_LIMITS, + "Per-user configuration for supported providers, encrypted API keys, and constrained outbound destinations. Free-form answers receive structural facts and observed paths\u2014not source bytes or line spans\u2014and return no automatic citations.", + ("product.ai-provider",), + ), + PublicCapability( + "async-processing", + "Asynchronous processing", + PublicStatus.IMPLEMENTED_WITH_DISCLOSED_LIMITS, + "Analysis runs off the request path. Import, extraction of the initial archive/clone, and file-tree parsing remain synchronous; one in-process worker handles analysis jobs.", + ("product.async-processing",), + ), + PublicCapability( + "revision-comparison", + "Incremental re-analysis and revision comparison", + PublicStatus.PLANNED, + "The full repository is analysed again; no snapshot-to-snapshot product workflow is available.", + ("product.revision-comparison",), + ), + PublicCapability( + "change-impact", + "Change-impact or blast-radius analysis", + PublicStatus.IMPLEMENTED_WITH_DISCLOSED_LIMITS, + "Owner-scoped traversal over one sealed snapshot's resolved import and dependency edges. It does not compare revisions or calculate churn or trends.", + ("product.change-impact",), + ), + PublicCapability( + "dependency-scanning", + "Vulnerability and outdated-dependency scanning", + PublicStatus.PLANNED, + "Dependency responses report explicit `not_computed` states; Review keeps vulnerability scanning `not_assessed`. No clean bill of health or zero count is fabricated.", + ("product.dependency-scanning",), + ), + PublicCapability( + "grounded-ai", + "Grounded, cited free-form AI answers", + PublicStatus.PLANNED, + "Provider answers are intentionally uncited because providers do not receive source content or line numbers.", + ("product.grounded-ai",), + ), +) + + +CAPABILITY_REGISTRY: tuple[Capability, ...] = tuple( + sorted( + ( + *CONSTRUCT_CAPABILITIES, + *IAC_CAPABILITIES, + *LOCKFILE_CAPABILITIES, + *MANIFEST_CAPABILITIES, + *PRODUCT_CAPABILITIES, + ), + key=lambda item: item.id, + ) +) +CAPABILITIES_BY_ID = {item.id: item for item in CAPABILITY_REGISTRY} + + +@dataclass(frozen=True) +class LanguageSupport: + """Compatibility projection consumed by existing extractor tests.""" + + supported: tuple[str, ...] + unsupported: tuple[str, ...] + + +def validate_registry( + capabilities: Iterable[Capability] = CAPABILITY_REGISTRY, + public_capabilities: Iterable[PublicCapability] = PUBLIC_CAPABILITIES, +) -> None: + entries = tuple(capabilities) + ids = [item.id for item in entries] + if ids != sorted(ids): + raise ValueError("capability registry entries must be ordered by stable id") + if len(ids) != len(set(ids)): + raise ValueError("capability registry contains duplicate capability ids") + for item in entries: + if not item.id or not item.language or not item.construct: + raise ValueError(f"capability {item.id!r} is missing a stable identity") + if not isinstance(item.status, SupportStatus): + raise ValueError(f"capability {item.id!r} has an unsupported status") + if not item.description or not item.limitation: + raise ValueError(f"capability {item.id!r} must disclose description and limitation") + if item.status == SupportStatus.UNSUPPORTED and not item.expected_diagnostic: + raise ValueError(f"unsupported capability {item.id!r} must name its diagnostic") + if item.status != SupportStatus.UNSUPPORTED and item.expected_diagnostic: + raise ValueError(f"supported capability {item.id!r} cannot require an unsupported diagnostic") + if not item.benchmark_ids and not item.benchmark_disclosure: + raise ValueError(f"capability {item.id!r} needs benchmark coverage or an explicit disclosure") + if len(item.benchmark_ids) != len(set(item.benchmark_ids)): + raise ValueError(f"capability {item.id!r} contains duplicate benchmark ids") + + capabilities_by_id = {item.id: item for item in entries} + allowed_statuses = { + PublicStatus.IMPLEMENTED: {SupportStatus.SUPPORTED}, + PublicStatus.IMPLEMENTED_WITH_DISCLOSED_LIMITS: { + SupportStatus.SUPPORTED, + SupportStatus.LIMITED, + SupportStatus.PARTIAL, + }, + PublicStatus.PLANNED: {SupportStatus.NOT_ASSESSED}, + PublicStatus.REJECTED: {SupportStatus.UNSUPPORTED}, + } + for public in public_capabilities: + if not isinstance(public.status, PublicStatus): + raise ValueError(f"public capability {public.id!r} has an unsupported status") + if not public.capability_ids: + raise ValueError(f"public capability {public.id!r} must resolve to registry capabilities") + resolved = [] + for capability_id in public.capability_ids: + capability = capabilities_by_id.get(capability_id) + if capability is None: + raise ValueError(f"public capability {public.id!r} references unknown capability {capability_id!r}") + resolved.append(capability) + if any(item.status not in allowed_statuses[public.status] for item in resolved): + raise ValueError(f"public capability {public.id!r} status is inconsistent with its registry capabilities") + if public.status == PublicStatus.IMPLEMENTED_WITH_DISCLOSED_LIMITS and not any( + item.status in {SupportStatus.LIMITED, SupportStatus.PARTIAL} for item in resolved + ): + raise ValueError(f"public capability {public.id!r} must resolve to a disclosed limited capability") + + +# Deliberate fail-fast: registry drift is an application-startup error because +# extractors and public claims must never run from an invalid truth contract. +validate_registry() + +# The compatibility view covers language constructs only. Manifest-family +# capabilities (manifest, lockfile, IaC) are keyed by filename or format rather +# than by a syntax construct, so they are projected out here and consumed +# through their own filename accessors. +_MATRIX_EXCLUDED_PREFIXES = ("iac:", "lockfile:", "manifest:") +_MATRIX_CAPABILITIES = tuple( + item + for item in CAPABILITY_REGISTRY + if item.language in {"python", "source", "typescript"} and not item.construct.startswith(_MATRIX_EXCLUDED_PREFIXES) +) +SUPPORT_MATRIX: dict[str, LanguageSupport] = { + language: LanguageSupport( + supported=tuple( + item.construct + for item in _MATRIX_CAPABILITIES + if item.language == language and item.status == SupportStatus.SUPPORTED + ), + unsupported=tuple( + item.construct + for item in _MATRIX_CAPABILITIES + if item.language == language and item.status == SupportStatus.UNSUPPORTED + ), + ) + for language in sorted({item.language for item in _MATRIX_CAPABILITIES}) +} + + +def supported_manifest_filenames() -> tuple[str, ...]: + return _SUPPORTED_MANIFEST_FILENAMES + + +def supported_lockfile_filenames() -> tuple[str, ...]: + return _SUPPORTED_LOCKFILE_FILENAMES + + +def supported_iac_filenames() -> tuple[str, ...]: + return _SUPPORTED_IAC_FILENAMES + + +def render_readme_capabilities() -> str: + lines = [ + README_CAPABILITIES_START, + "| Capability | Status | Current boundary |", + "| --- | --- | --- |", + ] + lines.extend(f"| {item.name} | **{item.status}** | {item.statement} |" for item in PUBLIC_CAPABILITIES) + lines.extend( + [ + "", + "**Implemented with disclosed limits** means the workflow exists with an explicit coverage or trust boundary. **Planned** means it is roadmap work and current responses do not manufacture an answer. **Rejected** means the capability is intentionally outside the product contract.", + README_CAPABILITIES_END, + ] + ) + return "\n".join(lines) + + +def check_readme_capabilities(readme: Path) -> None: + content = readme.read_text(encoding="utf-8") + start = content.find(README_CAPABILITIES_START) + end = content.find(README_CAPABILITIES_END) + if start < 0 or end < start: + raise ValueError("README is missing the generated capability registry markers") + actual = content[start : end + len(README_CAPABILITIES_END)] + expected = render_readme_capabilities() + if actual != expected: + raise ValueError("README capability registry block is stale; run the reviewed registry renderer") diff --git a/apps/backend/app/extraction/typescript.py b/apps/backend/app/extraction/typescript.py new file mode 100644 index 00000000..1a4ee184 --- /dev/null +++ b/apps/backend/app/extraction/typescript.py @@ -0,0 +1,1089 @@ +from __future__ import annotations + +import posixpath + +import tree_sitter_typescript as tsts +from tree_sitter import Language, Parser + +from app.extraction.base import ( + ExtractedDiagnostic, + ExtractedNode, + ExtractedObservation, + ExtractionResult, + RI_EXT_UNSUPPORTED, + RI_KEY_DUP_SYMBOL, + RI_SEC_PATH_ESCAPE, + RI_SRC_MALFORMED, + assign_ordinals, + build_evidence, + decode_source, + logical_line_count, +) +from app.extraction.http import ( + HTTP_METHOD_ATTRIBUTES, + describe_destination, + describe_destination_from_literal_prefix, +) +from app.extraction.naming import ( + DiscriminatorAssigner, + module_name, + module_stable_key, + service_stable_key, + symbol_stable_key, +) +from app.intelligence import canonical + +_TS_LANGUAGE = Language(tsts.language_typescript()) +_TSX_LANGUAGE = Language(tsts.language_tsx()) + +# react-router data-router factories: same call shape, same route-table argument. +_ROUTER_FACTORIES = {"createBrowserRouter", "createHashRouter", "createMemoryRouter"} +_ROUTE_ELEMENTS = {"Route"} + +# A bare call to an ECMAScript/host global (parseInt, structuredClone, ...) has +# no in-repo definition to resolve to, and is not a relationship worth a +# resolver diagnostic -- it scales with every call in the file, not with real +# coverage gaps. A name here that a function scope shadows (see `shadowed` in +# `_collect_calls`) is still treated as a real local call. +_GLOBAL_CALL_NAMES = { + "parseInt", + "parseFloat", + "isNaN", + "isFinite", + "String", + "Number", + "Boolean", + "Array", + "Object", + "Symbol", + "BigInt", + "Promise", + "Map", + "Set", + "WeakMap", + "WeakSet", + "Date", + "RegExp", + "Error", + "TypeError", + "RangeError", + "JSON", + "Math", + "Reflect", + "Proxy", + "structuredClone", + "encodeURIComponent", + "decodeURIComponent", + "encodeURI", + "decodeURI", + "setTimeout", + "setInterval", + "clearTimeout", + "clearInterval", + "queueMicrotask", + "atob", + "btoa", + "alert", + "confirm", + "prompt", +} + +# Collectors emit this; assign_ordinals sets the RFC §6.4 value on the way out. +# It is deliberately invalid (ordinals are one-based) so a result that skipped +# assignment fails loudly rather than persisting a wrong identity. +_UNASSIGNED_ORDINAL = 0 + +# --- Supported HTTP client surface (#209) ----------------------------------- +# +# ``fetch`` is the platform global, so it needs no import to be a client; axios +# must be proved by a default import from the ``axios`` module specifier. +_FETCH_NAME = "fetch" +_AXIOS_SPECIFIER = "axios" +# fetch(input) with no init is a GET by specification, so the method is proven +# by the call shape rather than assumed. +_FETCH_DEFAULT_METHOD = "get" + +_FUNCTION_SCOPE_TYPES = frozenset( + { + "function_declaration", + "function_expression", + "generator_function_declaration", + "generator_function", + "arrow_function", + "method_definition", + } +) + +_NAMED_DECLARATIONS = { + "function_declaration": "name", + "function_signature": "name", # ambient/overload signatures (no body) + "generator_function_declaration": "name", + "class_declaration": "name", + "abstract_class_declaration": "name", + "interface_declaration": "name", + "type_alias_declaration": "name", + "enum_declaration": "name", + "method_definition": "name", # emitted via the unified path, in class scope +} + + +class TypeScriptExtractor: + name = "typescript-ast" + version = "1.2.0" + + @property + def producer(self) -> str: + return f"{self.name}@{self.version}" + + def supports(self, path: str) -> bool: + return path.endswith(".ts") or path.endswith(".tsx") + + def _parser(self, path: str) -> Parser: + return Parser(_TSX_LANGUAGE if path.endswith(".tsx") else _TS_LANGUAGE) + + def extract(self, path: str, source: bytes) -> ExtractionResult: + text, source_diag = decode_source(path, source, producer=self.producer) + if text is None: + return ExtractionResult(diagnostics=(source_diag,)) + + try: + normalized_path = canonical.normalize_repo_path(path) + except canonical.PathEscapeError: + return ExtractionResult( + diagnostics=( + ExtractedDiagnostic( + code=RI_SEC_PATH_ESCAPE, + category="path escape", + severity="error", + message="source path is absolute or escapes the repository root", + path=None, + ), + ) + ) + + line_count = logical_line_count(text) + tree = self._parser(path).parse(source) + + nodes: list[ExtractedNode] = [] + diagnostics: list[ExtractedDiagnostic] = [] + file_key = canonical.normalize_stable_key("file", f"file:{normalized_path}") + + if tree.root_node.has_error: + return ExtractionResult( + diagnostics=( + ExtractedDiagnostic( + code=RI_SRC_MALFORMED, + category="malformed source", + severity="error", + message="file has TypeScript syntax errors", + path=normalized_path, + subject=file_key, + ), + ) + ) + + file_ev, file_diag = build_evidence(path, 1, line_count, line_count, producer=self.producer, granularity="file") + if file_ev is not None: + nodes.append( + ExtractedNode( + node_kind="file", + stable_key=file_key, + name=posixpath.basename(normalized_path), + language="typescript", + evidence=(file_ev,), + properties={"content_sha256": canonical.sha256_prefixed(source)}, + ) + ) + # #89 requires module nodes as well as file nodes. The module is + # directory-scoped and shared by every file in that directory — + # including files of another language, so the record carries no + # language of its own (see the Python extractor's module node). + nodes.append( + ExtractedNode( + node_kind="module", + stable_key=module_stable_key(path), + name=module_name(path), + language=None, + evidence=(file_ev,), + ) + ) + elif file_diag is not None: + diagnostics.append(file_diag) + + observations: list[ExtractedObservation] = [] + self._collect_symbols(tree.root_node, path, line_count, file_key, nodes, observations, diagnostics) + self._collect_implements(tree.root_node, path, line_count, nodes, observations) + self._collect_imports(tree.root_node, path, line_count, file_key, observations) + self._collect_routes(tree.root_node, path, line_count, file_key, nodes, observations, diagnostics) + # Service interactions run first so the generic call pass can skip the + # call sites they already own (see ``_collect_calls``). + http_call_sites = self._collect_service_interactions( + tree.root_node, path, line_count, file_key, nodes, observations, diagnostics + ) + self._collect_calls(tree.root_node, path, line_count, file_key, observations, http_call_sites) + self._collect_blind_spots(tree.root_node, path, line_count, diagnostics) + return ExtractionResult( + nodes=tuple(nodes), + observations=assign_ordinals(observations), + diagnostics=tuple(diagnostics), + ) + + def _collect_imports(self, root, path, line_count, file_key, observations) -> None: + source = root.text + + def walk(node): + if node.type in ("import_statement", "export_statement"): + source_node = node.child_by_field_name("source") + if source_node is not None: + literal = self._node_text(source_node, source).strip("'\"`") + ev, _ = build_evidence( + path, + node.start_point[0] + 1, + node.end_point[0] + 1, + line_count, + producer=self.producer, + ) + if ev is not None: + observations.append( + ExtractedObservation( + observed_kind="import", + subject_kind="file", + subject_key=file_key, + referent_text=literal, + ordinal=_UNASSIGNED_ORDINAL, + evidence=ev, + ) + ) + if node.type == "import_statement": + self._collect_import_bindings( + node, literal, path, line_count, file_key, source, observations + ) + for child in node.named_children: + walk(child) + + walk(root) + + def _collect_implements(self, root, path, line_count, nodes, observations) -> None: + """Emit direct TypeScript ``class ... implements ...`` references. + + Only the syntax-level `implements` clause is recorded. Resolving an + imported interface name is left to the stored-fact resolver, and + ``extends`` remains outside the registered v1 predicate set. + """ + + source = root.text + symbols = {node.stable_key: node for node in nodes if node.node_kind == "symbol"} + + def walk(node): + if node.type in ("class_declaration", "abstract_class_declaration"): + name = node.child_by_field_name("name") + if name is not None: + class_name = self._node_text(name, source) + start_line = node.start_point[0] + 1 + end_line = node.end_point[0] + 1 + class_candidates = [ + symbol + for symbol in symbols.values() + if symbol.name == class_name + and any( + evidence.start_line == start_line and evidence.end_line == end_line + for evidence in symbol.evidence + ) + ] + if len(class_candidates) == 1: + class_key = class_candidates[0].stable_key + heritage = next( + (child for child in node.named_children if child.type == "class_heritage"), + None, + ) + if heritage is not None: + for clause in heritage.named_children: + if clause.type != "implements_clause": + continue + for target in clause.named_children: + reference = target.child_by_field_name("name") or target + evidence, _ = build_evidence( + path, + target.start_point[0] + 1, + target.end_point[0] + 1, + line_count, + producer=self.producer, + ) + if evidence is not None: + observations.append( + ExtractedObservation( + observed_kind="implements", + subject_kind="symbol", + subject_key=class_key, + referent_text=self._node_text(reference, source), + ordinal=_UNASSIGNED_ORDINAL, + evidence=evidence, + ) + ) + for child in node.named_children: + walk(child) + + walk(root) + + def _collect_import_bindings(self, statement, specifier, path, line_count, file_key, source, observations) -> None: + """Record direct import aliases as resolver inputs without resolving them.""" + + clause = next((child for child in statement.named_children if child.type == "import_clause"), None) + if clause is None: + return + + def emit(imported, local, node): + evidence, _ = build_evidence( + path, + node.start_point[0] + 1, + node.end_point[0] + 1, + line_count, + producer=self.producer, + ) + if evidence is not None: + observations.append( + ExtractedObservation( + observed_kind="import_binding", + subject_kind="file", + subject_key=file_key, + # This is an exact, delimiter-safe representation of the + # source binding. The resolver consumes it as stored + # extractor output; it never re-parses the source file. + referent_text=f"{specifier}|{imported}|{local}", + ordinal=_UNASSIGNED_ORDINAL, + evidence=evidence, + ) + ) + + for child in clause.named_children: + if child.type == "identifier": + emit("default", self._node_text(child, source), child) + elif child.type == "named_imports": + for binding in child.named_children: + if binding.type != "import_specifier": + continue + name = binding.child_by_field_name("name") + alias = binding.child_by_field_name("alias") + if name is not None: + emit( + self._node_text(name, source), + self._node_text(alias, source) if alias is not None else self._node_text(name, source), + binding, + ) + + def _collect_routes(self, root, path, line_count, file_key, nodes, observations, diagnostics) -> None: + """Emit a ``route`` observation per confirmed react-router path literal. + + Only two contexts count: a ``path`` key inside an argument to a router + factory (``createBrowserRouter`` and friends), and a ``path`` attribute + on a ```` element. A bare ``{path: ...}`` object or a ``path`` + prop on any other component is not a route, and inventing one would be a + fabricated fact (RFC §7.2). + """ + + source = root.text + seen: set[int] = set() + route_ordinal = 0 + + def flag_dynamic_path(node) -> None: + diagnostics.append( + ExtractedDiagnostic( + code=RI_EXT_UNSUPPORTED, + category="unsupported construct", + severity="info", + message="dynamic route path is unsupported", + path=canonical.normalize_repo_path(path), + span=(node.start_point[0] + 1, node.end_point[0] + 1), + subject=file_key, + ) + ) + + def jsx_path_literal(node) -> str | None: + if node.type == "jsx_expression": + if len(node.named_children) != 1: + return None + node = node.named_children[0] + if node.type != "string": + return None + return self._node_text(node, source).strip("'\"`") + + def emit(node, literal, handler_referent=None, handler_node=None): + nonlocal route_ordinal + if node.id in seen: + return + seen.add(node.id) + ev, _ = build_evidence( + path, + node.start_point[0] + 1, + node.end_point[0] + 1, + line_count, + producer=self.producer, + ) + if ev is not None: + route_ordinal += 1 + route_key = canonical.normalize_stable_key( + "symbol", + symbol_stable_key(path, [], f"(anonymous:route#{route_ordinal})"), + ) + nodes.append( + ExtractedNode( + node_kind="symbol", + stable_key=route_key, + name="route", + language="typescript", + evidence=(ev,), + properties={"route_path": literal}, + ) + ) + observations.append( + ExtractedObservation( + observed_kind="route", + subject_kind="symbol", + subject_key=route_key, + referent_text=literal, + ordinal=_UNASSIGNED_ORDINAL, + evidence=ev, + ) + ) + if handler_referent: + handler_ev, _ = build_evidence( + path, + handler_node.start_point[0] + 1 if handler_node is not None else node.start_point[0] + 1, + handler_node.end_point[0] + 1 if handler_node is not None else node.end_point[0] + 1, + line_count, + producer=self.producer, + ) + if handler_ev is not None: + observations.append( + ExtractedObservation( + observed_kind="route_handler", + subject_kind="symbol", + subject_key=route_key, + referent_text=handler_referent, + ordinal=_UNASSIGNED_ORDINAL, + evidence=handler_ev, + ) + ) + + def pair_value(pair, name): + key = pair.child_by_field_name("key") + if key is None or self._node_text(key, source).strip("'\"") != name: + return None + return pair.child_by_field_name("value") + + def collect_route_entry(node): + """Read direct route fields and follow only its ``children`` table. + + A route entry is an object in the router's route-table array. Its + ``handle``, ``element``, and arbitrary metadata may contain objects + with their own ``path`` keys, but those are application data rather + than routes. ``children`` is the one RouteObject field that contains + another route table. + """ + + if node.type != "object": + return + path_pair = None + path_value = None + handler_referent = None + handler_node = None + child_tables = [] + for pair in node.named_children: + if pair.type != "pair": + continue + candidate_path = pair_value(pair, "path") + if candidate_path is not None: + if candidate_path.type == "string": + path_pair, path_value = pair, candidate_path + else: + flag_dynamic_path(candidate_path) + for handler_name in ("Component", "component"): + handler_value = pair_value(pair, handler_name) + if handler_value is not None and handler_value.type == "identifier": + handler_referent, handler_node = self._node_text(handler_value, source), handler_value + element_value = pair_value(pair, "element") + if element_value is not None: + jsx_handler = self._jsx_handler(element_value, source) + if jsx_handler is not None: + handler_referent, handler_node = jsx_handler + children_value = pair_value(pair, "children") + if children_value is not None: + child_tables.append(children_value) + if path_pair is not None and path_value is not None: + emit( + path_pair, + self._node_text(path_value, source).strip("'\"`"), + handler_referent, + handler_node, + ) + for child_table in child_tables: + collect_route_table(child_table) + + def collect_route_table(node): + # A factory's first argument and a route entry's `children` must be + # arrays of actual route objects. Do not recursively inspect their + # contents: unrelated nested objects are not route entries. + if node.type == "array": + for entry in node.named_children: + collect_route_entry(entry) + + def walk(node): + if node.type == "call_expression": + fn = node.child_by_field_name("function") + arguments = node.child_by_field_name("arguments") + if fn is not None and arguments is not None and self._node_text(fn, source) in _ROUTER_FACTORIES: + # createBrowserRouter(routes, opts?) — only the first argument + # is the route table. The options object also accepts a `path` + # (e.g. basename config), which is not a route. + route_table = arguments.named_children[0] if arguments.named_children else None + if route_table is not None: + collect_route_table(route_table) + elif node.type in ("jsx_self_closing_element", "jsx_opening_element"): + name_node = node.child_by_field_name("name") + if name_node is not None and self._node_text(name_node, source) in _ROUTE_ELEMENTS: + path_attribute = None + handler_referent = None + handler_node = None + for child in node.named_children: + if child.type != "jsx_attribute": + continue + parts = child.named_children + if len(parts) > 1 and self._node_text(parts[0], source) == "path": + path_literal = jsx_path_literal(parts[1]) + if path_literal is None: + flag_dynamic_path(parts[1]) + else: + path_attribute = child + elif len(parts) > 1 and self._node_text(parts[0], source) == "element": + jsx_handler = self._jsx_handler(parts[1], source) + if jsx_handler is not None: + handler_referent, handler_node = jsx_handler + if path_attribute is not None: + emit(path_attribute, path_literal, handler_referent, handler_node) + for child in node.named_children: + walk(child) + + walk(root) + + def _jsx_handler(self, node, source: bytes): + """Return a direct JSX component reference, never a computed expression.""" + + if node.type == "jsx_expression" and node.named_children: + node = node.named_children[0] + if node.type not in ("jsx_element", "jsx_self_closing_element"): + return None + name = node.child_by_field_name("name") + if name is None: + return None + text = self._node_text(name, source) + return (text, name) if text and text[0].isupper() else None + + def _binding_names(self, pattern, source: bytes) -> set[str]: + if pattern is None: + return set() + if pattern.type in ("identifier", "shorthand_property_identifier_pattern"): + return {self._node_text(pattern, source)} + names: set[str] = set() + for child in pattern.named_children: + names.update(self._binding_names(child, source)) + return names + + def _scope_bindings(self, function, source: bytes) -> set[str]: + """Names a function scope binds locally: parameters and own declarations. + + Shared by the call and service-interaction passes so both agree on what + "shadowed" means; two different answers would let one pass emit a + confident destination the other proved was a local name. + """ + + names: set[str] = set() + parameters = function.child_by_field_name("parameters") + if parameters is not None: + for parameter in parameters.named_children: + names.update(self._binding_names(parameter.child_by_field_name("pattern"), source)) + parameter = function.child_by_field_name("parameter") + if parameter is not None: + names.update(self._binding_names(parameter, source)) + + body = function.child_by_field_name("body") + + def collect(node) -> None: + if node is not body and node.type in _FUNCTION_SCOPE_TYPES: + names.update(self._binding_names(node.child_by_field_name("name"), source)) + return + if node.type in ("class_declaration", "abstract_class_declaration"): + names.update(self._binding_names(node.child_by_field_name("name"), source)) + return + if node.type == "variable_declarator": + names.update(self._binding_names(node.child_by_field_name("name"), source)) + for child in node.named_children: + collect(child) + + if body is not None: + collect(body) + return names + + def _collect_calls(self, root, path, line_count, file_key, observations, http_call_sites=frozenset()) -> None: + """Record direct identifier calls; target selection is resolver work. + + ``http_call_sites`` holds the node ids of call expressions the + service-interaction pass already recorded as ``http_call``. They are + skipped here so one ``fetch(...)`` line is one fact, not a service + interaction plus a generic unresolvable ``call``. + """ + + source = root.text + + def scope_bindings(function) -> set[str]: + return self._scope_bindings(function, source) + + def walk(node, shadowed: frozenset[str] = frozenset()): + if node.type in _FUNCTION_SCOPE_TYPES: + shadowed = shadowed | frozenset(scope_bindings(node)) + if node.type == "call_expression" and node.id not in http_call_sites: + function = node.child_by_field_name("function") + if function is not None and function.type == "identifier": + function_name = self._node_text(function, source) + # CommonJS require() remains an explicitly unsupported + # construct, so its diagnostic is the only emitted fact. + # An unshadowed ECMAScript/host global is skipped outright + # -- it has no in-repo target, so it is neither of those, + # it is simply not a relationship. + is_unshadowed_global = function_name in _GLOBAL_CALL_NAMES and function_name not in shadowed + if function_name != "require" and not is_unshadowed_global: + evidence, _ = build_evidence( + path, + node.start_point[0] + 1, + node.end_point[0] + 1, + line_count, + producer=self.producer, + ) + if evidence is not None: + observations.append( + ExtractedObservation( + observed_kind="call", + subject_kind="file", + subject_key=file_key, + referent_text=function_name, + ordinal=_UNASSIGNED_ORDINAL, + evidence=evidence, + ) + ) + if function_name in shadowed: + observations.append( + ExtractedObservation( + observed_kind="call_shadowed", + subject_kind="file", + subject_key=file_key, + referent_text=function_name, + ordinal=_UNASSIGNED_ORDINAL, + evidence=evidence, + ) + ) + for child in node.named_children: + walk(child, shadowed) + + walk(root) + + def _collect_service_interactions(self, root, path, line_count, file_key, nodes, observations, diagnostics): + """Record outbound HTTP call sites that syntax proves (#209). + + Two client forms are recognized: the ``fetch`` global, and attribute + calls on a local name bound by a **default import from ``axios``** — so + ``import http from "axios"`` works and an unrelated local ``axios`` + object does not. A call becomes a fact only when the destination is an + absolute literal URL and the method is proven: the axios method name, + a literal ``method`` in an inline ``fetch`` init object, or fetch's + specified GET default when no init is passed at all. + + Returns the set of call-expression node ids that produced a fact, so the + generic ``call`` pass can skip them. + """ + + source = root.text + normalized = canonical.normalize_repo_path(path) + emitted: set[int] = set() + axios_names = self._axios_default_import_names(root, source) + # Every name that is an HTTP client somewhere in this file. Used only to + # tell a genuine shadowed-client blind spot from an unrelated local name. + client_names = {_FETCH_NAME, *axios_names} + + def flag(node, message: str) -> None: + diagnostics.append( + ExtractedDiagnostic( + code=RI_EXT_UNSUPPORTED, + category="unsupported construct", + severity="info", + # Construct name only: a URL argument or init object can + # carry tokens and internal hostnames, which RFC §13 keeps + # out of diagnostic text. The span says where to look. + message=message, + path=normalized, + span=(node.start_point[0] + 1, node.end_point[0] + 1), + subject=file_key, + ) + ) + + def string_literal(node) -> str | None: + """Return a literal string's text, or ``None`` if it is computed.""" + + if node is None: + return None + if node.type == "template_string": + if any(child.type == "template_substitution" for child in node.named_children): + return None + return self._node_text(node, source).strip("`") + if node.type != "string": + return None + return self._node_text(node, source).strip("'\"") + + def leading_template_literal(node) -> str | None: + """The literal text a template literal opens with, up to its first + ``${...}`` substitution -- or ``None`` if it isn't a template + literal, or starts with one (`` `${x}...` ``, where there is no + leading literal text at all) (#408). + """ + + if node is None or node.type != "template_string": + return None + named = node.named_children + if not named or named[0].type != "string_fragment": + return None + return self._node_text(named[0], source) + + def fetch_method(arguments) -> str | None | bool: + """Method for a ``fetch`` call: name, ``False`` if computed.""" + + if len(arguments) < 2: + return _FETCH_DEFAULT_METHOD + init = arguments[1] + if init.type != "object": + return False + for entry in init.named_children: + if entry.type == "spread_element": + # A spread can set ``method`` invisibly, so the GET default + # is no longer proven by the call shape. + return False + if entry.type != "pair": + continue + key = entry.child_by_field_name("key") + if key is None or self._node_text(key, source).strip("'\"") != "method": + continue + literal = string_literal(entry.child_by_field_name("value")) + return literal if literal is not None else False + return _FETCH_DEFAULT_METHOD + + def emit_destination(call, destination) -> None: + evidence, _ = build_evidence( + path, + call.start_point[0] + 1, + call.end_point[0] + 1, + line_count, + producer=self.producer, + ) + if evidence is None: + return + emitted.add(call.id) + nodes.append( + ExtractedNode( + node_kind="service", + stable_key=service_stable_key(destination.origin), + name=destination.origin, + # A destination is not a source language: Python and + # TypeScript calling one origin must produce a byte-identical + # record or the snapshot refuses to seal. + language=None, + evidence=(evidence,), + properties={"origin": destination.origin}, + ) + ) + observations.append( + ExtractedObservation( + observed_kind="http_call", + subject_kind="file", + subject_key=file_key, + referent_text=destination.referent_text, + ordinal=_UNASSIGNED_ORDINAL, + evidence=evidence, + ) + ) + + def emit(call, method: str, url_node) -> None: + url = string_literal(url_node) + if url is not None: + destination = describe_destination(method, url) + if destination is None: + flag(call, "HTTP destination without an absolute http(s) URL is unsupported") + return + emit_destination(call, destination) + return + # The URL wasn't a plain literal -- a template literal still + # proves the origin when everything up to its first `${...}` is + # literal text and that text already closes off the authority + # (#408). + prefix = leading_template_literal(url_node) + if prefix is not None: + destination = describe_destination_from_literal_prefix(method, prefix) + if destination is not None: + emit_destination(call, destination) + return + flag(call, "dynamic HTTP destination is unsupported") + + def visit(call, shadowed: frozenset[str]) -> None: + function = call.child_by_field_name("function") + argument_node = call.child_by_field_name("arguments") + if function is None or argument_node is None: + return + arguments = [child for child in argument_node.named_children if child.type != "comment"] + + if function.type == "identifier": + name = self._node_text(function, source) + if name not in client_names: + return + if name in shadowed: + flag(call, "HTTP client name is shadowed by a local binding") + return + if name in axios_names: + # axios(config) takes its method and url from an object whose + # shape this extractor does not interpret. + flag(call, "unsupported HTTP client call form") + return + method = fetch_method(arguments) + if method is False: + flag(call, "computed fetch init is unsupported") + return + if not arguments: + flag(call, "dynamic HTTP destination is unsupported") + return + emit(call, str(method), arguments[0]) + return + + if function.type != "member_expression": + return + receiver = function.child_by_field_name("object") + attribute = function.child_by_field_name("property") + if receiver is None or attribute is None or receiver.type != "identifier": + return + name = self._node_text(receiver, source) + if name not in axios_names: + return + if name in shadowed: + flag(call, "HTTP client name is shadowed by a local binding") + return + method = self._node_text(attribute, source) + if method not in HTTP_METHOD_ATTRIBUTES: + # axios.request/axios.create and friends do not name a method. + flag(call, "unsupported HTTP client call form") + return + if not arguments: + flag(call, "dynamic HTTP destination is unsupported") + return + emit(call, method, arguments[0]) + + def walk(node, shadowed: frozenset[str] = frozenset()): + if node.type in _FUNCTION_SCOPE_TYPES: + shadowed = shadowed | frozenset(self._scope_bindings(node, source)) + if node.type == "call_expression": + visit(node, shadowed) + for child in node.named_children: + walk(child, shadowed) + + walk(root) + return frozenset(emitted) + + def _axios_default_import_names(self, root, source: bytes) -> set[str]: + """Local names bound by a default import from the ``axios`` specifier.""" + + names: set[str] = set() + + def walk(node): + if node.type == "import_statement": + specifier = node.child_by_field_name("source") + if specifier is not None and self._node_text(specifier, source).strip("'\"`") == _AXIOS_SPECIFIER: + clause = next((child for child in node.named_children if child.type == "import_clause"), None) + if clause is not None: + for child in clause.named_children: + if child.type == "identifier": + names.add(self._node_text(child, source)) + for child in node.named_children: + walk(child) + + walk(root) + return names + + def _collect_blind_spots(self, root, path, line_count, diagnostics) -> None: + source = root.text + normalized = canonical.normalize_repo_path(path) + file_subject = canonical.normalize_stable_key("file", f"file:{normalized}") + + def flag(node, message): + diagnostics.append( + ExtractedDiagnostic( + code=RI_EXT_UNSUPPORTED, + category="unsupported construct", + severity="info", + message=message, + path=normalized, + span=(node.start_point[0] + 1, node.end_point[0] + 1), + subject=file_subject, + ) + ) + + def walk(node): + if node.type in ("internal_module", "module") and node.child_by_field_name("name") is not None: + flag(node, "namespace/module declaration is unsupported") + elif node.type == "decorator": + # TypeScript decorators are declared unsupported: their semantics + # (and any metadata they attach) are not modelled here. The + # message names the construct only — quoting the decorator source + # would embed repository content, and its arguments can hold + # secrets (RFC §13). The span says where to look. + flag(node, "TypeScript decorator is unsupported") + elif node.type == "call_expression": + fn = node.child_by_field_name("function") + if fn is not None: + text = self._node_text(fn, source) + if fn.type == "import": + flag(node, "dynamic import() is unsupported") + elif text == "require": + flag(node, "CommonJS require() is unsupported") + for child in node.named_children: + walk(child) + + walk(root) + + def _node_text(self, node, source: bytes) -> str: + return source[node.start_byte : node.end_byte].decode("utf-8", errors="replace") + + def _is_exported(self, node) -> bool: + return node.parent is not None and node.parent.type == "export_statement" + + def _default_export_names(self, root, source: bytes) -> set[str]: + """Return module-local symbols explicitly exported as ``default``.""" + + names: set[str] = set() + for statement in root.named_children: + if statement.type != "export_statement": + continue + if any(child.type == "default" for child in statement.children): + target = statement.child_by_field_name("declaration") or statement.child_by_field_name("value") + if target is not None: + name = target.child_by_field_name("name") or target + if name.type in ("identifier", "type_identifier"): + names.add(self._node_text(name, source)) + for clause in statement.named_children: + if clause.type != "export_clause": + continue + for specifier in clause.named_children: + if specifier.type != "export_specifier": + continue + name = specifier.child_by_field_name("name") + alias = specifier.child_by_field_name("alias") + if name is not None and alias is not None and self._node_text(alias, source) == "default": + names.add(self._node_text(name, source)) + return names + + def _is_top_level(self, node) -> bool: + parent = node.parent + if parent is None: + return False + if parent.type == "program": + return True + return parent.type == "export_statement" and parent.parent is not None and parent.parent.type == "program" + + def _collect_symbols(self, root, path, line_count, file_key, nodes, observations, diagnostics) -> None: + assigner = DiscriminatorAssigner() + source = root.text # bytes of the whole tree + default_export_names = self._default_export_names(root, source) + + def emit(name_node, decl_node, scope, exported): + """Emit one symbol node + its definition observation; return the name.""" + name = self._node_text(name_node, source) + base_key = symbol_stable_key(path, scope, name) + final_key, duplicate = assigner.key(base_key) + key = canonical.normalize_stable_key("symbol", final_key) + # tree-sitter rows are 0-based; RFC spans are 1-based inclusive. + ev, diag = build_evidence( + path, + decl_node.start_point[0] + 1, + decl_node.end_point[0] + 1, + line_count, + producer=self.producer, + ) + if ev is None: + if diag is not None: + diagnostics.append(diag) + return None + is_default_export = name in default_export_names + properties = None + if exported or is_default_export: + properties = {"exported": True} + if is_default_export: + properties["default_export"] = True + nodes.append( + ExtractedNode( + node_kind="symbol", + stable_key=key, + name=name, + language="typescript", + evidence=(ev,), + properties=properties, + ) + ) + observations.append( + ExtractedObservation( + observed_kind="definition", + subject_kind="symbol", + subject_key=key, + referent_text=None, + ordinal=_UNASSIGNED_ORDINAL, + evidence=ev, + ) + ) + if duplicate: + diagnostics.append( + ExtractedDiagnostic( + code=RI_KEY_DUP_SYMBOL, + category="duplicate symbol", + severity="info", + # The key lives in `subject`, the field meant for it; + # repeating it here would put source-derived text in + # `message`, which RFC §13 reserves from content. + message="duplicate symbol name resolved with a discriminator", + path=canonical.normalize_repo_path(path), + subject=key, + ) + ) + return name + + def visit(node, scope): + # Top-level const/let/var bindings become symbols (RFC §4.3). Their + # initializer expressions are intentionally not descended into here; + # route literals inside them are found by the separate route pass. + if node.type == "lexical_declaration": + if self._is_top_level(node): + exported = self._is_exported(node) + for declarator in node.named_children: + if declarator.type != "variable_declarator": + continue + name_node = declarator.child_by_field_name("name") + if name_node is not None and name_node.type == "identifier": + emit(name_node, declarator, scope, exported) + return + + child_scope = scope + field = _NAMED_DECLARATIONS.get(node.type) + if field is not None: + name_node = node.child_by_field_name(field) + if name_node is not None: + # A method_definition arrives here with its enclosing class + # already in `scope`, so the unified path qualifies it as + # Class.method with no special-casing. + emitted = emit(name_node, node, scope, self._is_exported(node)) + if emitted is not None: + child_scope = [*scope, emitted] + + for child in node.named_children: + visit(child, child_scope) + + visit(root, []) diff --git a/apps/backend/app/github/client.py b/apps/backend/app/github/client.py index c0f574ee..d0c3988c 100644 --- a/apps/backend/app/github/client.py +++ b/apps/backend/app/github/client.py @@ -1,3 +1,4 @@ +import logging import os import re import shutil @@ -7,6 +8,8 @@ from app.core.config import Settings from app.core.exceptions import ExternalServiceError, TimeoutServiceError, ValidationServiceError +logger = logging.getLogger(__name__) + GITHUB_RE = re.compile(r"^https://github\.com/[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+/?(?:\.git)?$") BRANCH_RE = re.compile(r"^[A-Za-z0-9._/-]+$") @@ -14,6 +17,7 @@ class GitHubClient: def __init__(self, settings: Settings) -> None: self.timeout_seconds = settings.clone_timeout_seconds + self.max_clone_size_bytes = settings.max_clone_size_bytes def validate_public_url(self, url: str) -> str: normalized = url.rstrip("/") @@ -38,6 +42,75 @@ def validate_branch(self, branch: str | None) -> str | None: def repository_name(self, url: str) -> str: return url.rstrip("/").split("/")[-1].removesuffix(".git") + def read_head_commit(self, repo_dir: Path) -> str | None: + """Return the cloned repository's HEAD commit SHA, or None if unavailable.""" + try: + result = subprocess.run( + ["git", "rev-parse", "HEAD"], + cwd=str(repo_dir), + check=True, + capture_output=True, + text=True, + timeout=self.timeout_seconds, + ) + except (subprocess.SubprocessError, OSError) as exc: + logger.warning("Unable to read HEAD commit for cloned repository (%s).", type(exc).__name__) + return None + sha = result.stdout.strip() + return sha or None + + def read_head_ref(self, repo_dir: Path, requested_ref: str | None = None) -> str | None: + """Return the resolved ref HEAD points at (e.g. ``refs/heads/main``). + + Recorded alongside the commit SHA as descriptive revision metadata + (RFC §3.2); it is a moving pointer and never revision identity. A + detached tag clone is resolved only after git confirms the requested tag + points at HEAD. Returns ``None`` when git cannot answer unambiguously. + """ + try: + result = subprocess.run( + ["git", "symbolic-ref", "HEAD"], + cwd=str(repo_dir), + check=True, + capture_output=True, + text=True, + timeout=self.timeout_seconds, + ) + except (subprocess.SubprocessError, OSError): + # A tag clone has detached HEAD. Confirm the requested name against + # refs instead of fabricating a refs/heads value from user input. + if requested_ref: + for candidate, normalized in ( + (f"refs/heads/{requested_ref}", f"refs/heads/{requested_ref}"), + (f"refs/tags/{requested_ref}", f"refs/tags/{requested_ref}"), + (f"refs/remotes/origin/{requested_ref}", f"refs/heads/{requested_ref}"), + ): + try: + resolved = subprocess.run( + ["git", "rev-parse", "--verify", f"{candidate}^{{commit}}"], + cwd=str(repo_dir), + check=True, + capture_output=True, + text=True, + timeout=self.timeout_seconds, + ).stdout.strip() + head = subprocess.run( + ["git", "rev-parse", "HEAD"], + cwd=str(repo_dir), + check=True, + capture_output=True, + text=True, + timeout=self.timeout_seconds, + ).stdout.strip() + except (subprocess.SubprocessError, OSError): + continue + if resolved and resolved == head: + return normalized + logger.warning("Unable to resolve HEAD to a normalized Git ref for cloned repository.") + return None + ref = result.stdout.strip() + return ref if ref.startswith("refs/") else None + def clone_public_repository(self, url: str, destination: Path, branch: str | None = None) -> None: destination.parent.mkdir(parents=True, exist_ok=True) env = os.environ.copy() @@ -63,8 +136,41 @@ def clone_public_repository(self, url: str, destination: Path, branch: str | Non ) from exc except (subprocess.CalledProcessError, OSError) as exc: shutil.rmtree(destination, ignore_errors=True) - stderr = exc.stderr if isinstance(exc, subprocess.CalledProcessError) else str(exc) + return_code = exc.returncode if isinstance(exc, subprocess.CalledProcessError) else None + # Raw stderr can contain the destination's absolute host path and is + # intentionally not logged. + logger.warning( + "git clone failed for public repository (error_type=%s, return_code=%s).", + type(exc).__name__, + return_code, + ) raise ExternalServiceError( "Failed to clone GitHub repository. Confirm the repository is public and the branch exists.", - {"stderr": stderr}, ) from exc + + self._enforce_clone_size(destination) + + def _enforce_clone_size(self, destination: Path) -> None: + total = self._directory_size(destination) + if total > self.max_clone_size_bytes: + shutil.rmtree(destination, ignore_errors=True) + raise ValidationServiceError( + "Cloned repository exceeds the configured maximum size.", + {"maxCloneSizeBytes": self.max_clone_size_bytes}, + ) + + def _directory_size(self, path: Path) -> int: + total = 0 + for root, dirs, files in os.walk(path, followlinks=False): + for name in files: + child = Path(root, name) + try: + if not child.is_symlink(): + total += child.stat().st_size + except OSError: + continue + # Do not descend into symlinked directories: prevents a crafted + # symlink (e.g. to / or a parent dir) from causing disclosure or a + # size-measurement loop during clone budget enforcement. + dirs[:] = [d for d in dirs if not Path(root, d).is_symlink()] + return total diff --git a/apps/backend/app/graph/dependency_graph.py b/apps/backend/app/graph/dependency_graph.py index 79e2f66f..08bf4e30 100644 --- a/apps/backend/app/graph/dependency_graph.py +++ b/apps/backend/app/graph/dependency_graph.py @@ -1,37 +1,201 @@ -from app.intelligence.engine import RepositoryIntelligenceEngine +"""Dependency Graph read model, built exclusively from a sealed ri.v1 snapshot (#158). + +No filesystem read, no working-tree fallback, and no legacy +``repo_metadata['intelligence']`` read: every field is derived from +:class:`SnapshotQueryService` facts. A repository with no sealed snapshot for +its current revision raises ``NotFoundError`` (via +``require_sealed_snapshot_for_current_revision``) — the same 404 contract +Architecture, Review, and Insights already use, with no legacy fallback. +""" + +from __future__ import annotations + +import posixpath + +from sqlalchemy import select + +from app.analysis.manifest import build_manifest, manifest_digest +from app.intelligence.query_service import SnapshotQueryService +from app.extraction.support_matrix import supported_manifest_filenames from app.models.repository import RepositoryRecord -from app.schemas.dependencies import DependencyEdge, DependencyGraphResponse, DependencyNode +from app.models.snapshot import RiDiagnostic, RiEdge, RiEvidence, RiNode +from app.schemas.dependencies import ( + DependencyAssessment, + DependencyDeclaration, + DependencyDiagnostic, + DependencyEdge, + DependencyGraphResponse, + DependencyNode, + DependencyProvenance, +) + +#: Manifest filenames ``DependencyManifestExtractor`` supports (app/extraction/manifests.py). +#: A diagnostic is "relevant to dependency extraction" only when it names one of these paths — +#: the same scoping the legacy engine got for free by only ever iterating manifest-supporting files. +_MANIFEST_FILENAMES = frozenset(supported_manifest_filenames()) +#: Codes the manifest extractor and the pipeline's size-budget gate can emit for a manifest file. +_RELEVANT_DIAGNOSTIC_CODES = frozenset({"RI-SRC-MALFORMED", "RI-LIMIT-SKIP"}) class DependencyGraphBuilder: - def __init__(self, intelligence: RepositoryIntelligenceEngine | None = None) -> None: - self.intelligence = intelligence or RepositoryIntelligenceEngine() + """Build the Dependency Graph response solely from an owner-scoped sealed snapshot.""" + + def __init__(self, snapshots: SnapshotQueryService) -> None: + self.snapshots = snapshots def build(self, record: RepositoryRecord) -> DependencyGraphResponse: - repository_intelligence = self.intelligence.from_record(record) - nodes = [ - DependencyNode( - id=dependency.id, - name=dependency.name, - version=dependency.version, - type=dependency.type, - has_vulnerabilities=False, - is_outdated=False, - size=None, + snapshot = self.snapshots.require_sealed_snapshot_for_current_revision(record.id) + + dependency_nodes = [ + item + for item in self.snapshots.db.scalars( + select(RiNode) + .where(RiNode.snapshot_id == snapshot.snapshot_id, RiNode.node_kind == "dependency") + .order_by(RiNode.stable_key, RiNode.id) ) - for dependency in repository_intelligence.dependencies + if self._is_declared(item) ] - dependency_ids = {dependency.id for dependency in repository_intelligence.dependencies} + node_evidence = self.snapshots.evidence_for_nodes(snapshot, dependency_nodes) + nodes = [self._node(item, node_evidence.get(item.id, [])) for item in dependency_nodes] + dependency_keys = {item.stable_key for item in dependency_nodes} + + depends_on_edges = list( + self.snapshots.db.scalars( + select(RiEdge) + .where(RiEdge.snapshot_id == snapshot.snapshot_id, RiEdge.predicate == "depends_on") + .order_by(RiEdge.subject_key, RiEdge.object_key, RiEdge.edge_id, RiEdge.id) + ) + ) edges = [ - DependencyEdge(source=relationship.source, target=relationship.target, type="depends-on") - for relationship in repository_intelligence.graph.relationships - if relationship.type == "depends_on" and relationship.target in dependency_ids + DependencyEdge(id=edge.edge_id, source=edge.subject_key, target=edge.object_key, type="depends-on") + for edge in depends_on_edges + if edge.object_key in dependency_keys ] + + diagnostics = [ + item + for item in self.snapshots.db.scalars( + select(RiDiagnostic) + .where( + RiDiagnostic.snapshot_id == snapshot.snapshot_id, RiDiagnostic.code.in_(_RELEVANT_DIAGNOSTIC_CODES) + ) + .order_by(RiDiagnostic.path, RiDiagnostic.code, RiDiagnostic.id) + ) + if item.path and posixpath.basename(item.path) in _MANIFEST_FILENAMES + ] + + manifest_paths = {declaration.manifest_path for node in nodes for declaration in node.declarations} + manifest_paths.update(item.path for item in diagnostics if item.path) + + manifest = build_manifest(snapshot) return DependencyGraphResponse( repository_id=record.id, + repository_name=record.name, + revision_kind=snapshot.revision_kind, # type: ignore[arg-type] + revision_value=snapshot.revision_value, + snapshot_id=snapshot.snapshot_id, + snapshot_schema_version=snapshot.schema_version, + canonical_graph_hash=snapshot.canonical_graph_hash, + manifest_digest=manifest_digest(manifest), + provenance=DependencyProvenance( + snapshot_id=snapshot.snapshot_id, + snapshot_schema_version=snapshot.schema_version, + canonical_graph_hash=snapshot.canonical_graph_hash, + ), + generated_at=snapshot.sealed_at, nodes=nodes, edges=edges, total_dependencies=len(nodes), - vulnerabilities=0, - outdated=0, + manifest_count=len(manifest_paths), + diagnostics=[ + DependencyDiagnostic( + code=item.code, + category=item.category, + severity=item.severity, # type: ignore[arg-type] + message=item.message, + path=item.path, + producer=item.producer, + details=dict(item.details) if item.details is not None else None, + ) + for item in diagnostics + ], + vulnerability_assessment=DependencyAssessment(status="not_computed"), + outdated_assessment=DependencyAssessment(status="not_computed"), + ) + + @staticmethod + def _is_declared(item: RiNode) -> bool: + """Keep only dependencies a manifest actually declares. + + Since #209 a ``dependency`` node can exist purely because a lockfile + pinned it, which is every transitive package in a real ``package-lock``. + This response is the *direct* dependency graph, so rendering one would + claim the repository depends on a package it never asked for — and it + would arrive with no version and a ``multiple`` type, because it has no + declarations to summarize. A pre-#156 snapshot stored its single + declaration flat on the node, so that shape counts as declared too. + """ + + properties = item.properties or {} + declarations = properties.get("declarations") + if declarations is None: + return bool(properties.get("manifest_path")) + return bool(declarations) + + @staticmethod + def _node(item: RiNode, evidence: list[RiEvidence]) -> DependencyNode: + properties = item.properties or {} + ecosystem = str(properties.get("ecosystem") or "") + raw_declarations = properties.get("declarations") + if raw_declarations is None: + # A node sealed before #156 stored one flat declaration directly on + # the node's own properties, with the node's own evidence entry + # carrying its line span and extractor identity. Sealed snapshots + # are immutable, so a pre-fix snapshot can still be read this way + # rather than raising on an older, still-valid shape. + first_evidence = evidence[0] if evidence else None + raw_declarations = ( + [ + { + "name": item.name, + "version": properties.get("version"), + "dependency_type": properties.get("dependency_type"), + "manifest_path": properties.get("manifest_path"), + "workspace_path": properties.get("workspace_path"), + "start_line": first_evidence.start_line if first_evidence else 1, + "end_line": first_evidence.end_line if first_evidence else 1, + "extractor": first_evidence.extractor if first_evidence else "", + "extractor_version": first_evidence.extractor_version if first_evidence else "", + } + ] + if properties.get("manifest_path") + else [] + ) + declarations = sorted( + ( + DependencyDeclaration( + name=str(declaration.get("name") or item.name or ""), + manifest_path=str(declaration.get("manifest_path") or ""), + workspace_path=str(declaration.get("workspace_path") or ""), + start_line=int(declaration.get("start_line") or 1), + end_line=int(declaration.get("end_line") or 1), + extractor=str(declaration.get("extractor") or ""), + extractor_version=str(declaration.get("extractor_version") or ""), + ecosystem=ecosystem, + version=declaration.get("version"), + type=declaration.get("dependency_type"), # type: ignore[arg-type] + ) + for declaration in raw_declarations + ), + key=lambda declaration: (declaration.manifest_path, declaration.start_line, declaration.end_line), + ) + versions = {declaration.version for declaration in declarations} + types = {declaration.type for declaration in declarations} + return DependencyNode( + id=item.stable_key, + name=item.name or item.stable_key, + version=next(iter(versions)) if len(versions) == 1 else None, + type=next(iter(types)) if len(types) == 1 else "multiple", + ecosystem=ecosystem, + declarations=declarations, ) diff --git a/apps/backend/app/insights/__init__.py b/apps/backend/app/insights/__init__.py new file mode 100644 index 00000000..bda4c28b --- /dev/null +++ b/apps/backend/app/insights/__init__.py @@ -0,0 +1 @@ +"""Authentic repository insights derived from sealed Repository Intelligence.""" diff --git a/apps/backend/app/insights/relationship_diagnostics.py b/apps/backend/app/insights/relationship_diagnostics.py new file mode 100644 index 00000000..574bdc05 --- /dev/null +++ b/apps/backend/app/insights/relationship_diagnostics.py @@ -0,0 +1,236 @@ +"""Tell a real relationship gap apart from a reference into external code. + +The relationship resolver is deliberately strict: a reference it cannot prove +points at an in-repo symbol becomes an ``RI-RES-UNRESOLVED`` warning rather than +a guessed edge (see ``docs/architecture/REPOSITORY_INTELLIGENCE_RESOLUTION.md``). +That is correct, honest data and it stays in the sealed graph untouched. + +But most of those warnings, on any real repository, are not coverage gaps at +all -- they are calls into a third-party dependency (``jsonify``, ``MagicMock``, +``useState``) or the language platform (``Path``, ``SimpleNamespace``, ``len``), +and imports of packages the repo actually declares or of the standard library. +There is nothing in-repo for the resolver to point those at, and nothing wrong. +Surfacing all of them together -- as one "Unresolved relationships" number in +Repository Insights, or as a red module badge and a giant diagnostics list on +the Architecture page -- makes a healthy analysis look broken. + +This module applies the same "this identifier plainly belongs to the +language/platform, not to this repository" judgment the review layer already +uses for unresolved imports (``app/review/import_dispositions.py``, #412), +extended to bare-name reference observations via their file's import bindings. +Read-time only: it changes no stored fact. +""" + +from __future__ import annotations + +import builtins +from collections import defaultdict +from dataclasses import dataclass + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.models.snapshot import RiEvidence, RiNode, RiObservation +from app.review.import_dispositions import is_recognized_external_import + +#: Observation kinds that record a bare referenced name whose binding (if any) +#: is an ``import_binding`` for the same source file. ``import`` is handled +#: separately (its own referent already *is* the module specifier). +_REFERENCE_KINDS = frozenset({"call", "implements", "injects", "route_handler"}) + +#: A bare call to one of these, with no import binding, is a call into the +#: language itself, not a missing in-repo definition. Mirrors the extraction +#: layer's own builtin skip (``app/extraction/python.py``, #392); repeated +#: here so an already-sealed snapshot from before that fix -- whose bare +#: ``len`` / ``print`` / ``str`` calls are already recorded -- still reads +#: sensibly at read time. +_PYTHON_BUILTIN_NAMES: frozenset[str] = frozenset(name for name in dir(builtins) if not name.startswith("_")) + +#: JS/TS globals that are called but never imported: test-runner injections +#: and a few ubiquitous host objects. A curated, documented list in the same +#: spirit as ``import_dispositions.NODE_BUILTIN_MODULES`` -- there is no +#: runtime to introspect. +_JS_AMBIENT_GLOBAL_NAMES: frozenset[str] = frozenset( + { + "describe", + "it", + "test", + "expect", + "beforeAll", + "beforeEach", + "afterAll", + "afterEach", + "vi", + "jest", + "waitFor", + "within", + "fireEvent", + "fetch", + "structuredClone", + "queueMicrotask", + "requestAnimationFrame", + "cancelAnimationFrame", + "setTimeout", + "clearTimeout", + "setInterval", + "clearInterval", + "alert", + "confirm", + "prompt", + } +) + + +@dataclass(frozen=True) +class UnresolvedRelationshipContext: + """Everything needed to classify one snapshot's ``RI-RES-UNRESOLVED`` + diagnostics, loaded once per read. + + ``observation_id`` here is the sealed ``RiObservation.observation_id`` + string carried in a diagnostic's ``details['observation_id']``. + """ + + observed_kind_by_observation: dict[str, str] + referent_by_observation: dict[str, str | None] + #: source path -> {local name: module specifier} from that file's import bindings + import_specifier_by_local_name: dict[str, dict[str, str]] + declared_dependency_keys: frozenset[str] + + @classmethod + def empty(cls) -> UnresolvedRelationshipContext: + return cls({}, {}, {}, frozenset()) + + +@dataclass(frozen=True) +class UnresolvedRelationshipSplit: + """Counts for one snapshot's ``RI-RES-UNRESOLVED`` diagnostics.""" + + #: References PARTHA expected to resolve within the repository but could + #: not -- a relative import that matched no file, a bare name with no + #: binding and no same-file definition, a shadowed call. The signal worth + #: surfacing. + in_repo_gap: int + #: References whose target is the standard library / language platform or + #: a package the repository declares as a dependency. Expected, not a gap. + external_reference: int + + @property + def total(self) -> int: + return self.in_repo_gap + self.external_reference + + +def load_unresolved_relationship_context(db: Session, snapshot_id: str) -> UnresolvedRelationshipContext: + """Read the observation / import-binding / dependency facts for one snapshot. + + Sealed rows only; nothing here mutates or interprets the graph. + """ + + observed_kind_by_observation: dict[str, str] = {} + referent_by_observation: dict[str, str | None] = {} + binding_referent_by_pk: dict[int, str] = {} + for pk, observation_id, kind, referent in db.execute( + select( + RiObservation.id, + RiObservation.observation_id, + RiObservation.observed_kind, + RiObservation.referent_text, + ).where(RiObservation.snapshot_id == snapshot_id) + ).all(): + observed_kind_by_observation[observation_id] = kind + referent_by_observation[observation_id] = referent + if kind == "import_binding" and referent: + binding_referent_by_pk[pk] = referent + + # An import_binding's own subject_key is directory-scoped for Python, so its + # evidence path is the only exact source-file link. One join keeps this to + # the binding rows regardless of how many imports the repo has. + import_specifier_by_local_name: dict[str, dict[str, str]] = defaultdict(dict) + if binding_referent_by_pk: + for observation_ref, path in db.execute( + select(RiEvidence.observation_ref, RiEvidence.path) + .join(RiObservation, RiObservation.id == RiEvidence.observation_ref) + .where( + RiEvidence.snapshot_id == snapshot_id, + RiObservation.observed_kind == "import_binding", + ) + ).all(): + referent = binding_referent_by_pk.get(observation_ref) + if referent is None or not path: + continue + parts = referent.split("|", 2) + if len(parts) == 3 and parts[0] and parts[2]: + import_specifier_by_local_name[path].setdefault(parts[2], parts[0]) + + declared_dependency_keys = frozenset( + db.scalars( + select(RiNode.stable_key).where( + RiNode.snapshot_id == snapshot_id, + RiNode.node_kind == "dependency", + ) + ).all() + ) + + return UnresolvedRelationshipContext( + observed_kind_by_observation=observed_kind_by_observation, + referent_by_observation=referent_by_observation, + import_specifier_by_local_name=dict(import_specifier_by_local_name), + declared_dependency_keys=declared_dependency_keys, + ) + + +def is_external_unresolved( + source_path: str | None, + observation_id: str | None, + ctx: UnresolvedRelationshipContext, +) -> bool: + """True if this ``RI-RES-UNRESOLVED`` diagnostic is a reference into + external / platform code rather than an in-repo coverage gap. + + Unknown observation, missing path, or a kind that carries no usable + referent all return ``False`` -- the conservative direction (treat it as a + real gap). + """ + + if not source_path: + return False + kind = ctx.observed_kind_by_observation.get(observation_id or "") + referent = ctx.referent_by_observation.get(observation_id or "") + if kind == "import": + if not referent: + return False + return is_recognized_external_import(referent, source_path, ctx.declared_dependency_keys) + if kind in _REFERENCE_KINDS and referent: + specifier = ctx.import_specifier_by_local_name.get(source_path, {}).get(referent) + if specifier is None: + return _is_platform_reference(referent, source_path) + return is_recognized_external_import(specifier, source_path, ctx.declared_dependency_keys) + return False + + +def split_unresolved_relationships( + diagnostics: list[tuple[str | None, str | None]], + ctx: UnresolvedRelationshipContext, +) -> UnresolvedRelationshipSplit: + """Bucket every ``RI-RES-UNRESOLVED`` diagnostic into gap vs. external. + + ``diagnostics`` is ``(source_path, observation_id)`` per diagnostic. + """ + + in_repo_gap = 0 + external_reference = 0 + for source_path, observation_id in diagnostics: + if is_external_unresolved(source_path, observation_id, ctx): + external_reference += 1 + else: + in_repo_gap += 1 + return UnresolvedRelationshipSplit(in_repo_gap=in_repo_gap, external_reference=external_reference) + + +def _is_platform_reference(name: str, source_path: str) -> bool: + """True if a bare, unbound call name belongs to the language/host itself.""" + + if source_path.endswith(".py"): + return name in _PYTHON_BUILTIN_NAMES + if source_path.endswith((".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs")): + return name in _JS_AMBIENT_GLOBAL_NAMES + return False diff --git a/apps/backend/app/insights/service.py b/apps/backend/app/insights/service.py new file mode 100644 index 00000000..f1b7dc0f --- /dev/null +++ b/apps/backend/app/insights/service.py @@ -0,0 +1,338 @@ +"""Deterministic repository metrics over one sealed ``ri.v1`` snapshot.""" + +from __future__ import annotations + +from sqlalchemy import func, select + +from app.analysis.manifest import build_manifest, manifest_digest +from app.insights.relationship_diagnostics import ( + UnresolvedRelationshipSplit, + load_unresolved_relationship_context, + split_unresolved_relationships, +) +from app.intelligence.query_service import SnapshotQueryService +from app.models.repository import RepositoryRecord +from app.models.snapshot import RiDiagnostic, RiEdge, RiEvidence, RiNode +from app.schemas.insights import ( + InsightBreakdown, + InsightExtractor, + InsightMetric, + InsightProvenance, + RepositoryInsightsResponse, +) + + +def _producer_parts(producer: str) -> tuple[str, str]: + name, separator, version = producer.rpartition("@") + return (name, version) if separator else (producer, "") + + +class RepositoryInsightsBuilder: + """Compute only metrics whose definitions are exact and snapshot-local.""" + + def __init__(self, snapshots: SnapshotQueryService) -> None: + self.snapshots = snapshots + + def build(self, record: RepositoryRecord) -> RepositoryInsightsResponse: + snapshot = self.snapshots.require_sealed_snapshot_for_current_revision(record.id) + + snapshot_id = snapshot.snapshot_id + provenance = InsightProvenance( + snapshot_id=snapshot_id, + snapshot_schema_version=snapshot.schema_version, + canonical_graph_hash=snapshot.canonical_graph_hash, + ) + + node_counts = { + kind: count + for kind, count in self.snapshots.db.execute( + select(RiNode.node_kind, func.count(RiNode.id)) + .where(RiNode.snapshot_id == snapshot_id) + .group_by(RiNode.node_kind) + ).all() + } + relationship_counts = { + predicate: count + for predicate, count in self.snapshots.db.execute( + select(RiEdge.predicate, func.count(RiEdge.id)) + .where(RiEdge.snapshot_id == snapshot_id) + .group_by(RiEdge.predicate) + ).all() + } + diagnostic_severity_counts = { + severity: count + for severity, count in self.snapshots.db.execute( + select(RiDiagnostic.severity, func.count(RiDiagnostic.id)) + .where(RiDiagnostic.snapshot_id == snapshot_id) + .group_by(RiDiagnostic.severity) + ).all() + } + diagnostic_code_counts = { + code: count + for code, count in self.snapshots.db.execute( + select(RiDiagnostic.code, func.count(RiDiagnostic.id)) + .where(RiDiagnostic.snapshot_id == snapshot_id) + .group_by(RiDiagnostic.code) + ).all() + } + unresolved_relationships = self._split_unresolved_relationships(snapshot_id) + language_counts = { + language: count + for language, count in self.snapshots.db.execute( + select(RiNode.language, func.count(RiNode.id)) + .where( + RiNode.snapshot_id == snapshot_id, + RiNode.node_kind == "file", + RiNode.language.is_not(None), + ) + .group_by(RiNode.language) + ).all() + if language is not None + } + evidence_count = ( + self.snapshots.db.scalar(select(func.count(RiEvidence.id)).where(RiEvidence.snapshot_id == snapshot_id)) + or 0 + ) + evidence_extractor_counts = { + (name, version): count + for name, version, count in self.snapshots.db.execute( + select( + RiEvidence.extractor, + RiEvidence.extractor_version, + func.count(RiEvidence.id), + ) + .where(RiEvidence.snapshot_id == snapshot_id) + .group_by(RiEvidence.extractor, RiEvidence.extractor_version) + ).all() + } + + eligible_files = { + stable_key.removeprefix("file:") + for stable_key in self.snapshots.db.scalars( + # Only the key is needed, so the row is not hydrated: this set + # is one entry per source file in the repository. + select(RiNode.stable_key).where( + RiNode.snapshot_id == snapshot_id, + RiNode.node_kind == "file", + RiNode.language.is_not(None), + ) + ).all() + } + semantic_paths = set( + self.snapshots.db.scalars( + select(RiEvidence.path) + .where( + RiEvidence.snapshot_id == snapshot_id, + RiEvidence.extractor != "repository-inventory", + ) + .distinct() + ).all() + ) + covered_files = len(eligible_files & semantic_paths) + eligible_count = len(eligible_files) + + def metric( + metric_id: str, + label: str, + value: int | float | str | None, + unit: str, + definition: str, + *, + state: str = "assessed", + numerator: int | None = None, + denominator: int | None = None, + ) -> InsightMetric: + return InsightMetric( + id=metric_id, + label=label, + value=value, + unit=unit, + definition=definition, + provenance=provenance, + assessment_state=state, # type: ignore[arg-type] + snapshot_id=snapshot_id, + numerator=numerator, + denominator=denominator, + ) + + metrics = [ + metric( + "nodes.files.total", + "File nodes", + node_counts.get("file", 0), + "nodes", + "Count of observed ri.v1 nodes whose node kind is file.", + ), + metric( + "nodes.symbols.total", + "Symbol nodes", + node_counts.get("symbol", 0), + "nodes", + "Count of observed ri.v1 nodes whose node kind is symbol.", + ), + metric( + "nodes.dependencies.total", + "Dependency nodes", + node_counts.get("dependency", 0), + "nodes", + "Count of observed ri.v1 nodes whose node kind is dependency.", + ), + metric( + "relationships.resolved.total", + "Resolved relationships", + sum(relationship_counts.values()), + "relationships", + "Count of sealed ri.v1 edges. Every stored edge has resolved truth class.", + ), + metric( + "evidence.records.total", + "Evidence records", + evidence_count, + "records", + "Count of stored source evidence records in the selected snapshot.", + ), + metric( + "diagnostics.relationships.unresolved", + "Unresolved relationships", + unresolved_relationships.in_repo_gap, + "diagnostics", + "Sealed RI-RES-UNRESOLVED diagnostics whose reference was expected to " + "resolve within this repository but could not -- a relative import that " + "matched no file, or a bare name with no binding and no same-file " + "definition. Excludes references into external code (counted separately). " + "The raw RI-RES-UNRESOLVED total is in Diagnostics by code.", + ), + metric( + "diagnostics.relationships.external-references", + "References into external code", + unresolved_relationships.external_reference, + "diagnostics", + "Sealed RI-RES-UNRESOLVED diagnostics whose target is the standard " + "library / language platform or a package this repository declares as a " + "dependency. Expected -- that code is outside the analysed repository, so " + "there is no in-repo definition to resolve to.", + ), + metric( + "diagnostics.relationships.ambiguous", + "Ambiguous relationships", + diagnostic_code_counts.get("RI-RES-AMBIGUOUS", 0), + "diagnostics", + "Count of sealed diagnostics with code RI-RES-AMBIGUOUS.", + ), + metric( + "diagnostics.source.unsupported", + "Unsupported source constructs", + diagnostic_code_counts.get("RI-EXT-UNSUPPORTED", 0), + "diagnostics", + "Count of sealed diagnostics with code RI-EXT-UNSUPPORTED.", + ), + metric( + "diagnostics.source.malformed", + "Malformed source files", + diagnostic_code_counts.get("RI-SRC-MALFORMED", 0), + "diagnostics", + "Count of sealed diagnostics with code RI-SRC-MALFORMED.", + ), + metric( + "extraction.files.semantic", + "Files with semantic extraction evidence", + covered_files, + "files", + "Eligible file paths with at least one evidence record from an extractor other than repository-inventory.", + ), + metric( + "extraction.files.eligible", + "Eligible files", + eligible_count, + "files", + "Observed file nodes whose stored language is supported by a semantic extractor.", + ), + metric( + "extraction.coverage", + "Semantic extraction coverage", + covered_files / eligible_count if eligible_count else None, + "ratio", + "Files with semantic extraction evidence divided by total eligible files.", + state="assessed" if eligible_count else "insufficient_evidence", + numerator=covered_files, + denominator=eligible_count, + ), + metric( + "assessment.vulnerability-scanning", + "Vulnerability scanning", + None, + "status", + "No vulnerability scanner contributes facts to repository-insights.v1.", + state="not_assessed", + ), + ] + + producer_set = [_producer_parts(str(item)) for item in snapshot.producer_version_set or []] + # Include an evidence extractor even if a legacy snapshot omitted it + # from producer_version_set; this is still observed provenance, not an + # invented producer. Deterministic sorting keeps responses stable. + all_extractors = sorted(set(producer_set) | set(evidence_extractor_counts)) + extractors = [ + InsightExtractor( + name=name, + version=version, + evidence_record_count=evidence_extractor_counts.get((name, version), 0), + ) + for name, version in all_extractors + ] + + manifest = build_manifest(snapshot) + return RepositoryInsightsResponse( + repository_id=record.id, + repository_name=record.name, + revision_kind=snapshot.revision_kind, # type: ignore[arg-type] + revision_value=snapshot.revision_value, + snapshot_id=snapshot_id, + snapshot_schema_version=snapshot.schema_version, + canonical_graph_hash=snapshot.canonical_graph_hash, + manifest_digest=manifest_digest(manifest), + provenance=provenance, + # A deterministic computation over immutable facts is valid as of + # sealing time; using wall-clock time would make identical reads + # needlessly differ. + computed_at=snapshot.sealed_at, + snapshot_created_at=snapshot.created_at, + snapshot_sealed_at=snapshot.sealed_at, + extractor_set=extractors, + metrics=metrics, + relationships_by_predicate=[ + InsightBreakdown(key=key, label=key.replace("_", " "), value=value) + for key, value in sorted(relationship_counts.items()) + ], + diagnostics_by_severity=[ + InsightBreakdown(key=key, label=key, value=value) + for key, value in sorted(diagnostic_severity_counts.items()) + ], + diagnostics_by_code=[ + InsightBreakdown(key=key, label=key, value=value) + for key, value in sorted(diagnostic_code_counts.items()) + ], + languages=[ + InsightBreakdown(key=key, label=key, value=value) for key, value in sorted(language_counts.items()) + ], + ) + + def _split_unresolved_relationships(self, snapshot_id: str) -> UnresolvedRelationshipSplit: + """Bucket this snapshot's RI-RES-UNRESOLVED diagnostics into genuine + in-repo gaps vs. expected references into external code (#412 judgment, + extended to bare-name references). Reads sealed rows only; changes no + stored fact.""" + + db = self.snapshots.db + diagnostics: list[tuple[str | None, str | None]] = [ + (path, (details or {}).get("observation_id")) + for path, details in db.execute( + select(RiDiagnostic.path, RiDiagnostic.details).where( + RiDiagnostic.snapshot_id == snapshot_id, + RiDiagnostic.code == "RI-RES-UNRESOLVED", + ) + ).all() + ] + if not diagnostics: + return UnresolvedRelationshipSplit(in_repo_gap=0, external_reference=0) + return split_unresolved_relationships(diagnostics, load_unresolved_relationship_context(db, snapshot_id)) diff --git a/apps/backend/app/intelligence/__init__.py b/apps/backend/app/intelligence/__init__.py index d51d2584..92f35fa3 100644 --- a/apps/backend/app/intelligence/__init__.py +++ b/apps/backend/app/intelligence/__init__.py @@ -1,4 +1,10 @@ -from app.intelligence.engine import RepositoryIntelligenceEngine from app.intelligence.models import RepositoryIntelligence +from app.intelligence.resolution import RelationshipResolver, ResolutionResult +from app.intelligence.snapshot_store import SnapshotStore -__all__ = ["RepositoryIntelligenceEngine", "RepositoryIntelligence"] +__all__ = [ + "RelationshipResolver", + "RepositoryIntelligence", + "ResolutionResult", + "SnapshotStore", +] diff --git a/apps/backend/app/intelligence/canonical.py b/apps/backend/app/intelligence/canonical.py new file mode 100644 index 00000000..6dd1f9b8 --- /dev/null +++ b/apps/backend/app/intelligence/canonical.py @@ -0,0 +1,690 @@ +"""Canonical serialization, deterministic identities, and the graph hash. + +This module implements the deterministic parts of the Repository Intelligence +v1 contract (RFC-0001) that #88 depends on: + +- canonical UTF-8 JSON with RFC 8785 (JCS) semantics — sorted keys, no + insignificant whitespace, no trailing newline, integers only (RFC §12.1); +- Unicode NFC normalization of every string (RFC §12.4); +- repository-relative POSIX path normalization with traversal/absolute + rejection (RFC §4.2); +- deterministic content identities for edges, observations, and assertions + (RFC §5.3, §6.4, §5.6); +- the ``config_hash`` procedure (RFC §12.7); +- the canonical graph hash over five totally-ordered arrays (RFC §12.2, §12.3). + +The functions here are pure: they operate on plain mappings, not ORM rows, so +they are independently testable and reusable by later extraction/resolution work +(#89-#95) without importing the persistence layer. + +``json.dumps(sort_keys=True)`` is deliberately *not* used on its own. Every +value first passes through :func:`_normalize`, which NFC-normalizes strings, +rejects floats (no float ever appears in hashed content), and rejects +unsupported types, so the byte output satisfies the RFC canonicalization rules +for the integer/string/bool/null/array/object subset this contract uses. The +published RFC digests in §12.7, §5.3, §6.4, and §5.6 are reproduced exactly by +these functions (see ``tests/test_canonical_hash.py``). +""" + +from __future__ import annotations + +import hashlib +import json +import re +import unicodedata +from collections.abc import Mapping, Sequence +from typing import Any + +SCHEMA_VERSION = "ri.v1" +_TOKEN_RE = re.compile(r"^[a-z][a-z0-9_]*$") + +# ASCII unit separator. It cannot appear in a stable key or predicate, so it is +# a safe field separator for the edge-id pre-image (RFC §5.3). +_UNIT_SEPARATOR = "\x1f" + +# Tagged derived_from reference ranking (RFC §12.3). +_REFERENCE_RANK = {"observation": 0, "node": 1, "edge": 2, "assertion": 3} +_REFERENCE_IDENTITY_FIELD = { + "observation": "observation_id", + "node": "stable_key", + "edge": "edge_id", + "assertion": "assertion_id", +} + + +class CanonicalizationError(ValueError): + """Raised when a value cannot be canonicalized (e.g. a float appears).""" + + +class PathEscapeError(ValueError): + """Raised when a path is absolute or escapes the repository root (RFC §4.2).""" + + +def nfc(value: str) -> str: + """Return the Unicode NFC normalization of ``value``.""" + + return unicodedata.normalize("NFC", value) + + +def _normalize(value: Any) -> Any: + if isinstance(value, str): + return nfc(value) + # bool must be checked before int (bool is an int subclass). + if isinstance(value, bool): + return value + if isinstance(value, int): + if not (-(2**53) + 1 <= value <= (2**53) - 1): + raise CanonicalizationError("integers outside the exact JSON/IEEE-754 range are not permitted") + return value + if value is None: + return None + if isinstance(value, float): + raise CanonicalizationError("floats are not permitted in canonical content") + if isinstance(value, Mapping): + normalized: dict[str, Any] = {} + for key, item in value.items(): + normalized_key = nfc(str(key)) + if normalized_key in normalized: + raise CanonicalizationError(f"object keys collide after Unicode normalization: {normalized_key!r}") + normalized[normalized_key] = _normalize(item) + return normalized + if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)): + return [_normalize(item) for item in value] + raise CanonicalizationError(f"unsupported type in canonical content: {type(value)!r}") + + +def canonical_json_bytes(value: Any) -> bytes: + """Serialize ``value`` to canonical JCS bytes (RFC §12.1).""" + + normalized = _normalize(value) + + def serialize(node: Any) -> str: + if node is None: + return "null" + if node is True: + return "true" + if node is False: + return "false" + if isinstance(node, int): + return str(node) + if isinstance(node, str): + # Python's compact JSON string escaping matches JCS for valid + # Unicode strings. ``ensure_ascii=False`` keeps non-ASCII code + # points unescaped, as RFC 8785 requires. + return json.dumps(node, ensure_ascii=False, separators=(",", ":")) + if isinstance(node, list): + return "[" + ",".join(serialize(item) for item in node) + "]" + if isinstance(node, dict): + # JCS sorts property names as arrays of UTF-16 code units, not by + # Python's Unicode code-point ordering. The distinction matters for + # supplementary-plane characters versus high BMP code points. + keys = sorted(node, key=lambda key: key.encode("utf-16be")) + return "{" + ",".join(f"{serialize(key)}:{serialize(node[key])}" for key in keys) + "}" + raise CanonicalizationError(f"unsupported normalized type: {type(node)!r}") + + try: + return serialize(normalized).encode("utf-8") + except UnicodeEncodeError as exc: + raise CanonicalizationError("canonical content contains an invalid Unicode surrogate") from exc + + +def normalize_content(value: Any) -> Any: + """Return the recursively NFC-normalized, canonical JSON value.""" + + return _normalize(value) + + +def normalize_declared_arrays( + value: Any, + *, + set_array_keys: frozenset[str] = frozenset(), + ordered_array_keys: frozenset[str] = frozenset(), + context: str = "content", +) -> Any: + """Normalize JSON while enforcing explicit array semantics (RFC §12.3).""" + + overlap = set_array_keys & ordered_array_keys + if overlap: + raise CanonicalizationError(f"array keys cannot be both set and ordered: {sorted(overlap)!r}") + + def prepare(node: Any, key: str | None = None) -> Any: + if isinstance(node, Mapping): + prepared: dict[str, Any] = {} + for raw_key, nested in node.items(): + normalized_key = nfc(str(raw_key)) + if normalized_key in prepared: + raise CanonicalizationError( + f"{context} keys collide after Unicode normalization: {normalized_key!r}" + ) + prepared[normalized_key] = prepare(nested, normalized_key) + return prepared + if isinstance(node, Sequence) and not isinstance(node, (str, bytes, bytearray)): + if key not in set_array_keys and key not in ordered_array_keys: + raise CanonicalizationError(f"{context} array {key!r} has no declared set/order semantics") + elements = [prepare(item) for item in node] + if key in set_array_keys: + by_bytes = {canonical_json_bytes(element): element for element in elements} + return [by_bytes[encoded] for encoded in sorted(by_bytes)] + return elements + return _normalize(node) + + return prepare(value) + + +def sha256_hex(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def sha256_prefixed(data: bytes) -> str: + """Return ``sha256:`` for ``data``.""" + + return f"sha256:{sha256_hex(data)}" + + +# --------------------------------------------------------------------------- +# Path normalization (RFC §4.2) +# --------------------------------------------------------------------------- + + +def normalize_repo_path(raw: str) -> str: + """Normalize a path to a repository-relative POSIX path (RFC §4.2). + + The repository root itself normalizes to the empty string. Absolute paths + (POSIX ``/...`` or a Windows drive prefix) and paths that lexically escape + the root (a leading ``..`` that cannot be popped) raise + :class:`PathEscapeError` — they never produce a node or evidence record and + are surfaced as ``RI-SEC-PATH-ESCAPE`` diagnostics by callers. + """ + + if raw is None: # defensive: callers must pass a string + raise PathEscapeError("path is required") + text = nfc(str(raw)) + # Windows drive-letter absolute path, e.g. C:\Users -> reject before we + # fold backslashes into separators. + if len(text) >= 2 and text[1] == ":" and text[0].isalpha(): + raise PathEscapeError("absolute path is not repository-relative") + text = text.replace("\\", "/") + if text.startswith("/"): + raise PathEscapeError("absolute path is not repository-relative") + + resolved: list[str] = [] + for segment in text.split("/"): + if segment in ("", "."): + continue + if segment == "..": + if not resolved: + raise PathEscapeError("path escapes the repository root") + resolved.pop() + continue + resolved.append(segment) + return "/".join(resolved) + + +def is_valid_repo_path(raw: str) -> bool: + try: + normalize_repo_path(raw) + except PathEscapeError: + return False + return True + + +def normalize_stable_key(node_kind: str, stable_key: str) -> str: + """Validate and normalize the stable-key forms registered by RFC §4.3. + + Unknown future node kinds remain forward-compatible, but their kind and key + must still be non-empty normalized strings. Registered path-bearing keys are + normalized exactly, so an unnormalized or escaping spelling cannot enter a + snapshot. + """ + + kind = nfc(node_kind) + key = nfc(stable_key) + if not _TOKEN_RE.fullmatch(kind) or not key: + raise CanonicalizationError("node kind and stable key must be non-empty canonical tokens") + if _UNIT_SEPARATOR in key or any(ord(character) < 0x20 for character in key): + raise CanonicalizationError("stable keys cannot contain control characters") + if kind == "repository": + if key != "repo:root": + raise CanonicalizationError("the repository stable key must be 'repo:root'") + return key + if kind == "module": + if not key.startswith("mod:"): + raise CanonicalizationError("module stable keys must start with 'mod:'") + return "mod:" + normalize_repo_path(key[4:]) + if kind == "file": + if not key.startswith("file:"): + raise CanonicalizationError("file stable keys must start with 'file:'") + path = normalize_repo_path(key[5:]) + if not path: + raise CanonicalizationError("file stable keys require a repository-relative path") + return "file:" + path + if kind == "symbol": + if "::" not in key: + raise CanonicalizationError("symbol stable keys require '::'") + path, qualified_name = key.split("::", 1) + normalized_path = normalize_repo_path(path) + if not normalized_path or not qualified_name: + raise CanonicalizationError("symbol stable keys require a path and qualified name") + return f"{normalized_path}::{qualified_name}" + if kind == "dependency": + if not key.startswith("dep:") or key.count(":") < 2: + raise CanonicalizationError("dependency stable keys require 'dep::'") + return key + if kind == "service": + # An outbound service is identified by its absolute origin. Registering + # the form here (rather than letting it fall through as an unknown kind) + # is what stops a relative path or a bare host from entering a snapshot + # as if it were a proven destination. + if not key.startswith("svc:") or "://" not in key[4:]: + raise CanonicalizationError("service stable keys require 'svc:://[:]'") + return key + if kind == "iac_resource": + if not key.startswith("iac:") or "::" not in key[4:]: + raise CanonicalizationError("iac_resource stable keys require 'iac:::/'") + path, qualified = key[4:].split("::", 1) + normalized_path = normalize_repo_path(path) + if not normalized_path or "/" not in qualified: + raise CanonicalizationError("iac_resource stable keys require a manifest path and '/'") + return f"iac:{normalized_path}::{qualified}" + return key + + +def validate_predicate(predicate: str) -> str: + normalized = nfc(predicate) + if not _TOKEN_RE.fullmatch(normalized): + raise CanonicalizationError("predicates must be lowercase snake_case tokens") + return normalized + + +# --------------------------------------------------------------------------- +# Deterministic identities (RFC §5.3, §6.4, §5.6) +# --------------------------------------------------------------------------- + + +def compute_edge_id(subject_key: str, predicate: str, object_key: str) -> str: + """``edge:sha256:`` over the canonical relationship triple (RFC §5.3).""" + + pre_image = _UNIT_SEPARATOR.join((nfc(subject_key), nfc(predicate), nfc(object_key))) + return "edge:" + sha256_prefixed(pre_image.encode("utf-8")) + + +def _identity_evidence(evidence: Mapping[str, Any]) -> dict[str, Any]: + # The observation identity document uses the five minimum evidence fields + # WITHOUT granularity (RFC §6.4 identity document), distinct from the + # granularity-materialized form used when hashing a stored record. + return { + "extractor": evidence["extractor"], + "extractor_version": evidence["extractor_version"], + "path": normalize_repo_path(evidence["path"]), + "start_line": evidence["start_line"], + "end_line": evidence["end_line"], + } + + +def compute_observation_id( + *, + revision_kind: str, + revision_value: str, + observed_kind: str, + subject_kind: str, + subject_key: str, + referent_text: str | None, + ordinal: int, + evidence: Mapping[str, Any], + schema_version: str = SCHEMA_VERSION, +) -> str: + """``obs:sha256:`` over the observation identity document (RFC §6.4).""" + + document = { + "evidence": _identity_evidence(evidence), + "observed_kind": observed_kind, + "ordinal": ordinal, + "referent_text": referent_text, + "revision": {"kind": revision_kind, "value": revision_value}, + "schema_version": schema_version, + "subject": {"kind": subject_kind, "stable_key": subject_key}, + } + return "obs:" + sha256_prefixed(canonical_json_bytes(document)) + + +def _reference_identity(reference: Mapping[str, Any]) -> str: + kind = reference["kind"] + return str(reference[_REFERENCE_IDENTITY_FIELD[kind]]) + + +def sort_derived_from(references: Sequence[Mapping[str, Any]]) -> list[dict[str, Any]]: + """Sort and de-duplicate a tagged ``derived_from`` list (RFC §12.3). + + Ordering is ``(kind_rank, referenced_identity, complete_record_jcs)`` and + byte-identical references collapse. + """ + + seen: set[bytes] = set() + unique: list[tuple[dict[str, Any], bytes]] = [] + for reference in references: + record = _normalize(reference) + kind = record.get("kind") + if kind not in _REFERENCE_IDENTITY_FIELD: + raise CanonicalizationError("derived_from contains an unsupported reference kind") + identity_field = _REFERENCE_IDENTITY_FIELD[kind] + if set(record) != {"kind", identity_field} or not record.get(identity_field): + raise CanonicalizationError("derived_from reference does not match the RFC tagged shape") + encoded = canonical_json_bytes(record) + if encoded in seen: + continue + seen.add(encoded) + unique.append((record, encoded)) + unique.sort(key=lambda item: (_REFERENCE_RANK[item[0]["kind"]], _reference_identity(item[0]), item[1])) + return [record for record, _ in unique] + + +def compute_assertion_id( + *, + subject_kind: str, + subject_key: str, + predicate: str, + value: Mapping[str, Any], + truth_class: str, + producer: str, + producer_version: str, + derived_from: Sequence[Mapping[str, Any]], + schema_version: str = SCHEMA_VERSION, +) -> str: + """``assertion:sha256:`` over the assertion identity document (RFC §5.6).""" + + document = { + "derived_from": sort_derived_from(derived_from), + "predicate": predicate, + "producer": producer, + "producer_version": producer_version, + "schema_version": schema_version, + "subject": {"kind": subject_kind, "stable_key": subject_key}, + "truth_class": truth_class, + "value": value, + } + return "assertion:" + sha256_prefixed(canonical_json_bytes(document)) + + +# --------------------------------------------------------------------------- +# config_hash (RFC §12.7) +# --------------------------------------------------------------------------- + + +def compute_config_hash( + config: Mapping[str, Any] | None, + *, + set_array_keys: frozenset[str] = frozenset(), + path_keys: frozenset[str] = frozenset(), +) -> str: + """Compute ``config_hash`` for the output-affecting configuration (RFC §12.7). + + Arrays preserve order by default. Callers must explicitly declare keys whose + semantics are sets; those arrays are sorted and byte-deduplicated. This is + RFC §12.7's conservative "when in doubt, order-significant" rule. + """ + + if not config: + return sha256_prefixed(b"{}") + + def prepare(node: Any, key: str | None) -> Any: + if isinstance(node, Mapping): + prepared: dict[str, Any] = {} + for raw_key, value in node.items(): + normalized_key = nfc(str(raw_key)) + if normalized_key in prepared: + raise CanonicalizationError(f"config keys collide after Unicode normalization: {normalized_key!r}") + prepared[normalized_key] = prepare(value, normalized_key) + return prepared + if isinstance(node, Sequence) and not isinstance(node, (str, bytes, bytearray)): + elements = [prepare(item, key) for item in node] + if key in set_array_keys: + by_bytes = {canonical_json_bytes(element): element for element in elements} + elements = [by_bytes[encoded] for encoded in sorted(by_bytes)] + return elements + if isinstance(node, str) and key in path_keys: + return normalize_repo_path(node) + return node + + prepared = prepare(dict(config), None) + return sha256_prefixed(canonical_json_bytes(prepared)) + + +# --------------------------------------------------------------------------- +# Producer version set (RFC §3.3, §12.3) +# --------------------------------------------------------------------------- + + +def normalize_producer_version_set(producers: Sequence[str]) -> list[str]: + """Lexicographically sort and de-duplicate ``producer@version`` identifiers.""" + + return sorted({nfc(str(producer)) for producer in producers}, key=lambda value: value.encode("utf-8")) + + +def producer_set_hash(producers: Sequence[str]) -> str: + """Deterministic hash of the normalized producer set for identity indexing.""" + + normalized = normalize_producer_version_set(producers) + return sha256_prefixed(canonical_json_bytes(normalized)) + + +# --------------------------------------------------------------------------- +# Canonical record builders + total ordering (RFC §12.2, §12.3) +# --------------------------------------------------------------------------- + + +def canonical_evidence_record(evidence: Mapping[str, Any]) -> dict[str, Any]: + """Normalize one evidence record, materializing the default granularity.""" + + return { + "path": normalize_repo_path(evidence["path"]), + "start_line": evidence["start_line"], + "end_line": evidence["end_line"], + "granularity": evidence.get("granularity") or "span", + "extractor": evidence["extractor"], + "extractor_version": evidence["extractor_version"], + } + + +def _order_records(records: Sequence[Mapping[str, Any]], key) -> list[dict[str, Any]]: + normalized: list[tuple[dict[str, Any], bytes]] = [] + seen: set[bytes] = set() + for record in records: + prepared = _normalize(record) + encoded = canonical_json_bytes(prepared) + if encoded in seen: + continue + seen.add(encoded) + normalized.append((prepared, encoded)) + normalized.sort(key=lambda item: (*key(item[0]), item[1])) + return [record for record, _ in normalized] + + +def order_evidence(evidence_records: Sequence[Mapping[str, Any]]) -> list[dict[str, Any]]: + prepared = [canonical_evidence_record(record) for record in evidence_records] + return _order_records( + prepared, + lambda record: ( + record["path"], + record["start_line"], + record["end_line"], + record["granularity"], + record["extractor"], + record["extractor_version"], + ), + ) + + +def canonical_node_record(node: Mapping[str, Any], *, schema_version: str = SCHEMA_VERSION) -> dict[str, Any]: + record: dict[str, Any] = { + "kind": "node", + "node_kind": node["node_kind"], + "stable_key": node["stable_key"], + "truth_class": node["truth_class"], + "schema_version": schema_version, + "evidence": order_evidence(node.get("evidence", [])), + } + if node.get("name") is not None: + record["name"] = node["name"] + if node.get("language") is not None: + record["language"] = node["language"] + if node.get("properties"): + record["properties"] = node["properties"] + return record + + +def canonical_edge_record(edge: Mapping[str, Any], *, schema_version: str = SCHEMA_VERSION) -> dict[str, Any]: + return { + "kind": "edge", + "edge_id": edge["edge_id"], + "subject": {"kind": edge["subject_kind"], "stable_key": edge["subject_key"]}, + "predicate": edge["predicate"], + "object": {"kind": edge["object_kind"], "stable_key": edge["object_key"]}, + "truth_class": edge["truth_class"], + "producer": edge["producer"], + "producer_version": edge["producer_version"], + "evidence": order_evidence(edge.get("evidence", [])), + "derived_from": sort_derived_from(edge.get("derived_from", [])), + "schema_version": schema_version, + } + + +def canonical_assertion_record(assertion: Mapping[str, Any], *, schema_version: str = SCHEMA_VERSION) -> dict[str, Any]: + return { + "kind": "assertion", + "assertion_id": assertion["assertion_id"], + "subject": {"kind": assertion["subject_kind"], "stable_key": assertion["subject_key"]}, + "predicate": assertion["predicate"], + "value": assertion["value"], + "truth_class": assertion["truth_class"], + "producer": assertion["producer"], + "producer_version": assertion["producer_version"], + "derived_from": sort_derived_from(assertion.get("derived_from", [])), + "schema_version": schema_version, + } + + +def canonical_observation_record(observation: Mapping[str, Any]) -> dict[str, Any]: + return { + "observation_id": observation["observation_id"], + "observed_kind": observation["observed_kind"], + "subject": {"kind": observation["subject_kind"], "stable_key": observation["subject_key"]}, + "referent_text": observation.get("referent_text"), + "ordinal": observation["ordinal"], + "evidence": canonical_evidence_record(observation["evidence"]), + } + + +def canonical_diagnostic_record(diagnostic: Mapping[str, Any]) -> dict[str, Any]: + span = diagnostic.get("span") + path = diagnostic.get("path") + return { + "code": diagnostic["code"], + "category": diagnostic["category"], + "severity": diagnostic["severity"], + "message": diagnostic["message"], + "producer": diagnostic["producer"], + "path": normalize_repo_path(path) if path is not None else None, + "span": {"start_line": span["start_line"], "end_line": span["end_line"]} if span else None, + "subject": diagnostic.get("subject"), + "object": diagnostic.get("object"), + "details": diagnostic.get("details") or {}, + } + + +def _consolidate_edges( + edges: Sequence[Mapping[str, Any]], *, schema_version: str = SCHEMA_VERSION +) -> list[dict[str, Any]]: + """Collapse duplicate ``(subject, predicate, object)`` triples (RFC §12.3). + + Evidence and ``derived_from`` are set-unioned; every other field must agree + or the graph is internally inconsistent. + """ + + grouped: dict[tuple[str, str, str], dict[str, Any]] = {} + for edge in edges: + record = canonical_edge_record(edge, schema_version=schema_version) + triple = ( + record["subject"]["stable_key"], + record["predicate"], + record["object"]["stable_key"], + ) + if triple not in grouped: + grouped[triple] = record + continue + existing = grouped[triple] + for field in ("edge_id", "truth_class", "producer", "producer_version"): + if existing[field] != record[field]: + raise CanonicalizationError(f"inconsistent edge field {field!r} for duplicate triple {triple}") + existing["evidence"] = order_evidence(existing["evidence"] + record["evidence"]) + existing["derived_from"] = sort_derived_from(existing["derived_from"] + record["derived_from"]) + return list(grouped.values()) + + +def compute_canonical_graph_hash( + *, + revision_kind: str, + revision_value: str, + producer_version_set: Sequence[str], + config_hash: str, + nodes: Sequence[Mapping[str, Any]], + edges: Sequence[Mapping[str, Any]], + assertions: Sequence[Mapping[str, Any]], + observations: Sequence[Mapping[str, Any]], + diagnostics: Sequence[Mapping[str, Any]], + schema_version: str = SCHEMA_VERSION, +) -> str: + """Compute the primary canonical graph hash (RFC §12). + + The result is ``sha256:`` over the canonical document with five totally + ordered arrays plus scalar inputs. Insertion order of any array cannot alter + the result: every array is normalized, de-duplicated, and sorted by a + semantic tuple with the complete JCS record bytes as the final tie-breaker. + Volatile fields (database ids, timestamps, ``actual_producers``, the moving + ``revision.ref``) are excluded because they never enter these records. + """ + + node_records = _order_records( + [canonical_node_record(node, schema_version=schema_version) for node in nodes], + lambda record: (record["stable_key"],), + ) + edge_records = _order_records( + _consolidate_edges(edges, schema_version=schema_version), + lambda record: (record["subject"]["stable_key"], record["predicate"], record["object"]["stable_key"]), + ) + assertion_records = _order_records( + [canonical_assertion_record(assertion, schema_version=schema_version) for assertion in assertions], + lambda record: (record["subject"]["stable_key"], record["predicate"], record["assertion_id"]), + ) + observation_records = _order_records( + [canonical_observation_record(observation) for observation in observations], + lambda record: (record["observation_id"],), + ) + diagnostic_records = _order_records( + [canonical_diagnostic_record(diagnostic) for diagnostic in diagnostics], + lambda record: ( + record["code"], + record["category"], + record["severity"], + record["path"] or "", + (record["span"] or {}).get("start_line", 0), + (record["span"] or {}).get("end_line", 0), + record["producer"], + record["subject"] or "", + record["object"] or "", + record["message"], + canonical_json_bytes(record["details"]).decode("utf-8"), + ), + ) + + document = { + "schema_version": schema_version, + "revision": {"kind": revision_kind, "value": revision_value}, + "producer_version_set": normalize_producer_version_set(producer_version_set), + "config_hash": config_hash, + "nodes": node_records, + "edges": edge_records, + "assertions": assertion_records, + "observations": observation_records, + "diagnostics": diagnostic_records, + } + return sha256_prefixed(canonical_json_bytes(document)) diff --git a/apps/backend/app/intelligence/classification.py b/apps/backend/app/intelligence/classification.py new file mode 100644 index 00000000..6d975d2c --- /dev/null +++ b/apps/backend/app/intelligence/classification.py @@ -0,0 +1,190 @@ +"""Heuristic role classification over a building RI snapshot (#95). + +This producer never reads the repository working tree or reparses source: it +reads only facts a snapshot already has (file/symbol nodes and resolved +``injects`` edges) and emits ``classified_as`` assertions naming the semantic +role a file or symbol plays (service, model, controller, middleware, ...), or +flags a dependency-injected function as an authentication guard. + +Every emitted assertion is ``truth_class="inferred"`` with a +``"confidence": "heuristic"`` value, so a heuristic classification can never +be mistaken for an observed fact (CONTRIBUTING.md rule: heuristic results must +not be presented as guaranteed facts). A file or symbol that matches no rule +gets no assertion at all — silence, not a fabricated "unknown" classification. +""" + +from __future__ import annotations + +import re +from collections.abc import Callable + +from sqlalchemy import select + +from app.intelligence.snapshot_store import SnapshotStateError, SnapshotStore, node_ref +from app.models.snapshot import RiEdge, RiNode, RiSnapshot + +_FILE_RULES: tuple[tuple[re.Pattern[str], str], ...] = ( + (re.compile(r"(^|/)readme", re.IGNORECASE), "documentation"), + (re.compile(r"(^|/)(test|spec|__tests__)", re.IGNORECASE), "test"), + (re.compile(r"(^|/)(main|app|index)\.(py|ts|tsx|js|jsx)$", re.IGNORECASE), "entrypoint"), + (re.compile(r"middleware", re.IGNORECASE), "middleware"), + (re.compile(r"(/controllers?/|controller)", re.IGNORECASE), "controller"), + (re.compile(r"(/routes?/|/api/|route)", re.IGNORECASE), "route"), + (re.compile(r"(/services?/|service)", re.IGNORECASE), "service"), + (re.compile(r"(/repositories?/|repository|repo)", re.IGNORECASE), "repository"), + (re.compile(r"(/models?/|/schemas?/|model)", re.IGNORECASE), "model"), + (re.compile(r"dto", re.IGNORECASE), "dto"), + (re.compile(r"interface", re.IGNORECASE), "interface"), + (re.compile(r"enum", re.IGNORECASE), "enum"), + (re.compile(r"(/config|settings)", re.IGNORECASE), "configuration"), + (re.compile(r"(/utils?/|/lib/|util)", re.IGNORECASE), "utility"), +) + +_SYMBOL_SUFFIX_RULES: tuple[tuple[str, str], ...] = ( + ("Middleware", "middleware"), + ("Service", "service"), + ("Repository", "repository"), + ("Controller", "controller"), + ("Model", "model"), + ("DTO", "dto"), + ("Dto", "dto"), +) + +_AUTH_DEPENDENCY_PATTERN = re.compile( + r"(auth|current_user|credential|token|permission|require_user|verify|oauth)", + re.IGNORECASE, +) + +#: Presentation-only grouping of a ``classified_as`` role into an architectural +#: layer. This is not a snapshot fact: it is a heuristic re-labelling of an +#: already-heuristic role, shared by every consumer that groups modules into +#: layers (Architecture, Documentation) so they cannot disagree about what +#: "presentation" or "infrastructure" means for the same role. +_LAYER_BY_ROLE = { + "entrypoint": "presentation", + "controller": "presentation", + "route": "presentation", + "service": "business-logic", + "model": "domain", + "dto": "domain", + "interface": "domain", + "enum": "domain", + "repository": "infrastructure", + "configuration": "infrastructure", + "test": "infrastructure", + "middleware": "infrastructure", +} + +#: Display order for layers; anything absent (e.g. "external") sorts last. +LAYER_ORDER: dict[str, int] = { + "presentation": 0, + "business-logic": 1, + "domain": 2, + "infrastructure": 3, + "shared": 4, + "external": 5, +} + + +def layer_for_role(role: str | None) -> str: + """Heuristic architectural layer for a ``classified_as`` role, or ``"shared"``.""" + + return _LAYER_BY_ROLE.get(role or "", "shared") + + +def _file_role(path: str) -> str | None: + for pattern, role in _FILE_RULES: + if pattern.search(path): + return role + return None + + +def _symbol_role(name: str) -> str | None: + for suffix, role in _SYMBOL_SUFFIX_RULES: + if name.endswith(suffix) and len(name) > len(suffix): + return role + return None + + +class RoleClassifier: + """Classify file and symbol facts already persisted in a building snapshot.""" + + name = "role-classifier" + version = "1.0.0" + + def __init__(self, store: SnapshotStore) -> None: + self.store = store + + @property + def producer(self) -> str: + return f"{self.name}@{self.version}" + + def classify( + self, + snapshot: RiSnapshot, + *, + check_cancelled: Callable[[], None] | None = None, + ) -> int: + """Add every resolvable classification assertion to one building snapshot. + + Returns the number of assertions attempted (existing matching + assertions are consolidated by :class:`SnapshotStore`, so this is a + count of attempted facts, not a row count). + """ + + self._check_cancelled(check_cancelled) + if snapshot.state != "building": + raise SnapshotStateError("role classification requires a building snapshot") + if self.producer not in set(snapshot.producer_version_set): + raise ValueError(f"snapshot producer_version_set is missing {self.producer!r}") + + nodes: list[RiNode] = [] + for node in self.store.db.scalars(select(RiNode).where(RiNode.snapshot_id == snapshot.snapshot_id)).yield_per( + 500 + ): + self._check_cancelled(check_cancelled) + nodes.append(node) + injected_keys: set[str] = set() + for edge in self.store.db.scalars( + select(RiEdge).where( + RiEdge.snapshot_id == snapshot.snapshot_id, + RiEdge.predicate == "injects", + ) + ).yield_per(500): + self._check_cancelled(check_cancelled) + injected_keys.add(edge.object_key) + + assertions_added = 0 + for node in nodes: + self._check_cancelled(check_cancelled) + if node.node_kind == "file" and node.stable_key.startswith("file:"): + role = _file_role(node.stable_key.removeprefix("file:")) + if role is not None: + self._assert_classification(snapshot, node, role) + assertions_added += 1 + elif node.node_kind == "symbol" and node.name: + role = _symbol_role(node.name) + if role is not None: + self._assert_classification(snapshot, node, role) + assertions_added += 1 + if node.stable_key in injected_keys and _AUTH_DEPENDENCY_PATTERN.search(node.name): + self._assert_classification(snapshot, node, "auth_dependency") + assertions_added += 1 + return assertions_added + + def _assert_classification(self, snapshot: RiSnapshot, node: RiNode, role: str) -> None: + self.store.add_assertion( + snapshot, + subject_kind=node.node_kind, + subject_key=node.stable_key, + predicate="classified_as", + value={"classification": role, "confidence": "heuristic"}, + producer=self.name, + producer_version=self.version, + derived_from=(node_ref(node.stable_key),), + ) + + @staticmethod + def _check_cancelled(check_cancelled: Callable[[], None] | None) -> None: + if check_cancelled is not None: + check_cancelled() diff --git a/apps/backend/app/intelligence/engine.py b/apps/backend/app/intelligence/engine.py deleted file mode 100644 index bbc6ec96..00000000 --- a/apps/backend/app/intelligence/engine.py +++ /dev/null @@ -1,550 +0,0 @@ -from __future__ import annotations - -import json -import re -import tomllib -from collections import Counter, defaultdict -from datetime import UTC, datetime -from pathlib import Path -from typing import Any - -from app.intelligence.models import ( - KnowledgeGraph, - KnowledgeGraphNode, - KnowledgeGraphRelationship, - RepositoryDependency, - RepositoryDiscovery, - RepositoryIntelligence, - RepositoryModule, - RepositoryStatistics, - SourceFileIntelligence, - SourceRole, - SourceSymbol, -) -from app.models.repository import RepositoryRecord -from app.parsers.tree_sitter_parser import TreeSitterParser -from app.schemas.repository import FileTreeNode, RepositoryMeta - -SOURCE_EXTENSIONS = {"py", "ts", "tsx", "js", "jsx", "go", "rs", "java", "kt", "swift", "cs"} -DOC_EXTENSIONS = {"md", "mdx", "rst"} -CONFIG_NAMES = { - "package.json", - "tsconfig.json", - "vite.config.ts", - "next.config.js", - "next.config.mjs", - "pyproject.toml", - "requirements.txt", - "Dockerfile", - "docker-compose.yml", - ".env.example", - ".gitignore", - "alembic.ini", -} -DATABASE_TECHNOLOGIES = { - "postgres": "PostgreSQL", - "postgresql": "PostgreSQL", - "psycopg": "PostgreSQL", - "mysql": "MySQL", - "sqlite": "SQLite", - "mongodb": "MongoDB", - "redis": "Redis", - "sqlalchemy": "SQLAlchemy", - "prisma": "Prisma", -} -CLOUD_TECHNOLOGIES = { - "aws": "AWS", - "boto3": "AWS", - "azure": "Azure", - "google-cloud": "Google Cloud", - "gcp": "Google Cloud", - "vercel": "Vercel", - "netlify": "Netlify", - "render": "Render", - "railway": "Railway", -} - - -class RepositoryIntelligenceEngine: - """Builds reusable repository intelligence from parser output and repository files.""" - - def __init__(self, syntax_parser: TreeSitterParser | None = None) -> None: - self.syntax_parser = syntax_parser or TreeSitterParser() - - def from_record(self, record: RepositoryRecord) -> RepositoryIntelligence: - existing = self.load(record) - if existing: - return existing - return self.build( - repository_id=record.id, - repository_name=record.name, - root=Path(record.local_path), - tree=[FileTreeNode.model_validate(node) for node in record.file_tree or []], - metadata=RepositoryMeta.model_validate(record.repo_metadata or {}), - total_size=record.size, - ) - - def load(self, record: RepositoryRecord) -> RepositoryIntelligence | None: - intelligence = (record.repo_metadata or {}).get("intelligence") - if not intelligence: - return None - try: - return RepositoryIntelligence.model_validate(intelligence) - except ValueError: - return None - - def persist(self, record: RepositoryRecord, intelligence: RepositoryIntelligence) -> None: - metadata = dict(record.repo_metadata or {}) - metadata["intelligence"] = intelligence.model_dump(mode="json", by_alias=True) - record.repo_metadata = metadata - - def build( - self, - repository_id: str, - repository_name: str, - root: Path, - tree: list[FileTreeNode], - metadata: RepositoryMeta, - total_size: int, - ) -> RepositoryIntelligence: - flat_files = self._flatten_files(tree) - file_intelligence = [self._file_intelligence(root, node) for node in flat_files] - symbols = [symbol for file in file_intelligence for symbol in file.symbols] - dependencies = self._dependencies(root) - discovery = self._discovery(metadata, tree, file_intelligence, dependencies, total_size) - modules = self._modules(file_intelligence) - graph = self._knowledge_graph(repository_id, repository_name, modules, file_intelligence, symbols, dependencies) - return RepositoryIntelligence( - repository_id=repository_id, - repository_name=repository_name, - generated_at=datetime.now(UTC), - metadata=metadata, - discovery=discovery, - modules=modules, - files=file_intelligence, - symbols=symbols, - dependencies=dependencies, - graph=graph, - ) - - def _flatten_files(self, nodes: list[FileTreeNode]) -> list[FileTreeNode]: - result: list[FileTreeNode] = [] - for node in nodes: - if node.type == "file": - result.append(node) - if node.children: - result.extend(self._flatten_files(node.children)) - return result - - def _file_intelligence(self, root: Path, node: FileTreeNode) -> SourceFileIntelligence: - path = node.path - extension = node.extension - language = node.language - text = self._read_text(root, path) - syntax = self.syntax_parser.parse_symbols(text.encode("utf-8", errors="ignore"), extension) - if syntax.language and not language: - language = syntax.language - role = self._role(path, extension) - imports = self._imports(text, extension) - exports = self._exports(text, extension) - api_routes = self._api_routes(text, extension) - symbols = self._symbols(path, text, extension, exports, api_routes) - technologies = self._technologies(path, text) - return SourceFileIntelligence( - path=path, - name=node.name, - module_id=self._module_id(path, role), - language=language, - extension=extension, - size=node.size or 0, - role=role, - imports=imports, - exports=exports, - api_routes=api_routes, - symbols=symbols, - technologies=technologies, - ) - - def _read_text(self, root: Path, path: str) -> str: - absolute = root / path.lstrip("/") - if not absolute.exists() or absolute.stat().st_size > 512_000: - return "" - try: - return absolute.read_text(encoding="utf-8", errors="ignore") - except OSError: - return "" - - def _role(self, path: str, extension: str | None) -> SourceRole: - lowered = path.lower() - name = lowered.rsplit("/", 1)[-1] - if name.startswith("readme") or extension in DOC_EXTENSIONS: - return "documentation" - if name in {item.lower() for item in CONFIG_NAMES} or "/.github/" in lowered: - return "configuration" - if any(token in lowered for token in ["test", "spec", "__tests__"]): - return "test" - if name in {"main.py", "app.py", "main.ts", "main.tsx", "index.js", "index.ts"}: - return "entrypoint" - if "/controllers/" in lowered or "controller" in name: - return "controller" - if "/routes/" in lowered or "/api/" in lowered or "route" in name: - return "route" - if "/services/" in lowered or "service" in name: - return "service" - if "/repositories/" in lowered or "repository" in name or "repo" in name: - return "repository" - if "/models/" in lowered or "/schemas/" in lowered or "model" in name: - return "model" - if "dto" in name: - return "dto" - if "interface" in name: - return "interface" - if "enum" in name: - return "enum" - if "/utils/" in lowered or "/lib/" in lowered or "util" in name: - return "utility" - return "unknown" - - def _imports(self, text: str, extension: str | None) -> list[str]: - imports: list[str] = [] - if extension in {"ts", "tsx", "js", "jsx"}: - patterns = [ - r"import\s+(?:.+?\s+from\s+)?['\"]([^'\"]+)['\"]", - r"export\s+.+?\s+from\s+['\"]([^'\"]+)['\"]", - r"require\(['\"]([^'\"]+)['\"]\)", - ] - for pattern in patterns: - imports.extend(re.findall(pattern, text)) - if extension == "py": - imports.extend(re.findall(r"^\s*from\s+([\w.]+)\s+import\s+", text, flags=re.MULTILINE)) - imports.extend(re.findall(r"^\s*import\s+([\w.]+)", text, flags=re.MULTILINE)) - return sorted(set(imports)) - - def _exports(self, text: str, extension: str | None) -> list[str]: - exports: list[str] = [] - if extension in {"ts", "tsx", "js", "jsx"}: - exports.extend(re.findall(r"export\s+(?:default\s+)?(?:async\s+)?function\s+(\w+)", text)) - exports.extend(re.findall(r"export\s+(?:default\s+)?class\s+(\w+)", text)) - exports.extend(re.findall(r"export\s+(?:interface|type|enum)\s+(\w+)", text)) - exports.extend(re.findall(r"export\s+const\s+(\w+)", text)) - if extension == "py": - exports.extend(re.findall(r"^class\s+(\w+)", text, flags=re.MULTILINE)) - exports.extend(re.findall(r"^def\s+(\w+)", text, flags=re.MULTILINE)) - return sorted(set(exports)) - - def _api_routes(self, text: str, extension: str | None) -> list[str]: - routes: list[str] = [] - if extension == "py": - routes.extend(re.findall(r"@(?:\w+\.)?(?:get|post|put|patch|delete)\(['\"]([^'\"]+)['\"]", text)) - if extension in {"ts", "tsx", "js", "jsx"}: - routes.extend(re.findall(r"\.(?:get|post|put|patch|delete)\(['\"]([^'\"]+)['\"]", text)) - return sorted(set(routes)) - - def _symbols(self, path: str, text: str, extension: str | None, exports: list[str], routes: list[str]) -> list[SourceSymbol]: - symbols: list[SourceSymbol] = [] - patterns: list[tuple[str, str]] = [] - if extension in {"ts", "tsx", "js", "jsx"}: - patterns = [ - ("function", r"(?:export\s+)?(?:async\s+)?function\s+(\w+)"), - ("class", r"(?:export\s+)?class\s+(\w+)"), - ("interface", r"(?:export\s+)?interface\s+(\w+)"), - ("type", r"(?:export\s+)?type\s+(\w+)"), - ("enum", r"(?:export\s+)?enum\s+(\w+)"), - ("constant", r"(?:export\s+)?const\s+(\w+)"), - ] - elif extension == "py": - patterns = [("class", r"^class\s+(\w+)"), ("function", r"^def\s+(\w+)")] - - for kind, pattern in patterns: - for name in re.findall(pattern, text, flags=re.MULTILINE): - symbols.append( - SourceSymbol( - id=self._symbol_id(path, name), - name=name, - kind=kind, # type: ignore[arg-type] - file_path=path, - exported=name in exports or extension == "py", - ) - ) - for route in routes: - symbols.append(SourceSymbol(id=self._symbol_id(path, route), name=route, kind="route", file_path=path, exported=True)) - return symbols - - def _technologies(self, path: str, text: str) -> list[str]: - lowered = f"{path}\n{text}".lower() - technologies: set[str] = set() - for token, name in {**DATABASE_TECHNOLOGIES, **CLOUD_TECHNOLOGIES}.items(): - if token in lowered: - technologies.add(name) - if "dockerfile" in lowered or "docker-compose" in lowered: - technologies.add("Docker") - if "github/workflows" in lowered: - technologies.add("GitHub Actions") - return sorted(technologies) - - def _dependencies(self, root: Path) -> list[RepositoryDependency]: - dependencies: list[RepositoryDependency] = [] - package_json = root / "package.json" - if package_json.exists(): - try: - data = json.loads(package_json.read_text(encoding="utf-8")) - except json.JSONDecodeError: - data = {} - for section, dep_type in (("dependencies", "production"), ("devDependencies", "development"), ("peerDependencies", "peer"), ("optionalDependencies", "optional")): - for name, version in data.get(section, {}).items(): - dependencies.append(self._dependency(name, str(version), dep_type, "npm", "package.json")) - - requirements = root / "requirements.txt" - if requirements.exists(): - for line in requirements.read_text(encoding="utf-8", errors="ignore").splitlines(): - clean = line.strip() - if not clean or clean.startswith("#"): - continue - name = re.split(r"==|>=|<=|~=|>|<", clean)[0].strip() - version = clean.replace(name, "", 1) or "unknown" - dependencies.append(self._dependency(name, version, "production", "python", "requirements.txt")) - - pyproject = root / "pyproject.toml" - if pyproject.exists(): - try: - data = tomllib.loads(pyproject.read_text(encoding="utf-8")) - except tomllib.TOMLDecodeError: - data = {} - for item in data.get("project", {}).get("dependencies", []): - name = re.split(r"==|>=|<=|~=|>|<", str(item))[0].strip() - version = str(item).replace(name, "", 1) or "unknown" - dependencies.append(self._dependency(name, version, "production", "python", "pyproject.toml")) - return sorted({dependency.id: dependency for dependency in dependencies}.values(), key=lambda dependency: dependency.name.lower()) - - def _dependency(self, name: str, version: str, dep_type: str, ecosystem: str, source_file: str) -> RepositoryDependency: - safe_id = re.sub(r"[^A-Za-z0-9_.@/-]", "-", name) - return RepositoryDependency( - id=f"dependency:{ecosystem}:{safe_id}", - name=name, - version=version, - type=dep_type, # type: ignore[arg-type] - ecosystem=ecosystem, - source_file=source_file, - ) - - def _discovery( - self, - metadata: RepositoryMeta, - tree: list[FileTreeNode], - files: list[SourceFileIntelligence], - dependencies: list[RepositoryDependency], - total_size: int, - ) -> RepositoryDiscovery: - language_counts = Counter(file.language for file in files if file.language) - folders = self._count_folders(tree) - paths = [file.path for file in files] - dep_names = {dependency.name.lower() for dependency in dependencies} - frameworks = sorted({metadata.framework, *self._frameworks_from_dependencies(dep_names)} - {"Unknown", ""}) - package_managers = sorted({metadata.package_manager} - {None}) # type: ignore[arg-type] - config_files = [path.lstrip("/") for path in paths if Path(path).name in CONFIG_NAMES or "/.github/" in path] - env_files = [path.lstrip("/") for path in paths if Path(path).name.startswith(".env")] - docker_files = [path.lstrip("/") for path in paths if "docker" in path.lower()] - ci_files = [path.lstrip("/") for path in paths if "/.github/workflows/" in path or "gitlab-ci" in path.lower()] - build_systems = self._build_systems(paths, dep_names) - technologies = {technology for file in files for technology in file.technologies} - database_technologies = sorted(technologies.intersection(set(DATABASE_TECHNOLOGIES.values()))) - cloud_providers = sorted(technologies.intersection(set(CLOUD_TECHNOLOGIES.values()))) - return RepositoryDiscovery( - primary_language=metadata.language, - languages=dict(language_counts), - frameworks=frameworks, - package_managers=package_managers, - configuration_files=config_files, - environment_files=env_files, - docker_files=docker_files, - ci_files=ci_files, - entry_points=[metadata.entry_point] if metadata.entry_point else [], - build_systems=build_systems, - database_technologies=database_technologies, - cloud_providers=cloud_providers, - statistics=RepositoryStatistics( - total_files=metadata.total_files, - total_folders=folders, - total_size=total_size, - source_files=len([file for file in files if file.extension in SOURCE_EXTENSIONS]), - test_files=len([file for file in files if file.role == "test"]), - config_files=len(config_files), - documentation_files=len([file for file in files if file.role == "documentation"]), - ), - ) - - def _count_folders(self, nodes: list[FileTreeNode]) -> int: - count = 0 - for node in nodes: - if node.type == "folder": - count += 1 - if node.children: - count += self._count_folders(node.children) - return count - - def _frameworks_from_dependencies(self, dep_names: set[str]) -> set[str]: - frameworks: set[str] = set() - mapping = {"react": "React", "next": "Next.js", "vue": "Vue", "fastapi": "FastAPI", "django": "Django", "flask": "Flask"} - for dependency, framework in mapping.items(): - if dependency in dep_names: - frameworks.add(framework) - return frameworks - - def _build_systems(self, paths: list[str], dep_names: set[str]) -> list[str]: - systems: set[str] = set() - names = {Path(path).name for path in paths} - if "package.json" in names: - systems.add("npm") - if "vite.config.ts" in names or "vite" in dep_names: - systems.add("Vite") - if "next" in dep_names: - systems.add("Next.js") - if "pyproject.toml" in names: - systems.add("pyproject") - if "requirements.txt" in names: - systems.add("pip") - if "Dockerfile" in names or "docker-compose.yml" in names: - systems.add("Docker") - return sorted(systems) - - def _modules(self, files: list[SourceFileIntelligence]) -> list[RepositoryModule]: - grouped: dict[str, list[SourceFileIntelligence]] = defaultdict(list) - for file in files: - grouped[file.module_id].append(file) - modules: list[RepositoryModule] = [] - for module_id, module_files in grouped.items(): - role = self._dominant_role(module_files) - dependencies = sorted({import_name for file in module_files for import_name in file.imports}) - symbols = sorted({symbol.id for file in module_files for symbol in file.symbols}) - modules.append( - RepositoryModule( - id=module_id, - name=module_id.replace("module:", "").replace("-", " ").title(), - role=role, - layer=self._layer(role), - path_prefix=self._path_prefix(module_files), - files=[file.path for file in module_files], - symbols=symbols, - dependencies=dependencies, - ) - ) - return sorted(modules, key=lambda module: module.id) - - def _dominant_role(self, files: list[SourceFileIntelligence]) -> SourceRole: - roles = [file.role for file in files if file.role not in {"unknown", "documentation", "test"}] - if not roles: - return files[0].role if files else "unknown" - return Counter(roles).most_common(1)[0][0] - - def _module_id(self, path: str, role: SourceRole) -> str: - parts = [part for part in path.strip("/").split("/") if part] - if role in {"controller", "route"}: - return "module:api" - if role == "service": - return "module:services" - if role == "repository": - return "module:repositories" - if role in {"model", "dto", "interface", "enum"}: - return "module:domain" - if role == "configuration": - return "module:configuration" - if role == "test": - return "module:tests" - if role == "documentation": - return "module:documentation" - if parts and parts[0] in {"app", "src", "backend", "frontend"} and len(parts) > 1: - return f"module:{parts[1].lower()}" - return f"module:{parts[0].lower() if parts else 'repository'}" - - def _layer(self, role: SourceRole) -> str: - if role in {"entrypoint", "controller", "route"}: - return "presentation" - if role == "service": - return "business-logic" - if role in {"model", "dto", "interface", "enum"}: - return "domain" - if role in {"repository", "configuration", "test"}: - return "infrastructure" - return "shared" - - def _path_prefix(self, files: list[SourceFileIntelligence]) -> str: - if not files: - return "/" - parts = [file.path.strip("/").split("/") for file in files] - prefix: list[str] = [] - for columns in zip(*parts): - if len(set(columns)) == 1: - prefix.append(columns[0]) - else: - break - return "/" + "/".join(prefix) if prefix else "/" - - def _knowledge_graph( - self, - repository_id: str, - repository_name: str, - modules: list[RepositoryModule], - files: list[SourceFileIntelligence], - symbols: list[SourceSymbol], - dependencies: list[RepositoryDependency], - ) -> KnowledgeGraph: - nodes: list[KnowledgeGraphNode] = [KnowledgeGraphNode(id=f"repository:{repository_id}", type="repository", name=repository_name)] - relationships: list[KnowledgeGraphRelationship] = [] - module_by_id = {module.id: module for module in modules} - - for module in modules: - nodes.append(KnowledgeGraphNode(id=module.id, type="module", name=module.name, path=module.path_prefix, metadata={"role": module.role, "layer": module.layer})) - relationships.append(self._relationship(f"repository:{repository_id}", module.id, "contains", [module.path_prefix])) - - for file in files: - file_id = self._file_id(file.path) - nodes.append(KnowledgeGraphNode(id=file_id, type="file", name=file.name, path=file.path, metadata={"role": file.role, "language": file.language or "Unknown"})) - if file.module_id in module_by_id: - relationships.append(self._relationship(file.module_id, file_id, "contains", [file.path])) - for import_name in file.imports: - dependency = self._dependency_for_import(import_name, dependencies) - target = dependency.id if dependency else f"external:{import_name}" - if dependency is None: - nodes.append(KnowledgeGraphNode(id=target, type="dependency", name=import_name, metadata={"external": True})) - relationships.append(self._relationship(file_id, target, "imports", [file.path])) - - seen_nodes = {node.id for node in nodes} - for symbol in symbols: - nodes.append(KnowledgeGraphNode(id=symbol.id, type="symbol", name=symbol.name, path=symbol.file_path, metadata={"kind": symbol.kind, "exported": symbol.exported})) - relationships.append(self._relationship(self._file_id(symbol.file_path), symbol.id, "contains", [symbol.file_path])) - if symbol.exported: - relationships.append(self._relationship(self._file_id(symbol.file_path), symbol.id, "exports", [symbol.file_path])) - seen_nodes.add(symbol.id) - - for dependency in dependencies: - if dependency.id not in seen_nodes: - nodes.append(KnowledgeGraphNode(id=dependency.id, type="dependency", name=dependency.name, path=dependency.source_file, metadata={"version": dependency.version, "ecosystem": dependency.ecosystem, "type": dependency.type})) - seen_nodes.add(dependency.id) - relationships.append(self._relationship(f"repository:{repository_id}", dependency.id, "depends_on", [dependency.source_file])) - - deduped_nodes = list({node.id: node for node in nodes}.values()) - deduped_relationships = list({relationship.id: relationship for relationship in relationships}.values()) - return KnowledgeGraph(nodes=deduped_nodes, relationships=deduped_relationships) - - def _dependency_for_import(self, import_name: str, dependencies: list[RepositoryDependency]) -> RepositoryDependency | None: - normalized = import_name.split("/")[0] if not import_name.startswith("@") else "/".join(import_name.split("/")[:2]) - for dependency in dependencies: - if dependency.name == normalized or dependency.name == import_name: - return dependency - return None - - def _relationship(self, source: str, target: str, rel_type: str, evidence: list[str]) -> KnowledgeGraphRelationship: - return KnowledgeGraphRelationship( - id=f"{rel_type}:{source}->{target}", - source=source, - target=target, - type=rel_type, # type: ignore[arg-type] - evidence=evidence, - ) - - def _file_id(self, path: str) -> str: - return f"file:{path}" - - def _symbol_id(self, path: str, name: str) -> str: - safe_name = re.sub(r"[^A-Za-z0-9_.@/-]", "-", name) - return f"symbol:{path}:{safe_name}" diff --git a/apps/backend/app/intelligence/models.py b/apps/backend/app/intelligence/models.py index cdac17df..dcc10238 100644 --- a/apps/backend/app/intelligence/models.py +++ b/apps/backend/app/intelligence/models.py @@ -1,6 +1,8 @@ from datetime import datetime from typing import Literal +from pydantic import Field + from app.schemas.base import CamelModel from app.schemas.repository import RepositoryMeta @@ -16,14 +18,17 @@ "enum", "utility", "configuration", + "middleware", "test", "documentation", "unknown", ] SymbolKind = Literal["function", "class", "interface", "type", "enum", "constant", "route"] GraphNodeType = Literal["repository", "module", "file", "symbol", "dependency"] -RelationshipType = Literal["imports", "calls", "extends", "implements", "depends_on", "contains", "references", "exports"] -DependencyType = Literal["production", "development", "peer", "optional"] +RelationshipType = Literal[ + "imports", "calls", "extends", "implements", "depends_on", "contains", "references", "exports" +] +DependencyType = Literal["production", "development", "peer", "optional", "multiple"] class RepositoryStatistics(CamelModel): @@ -36,6 +41,15 @@ class RepositoryStatistics(CamelModel): documentation_files: int +EnvironmentFileEvidenceClass = Literal["template_present", "runtime_env_file_present", "secret_like_value_detected"] + + +class EnvironmentFileEvidence(CamelModel): + path: str + evidence_class: EnvironmentFileEvidenceClass + secret_keys: list[str] = Field(default_factory=list) + + class RepositoryDiscovery(CamelModel): primary_language: str languages: dict[str, int] @@ -43,6 +57,7 @@ class RepositoryDiscovery(CamelModel): package_managers: list[str] configuration_files: list[str] environment_files: list[str] + environment_file_evidence: list[EnvironmentFileEvidence] = Field(default_factory=list) docker_files: list[str] ci_files: list[str] entry_points: list[str] @@ -86,13 +101,37 @@ class RepositoryModule(CamelModel): dependencies: list[str] +class DependencyDeclaration(CamelModel): + name: str + manifest_path: str + workspace_path: str + start_line: int + end_line: int + extractor: str + extractor_version: str + ecosystem: str + version: str | None + type: Literal["production", "development", "peer", "optional"] + + +class DependencyDiagnostic(CamelModel): + code: str + category: str + severity: Literal["fatal", "error", "warning", "info"] + message: str + path: str | None = None + producer: str + details: dict[str, object] | None = None + + class RepositoryDependency(CamelModel): id: str name: str - version: str + version: str | None type: DependencyType ecosystem: str source_file: str + declarations: list[DependencyDeclaration] class KnowledgeGraphNode(CamelModel): @@ -126,4 +165,6 @@ class RepositoryIntelligence(CamelModel): files: list[SourceFileIntelligence] symbols: list[SourceSymbol] dependencies: list[RepositoryDependency] + dependency_manifest_count: int = 0 + dependency_diagnostics: list[DependencyDiagnostic] = [] graph: KnowledgeGraph diff --git a/apps/backend/app/intelligence/query_service.py b/apps/backend/app/intelligence/query_service.py new file mode 100644 index 00000000..13df5a60 --- /dev/null +++ b/apps/backend/app/intelligence/query_service.py @@ -0,0 +1,995 @@ +"""Read-only, owner-scoped queries over sealed ``ri.v1`` snapshots (#92).""" + +from collections import defaultdict +from collections import Counter +from collections.abc import Iterator, Sequence +from dataclasses import dataclass +from typing import Any + +from sqlalchemy import and_, func, or_, select +from sqlalchemy.orm import Session + +from app.core.exceptions import NotFoundError, UnsupportedSchemaVersionError +from app.intelligence.classification import layer_for_role +from app.models.snapshot import ( + RiAssertion, + RiDerivation, + RiDiagnostic, + RiEdge, + RiEvidence, + RiNode, + RiObservation, + RiSnapshot, +) + + +#: Predicates Architecture renders, mapped to the architecture edge type. +ARCHITECTURE_RELATIONSHIP_EDGE_TYPES = { + "imports": "import", + "calls": "calls", + "routes_to": "api-call", + "implements": "dependency", + "depends_on": "dependency", +} +#: Predicates the authentication explanation walks to build guard chains. It +#: shares ``architecture_facts`` with Architecture but needs ``injects``, which +#: Architecture never draws. +AUTHENTICATION_EXPLANATION_PREDICATES = frozenset({"injects", "calls", "routes_to"}) +#: What ``architecture_facts`` actually loads: the union of both consumers. +#: Each consumer still filters again in Python for what it renders, so widening +#: this set never widens a response — it only prevents starving a consumer. +ARCHITECTURE_FACT_PREDICATES = frozenset(ARCHITECTURE_RELATIONSHIP_EDGE_TYPES) | AUTHENTICATION_EXPLANATION_PREDICATES +ARCHITECTURE_DIAGNOSTIC_CODES = frozenset({"RI-RES-UNRESOLVED", "RI-RES-AMBIGUOUS"}) +#: Diagnostic codes the authentication explanation renders. +AUTHENTICATION_DIAGNOSTIC_CODES = frozenset( + {"RI-RES-UNRESOLVED", "RI-RES-AMBIGUOUS", "RI-EXT-UNSUPPORTED", "RI-SRC-MALFORMED"} +) +#: Union of diagnostic codes loaded for both consumers. +ARCHITECTURE_FACT_DIAGNOSTIC_CODES = ARCHITECTURE_DIAGNOSTIC_CODES | AUTHENTICATION_DIAGNOSTIC_CODES +#: Node kinds the architecture consumer always needs, independent of whether the +#: node participates in a relationship edge. ``symbol`` is deliberately absent: +#: symbols are loaded only when an edge references them. +ARCHITECTURE_NODE_KINDS = frozenset({"file", "dependency", "repository"}) +#: The only assertion predicate the architecture consumer reads (file role +#: classification). Others stay in the snapshot and are simply not loaded here. +ARCHITECTURE_ASSERTION_PREDICATES = frozenset({"classified_as"}) +#: Upper bound on ids bound into a single ``IN`` clause. SQLite caps bound +#: parameters per statement (999 before 3.32, 32766 after), so any read whose +#: id set grows with repository size must be split into batches rather than +#: trusting the set to stay small. +SNAPSHOT_IN_CLAUSE_BATCH_SIZE = 500 +ARCHITECTURE_EVIDENCE_BATCH_SIZE = SNAPSHOT_IN_CLAUSE_BATCH_SIZE +#: Only these persisted, directed relationship facts participate in the #173 +#: blast-radius query. Unresolved observations remain diagnostics and are never +#: traversed as if they were relationships. +IMPACT_TRAVERSAL_PREDICATES = ("depends_on", "imports") +#: A traversal is intentionally bounded by both depth and returned nodes. The +#: endpoint exposes ``limit_reached`` whenever the node cap prevents a complete +#: result at the requested depth. +IMPACT_MAX_DEPTH = 10 +IMPACT_MAX_RESULTS_PER_DIRECTION = 100 + + +def batched_ids[T](values: Sequence[T], size: int = SNAPSHOT_IN_CLAUSE_BATCH_SIZE) -> Iterator[list[T]]: + """Yield ``values`` in ``size``-sized lists for bounded ``IN`` clauses.""" + + for start in range(0, len(values), size): + yield list(values[start : start + size]) + + +@dataclass(frozen=True) +class ArchitectureSnapshotFacts: + """Persisted facts needed to build evidence-backed architecture relationships.""" + + snapshot: RiSnapshot + nodes: list[RiNode] + edges: list[RiEdge] + assertions: list[RiAssertion] + node_evidence: dict[int, list[RiEvidence]] + edge_evidence: dict[int, list[RiEvidence]] + diagnostics: list[RiDiagnostic] + covered_paths: set[str] + + +@dataclass(frozen=True) +class SnapshotFileFact: + path: str + language: str | None + role: str | None + role_confidence: str | None + + +@dataclass(frozen=True) +class SnapshotDependencyDeclaration: + version: str | None + dependency_type: str | None + manifest_path: str | None + workspace_path: str | None + start_line: int | None + end_line: int | None + extractor: str | None + extractor_version: str | None + + +@dataclass(frozen=True) +class SnapshotDependencyFact: + stable_key: str + name: str + ecosystem: str | None + declarations: tuple[SnapshotDependencyDeclaration, ...] + + +@dataclass(frozen=True) +class SnapshotRouteFact: + path: str + evidence_paths: tuple[str, ...] + + +@dataclass(frozen=True) +class SnapshotModuleFact: + name: str + role: str + paths: tuple[str, ...] + layer: str = "shared" + + +@dataclass(frozen=True) +class SnapshotFileRelationshipFact: + """A resolved, file-to-file architecture edge (RFC-0001 relationship kinds). + + Restricted to file/file endpoints: symbol- and dependency-scoped edges need + module resolution that only the Architecture consumer performs, so widening + this to every ``ARCHITECTURE_RELATIONSHIP_EDGE_TYPES`` predicate would + silently drop most of them rather than mis-attribute them. + """ + + subject_path: str + predicate: str + object_path: str + + +@dataclass(frozen=True) +class ProductSnapshotProjection: + """Immutable, bounded projection shared by Documentation and free-form AI.""" + + snapshot_id: str + schema_version: str + repository_id: str + revision_kind: str + revision_value: str + revision_ref: str | None + canonical_graph_hash: str + primary_language: str + frameworks: tuple[str, ...] + entry_points: tuple[str, ...] + modules: tuple[SnapshotModuleFact, ...] + files: tuple[SnapshotFileFact, ...] + dependencies: tuple[SnapshotDependencyFact, ...] + routes: tuple[SnapshotRouteFact, ...] + diagnostics: tuple[tuple[str, str, str | None], ...] + file_relationships: tuple[SnapshotFileRelationshipFact, ...] = () + + +@dataclass(frozen=True) +class SnapshotImpactStep: + """One deterministically selected edge on a bounded graph traversal.""" + + depth: int + node_key: str + edge: RiEdge + + +@dataclass(frozen=True) +class SnapshotImpactDirection: + """One directional impact result and whether the response was capped.""" + + steps: tuple[SnapshotImpactStep, ...] + limit_reached: bool + + +@dataclass(frozen=True) +class SnapshotImpact: + """Persisted, provenance-backed impact facts for one snapshot node.""" + + snapshot: RiSnapshot + node_key: str + depth: int + dependents: SnapshotImpactDirection + dependencies: SnapshotImpactDirection + + +class SnapshotQueryService: + """Expose only persisted snapshot facts; this service never touches repository storage.""" + + def __init__(self, db: Session, owner_id: str) -> None: + self.db = db + self.owner_id = owner_id + + supported_schema_versions = ("ri.v1",) + + def metadata(self, snapshot_id: str) -> RiSnapshot: + return self._snapshot(snapshot_id) + + def has_evidence_reference( + self, + snapshot: RiSnapshot, + fact_id: str, + path: str, + start_line: int, + end_line: int, + ) -> bool: + """Whether the exact fact citation exists in this snapshot's evidence. + + A client-controlled deep link must not be able to keep a genuine + snapshot/span while substituting a different fact (or vice versa) and + still receive a verified citation response. + """ + + count = self.db.scalar( + select(func.count()) + .select_from(RiEvidence) + .outerjoin( + RiNode, + and_( + RiNode.snapshot_id == RiEvidence.snapshot_id, + RiNode.id == RiEvidence.node_ref, + ), + ) + .outerjoin( + RiEdge, + and_( + RiEdge.snapshot_id == RiEvidence.snapshot_id, + RiEdge.id == RiEvidence.edge_ref, + ), + ) + .outerjoin( + RiObservation, + and_( + RiObservation.snapshot_id == RiEvidence.snapshot_id, + RiObservation.id == RiEvidence.observation_ref, + ), + ) + .where( + RiEvidence.snapshot_id == snapshot.snapshot_id, + RiEvidence.path == path, + RiEvidence.start_line == start_line, + RiEvidence.end_line == end_line, + or_( + RiNode.stable_key == fact_id, + RiEdge.edge_id == fact_id, + RiObservation.observation_id == fact_id, + ), + ) + ) + return bool(count) + + def source_content_hash(self, snapshot: RiSnapshot, path: str) -> str | None: + """Return the sealed byte hash for a snapshot file, if one exists.""" + + node = self.db.scalars( + select(RiNode).where( + RiNode.snapshot_id == snapshot.snapshot_id, + RiNode.node_kind == "file", + RiNode.stable_key == f"file:{path}", + ) + ).first() + value = (node.properties or {}).get("content_sha256") if node is not None else None + return value if isinstance(value, str) else None + + def latest_snapshot_for_owner(self, repository_id: str) -> RiSnapshot | None: + """Newest sealed snapshot for an owner-scoped repository, or ``None``. + + Public entry point for consumers that need snapshot identity rather + than snapshot facts (#113). Owner scoping and the sealed-state filter + are the same ones every other consumer query uses. + """ + + return self._latest_snapshot(repository_id) + + def require_sealed_snapshot_for_current_revision(self, repository_id: str) -> RiSnapshot: + """Newest sealed snapshot bound to the repository's *current* revision. + + Every product surface that publishes snapshot identity must agree on + which snapshot it is describing. Resolving "latest sealed" alone is not + enough: after a re-import the newest sealed snapshot can belong to the + previous revision, and a surface that rendered it would present stale + facts under the current revision's name. Consumers therefore share this + one resolution, so Architecture, Review, Insights and the revision + manifest cannot disagree about the revision under review. + + Raises ``NotFoundError`` rather than returning ``None`` so a repository + owned by someone else stays indistinguishable from one that has never + been analysed. + """ + + from app.models.repository import RepositoryRecord + + snapshot = self._latest_snapshot(repository_id) + if snapshot is None: + raise NotFoundError( + "No sealed Repository Intelligence snapshot is available for this repository.", + {"repositoryId": repository_id}, + ) + record = self.db.get(RepositoryRecord, repository_id) + if ( + record is None + or snapshot.repository_id != record.id + or snapshot.revision_kind != record.revision_kind + or snapshot.revision_value != record.revision_value + ): + # Fail closed even if persistence constraints are bypassed or a test + # double supplies mismatched facts. + raise NotFoundError( + "The sealed snapshot does not match the selected repository revision.", + {"repositoryId": repository_id, "snapshotId": snapshot.snapshot_id}, + ) + if snapshot.canonical_graph_hash is None or snapshot.sealed_at is None: + raise NotFoundError( + "The selected repository revision has no sealed snapshot.", + {"repositoryId": repository_id}, + ) + return snapshot + + def sealed_snapshot_for_owner(self, repository_id: str, snapshot_id: str) -> RiSnapshot | None: + """One named sealed snapshot of an owner-scoped repository, or ``None``. + + Verification needs the snapshot a submitted manifest actually names, not + whichever snapshot happens to be newest, so an authentic manifest for a + superseded revision can be recognised as authentic. + """ + + from app.models.repository import RepositoryRecord + + snapshot = self.db.scalars( + select(RiSnapshot) + .join(RepositoryRecord, RepositoryRecord.id == RiSnapshot.repository_id) + .where( + RiSnapshot.repository_id == repository_id, + RiSnapshot.snapshot_id == snapshot_id, + RiSnapshot.state == "completed", + RepositoryRecord.owner_id == self.owner_id, + ) + ).first() + if snapshot is not None and snapshot.schema_version not in self.supported_schema_versions: + raise UnsupportedSchemaVersionError( + f"Unsupported snapshot schema version: {snapshot.schema_version}.", + details={"received": snapshot.schema_version, "supported": list(self.supported_schema_versions)}, + ) + return snapshot + + def architecture_facts(self, repository_id: str) -> ArchitectureSnapshotFacts | None: + """Return the newest sealed snapshot facts for an owner-scoped repository. + + This internal consumer query uses the normalized store behind the public + #92 endpoints. ``None`` means no sealed snapshot is available; it is not + evidence that the repository has no relationships. + """ + + snapshot = self._latest_snapshot(repository_id) + if snapshot is None: + return None + edges = list( + self.db.scalars( + select(RiEdge) + .where( + RiEdge.snapshot_id == snapshot.snapshot_id, + RiEdge.predicate.in_(ARCHITECTURE_FACT_PREDICATES), + ) + .order_by(RiEdge.subject_key, RiEdge.predicate, RiEdge.object_key, RiEdge.edge_id, RiEdge.id) + ).all() + ) + # The architecture consumer needs every file node (module inventory and + # primary language), every dependency node (frameworks and dependency + # nodes) and the repository node. It needs a ``symbol`` node only when + # that symbol is an endpoint of a relationship edge. Symbols dominate a + # large snapshot, so excluding the unreferenced ones is the bound that + # matters here — while still returning every node the consumer reads. + endpoint_keys = {key for edge in edges for key in (edge.subject_key, edge.object_key)} + node_filter = RiNode.node_kind.in_(ARCHITECTURE_NODE_KINDS) + if endpoint_keys: + node_filter = or_(node_filter, RiNode.stable_key.in_(endpoint_keys)) + nodes = list( + self.db.scalars( + select(RiNode) + .where(RiNode.snapshot_id == snapshot.snapshot_id, node_filter) + .order_by(RiNode.stable_key, RiNode.id) + ).all() + ) + diagnostics = list( + self.db.scalars( + select(RiDiagnostic) + .where( + RiDiagnostic.snapshot_id == snapshot.snapshot_id, + RiDiagnostic.code.in_(ARCHITECTURE_FACT_DIAGNOSTIC_CODES), + ) + .order_by( + RiDiagnostic.path, + RiDiagnostic.span_start_line, + RiDiagnostic.code, + RiDiagnostic.id, + ) + ).all() + ) + assertions = list( + self.db.scalars( + select(RiAssertion) + .where( + RiAssertion.snapshot_id == snapshot.snapshot_id, + RiAssertion.predicate.in_(ARCHITECTURE_ASSERTION_PREDICATES), + ) + .order_by(RiAssertion.subject_key, RiAssertion.predicate, RiAssertion.assertion_id, RiAssertion.id) + ).all() + ) + covered_paths = self._architecture_covered_paths(snapshot) + return ArchitectureSnapshotFacts( + snapshot=snapshot, + nodes=nodes, + edges=edges, + assertions=assertions, + node_evidence=self._evidence_for(snapshot, "node_ref", [node.id for node in nodes]), + edge_evidence=self._evidence_for(snapshot, "edge_ref", [edge.id for edge in edges]), + diagnostics=diagnostics, + covered_paths=covered_paths, + ) + + def product_projection(self, repository_id: str) -> ProductSnapshotProjection: + """Facts used by product documentation and free-form AI. + + Resolution is deliberately current-revision-only. The query reads only + file/dependency nodes, file role assertions, route observations and + diagnostics. Growing id sets are batched before entering ``IN`` clauses. + """ + + snapshot = self.require_sealed_snapshot_for_current_revision(repository_id) + nodes = list( + self.db.scalars( + select(RiNode) + .where( + RiNode.snapshot_id == snapshot.snapshot_id, + RiNode.node_kind.in_(("file", "dependency")), + ) + .order_by(RiNode.node_kind, RiNode.stable_key, RiNode.id) + ).all() + ) + assertions = list( + self.db.scalars( + select(RiAssertion) + .where( + RiAssertion.snapshot_id == snapshot.snapshot_id, + RiAssertion.subject_kind == "file", + RiAssertion.predicate == "classified_as", + ) + .order_by(RiAssertion.subject_key, RiAssertion.assertion_id, RiAssertion.id) + ).all() + ) + roles: dict[str, tuple[str, str | None]] = {} + for assertion in assertions: + value = assertion.value or {} + classification = value.get("classification") + if isinstance(classification, str) and classification: + confidence = value.get("confidence") + roles[assertion.subject_key] = ( + classification, + confidence if isinstance(confidence, str) else None, + ) + + files = tuple( + SnapshotFileFact( + path=node.stable_key.removeprefix("file:"), + language=node.language, + role=roles.get(node.stable_key, (None, None))[0], + role_confidence=roles.get(node.stable_key, (None, None))[1], + ) + for node in nodes + if node.node_kind == "file" and node.stable_key.startswith("file:") + ) + dependencies = tuple(self._project_dependency(node) for node in nodes if node.node_kind == "dependency") + language_counts = Counter(file.language for file in files if file.language) + primary_language = ( + {"python": "Python", "typescript": "TypeScript"}.get( + language_counts.most_common(1)[0][0], + language_counts.most_common(1)[0][0].title(), + ) + if language_counts + else "Unknown" + ) + framework_by_dependency = { + "react": "React", + "next": "Next.js", + "vue": "Vue", + "fastapi": "FastAPI", + "django": "Django", + "flask": "Flask", + } + dependency_names = {item.name.lower() for item in dependencies} + frameworks = tuple( + sorted(framework for name, framework in framework_by_dependency.items() if name in dependency_names) + ) + entry_points = tuple(sorted(file.path for file in files if file.role == "entrypoint")) + modules = self._project_modules(files) + + route_observations = list( + self.db.scalars( + select(RiObservation) + .where( + RiObservation.snapshot_id == snapshot.snapshot_id, + RiObservation.observed_kind == "route", + ) + .order_by( + RiObservation.subject_key, + RiObservation.referent_text, + RiObservation.observation_id, + RiObservation.id, + ) + ).all() + ) + route_evidence = self._evidence_for( + snapshot, + "observation_ref", + [observation.id for observation in route_observations], + ) + routes = tuple( + SnapshotRouteFact( + path=observation.referent_text or "Not available", + evidence_paths=tuple(sorted({item.path for item in route_evidence.get(observation.id, [])})), + ) + for observation in route_observations + ) + diagnostics = tuple( + (row.code, row.message, row.path) + for row in self.db.scalars( + select(RiDiagnostic) + .where(RiDiagnostic.snapshot_id == snapshot.snapshot_id) + .order_by( + RiDiagnostic.path, + RiDiagnostic.span_start_line, + RiDiagnostic.code, + RiDiagnostic.id, + ) + ).all() + ) + relationship_edges = list( + self.db.scalars( + select(RiEdge) + .where( + RiEdge.snapshot_id == snapshot.snapshot_id, + RiEdge.predicate.in_(ARCHITECTURE_RELATIONSHIP_EDGE_TYPES), + RiEdge.subject_kind == "file", + RiEdge.object_kind == "file", + ) + .order_by(RiEdge.subject_key, RiEdge.predicate, RiEdge.object_key, RiEdge.edge_id, RiEdge.id) + ).all() + ) + file_relationships = tuple( + SnapshotFileRelationshipFact( + subject_path=edge.subject_key.removeprefix("file:"), + predicate=edge.predicate, + object_path=edge.object_key.removeprefix("file:"), + ) + for edge in relationship_edges + ) + return ProductSnapshotProjection( + snapshot_id=snapshot.snapshot_id, + schema_version=snapshot.schema_version, + repository_id=snapshot.repository_id, + revision_kind=snapshot.revision_kind, + revision_value=snapshot.revision_value, + revision_ref=snapshot.revision_ref, + canonical_graph_hash=snapshot.canonical_graph_hash or "", + primary_language=primary_language, + frameworks=frameworks, + entry_points=entry_points, + modules=modules, + files=files, + dependencies=dependencies, + file_relationships=file_relationships, + routes=routes, + diagnostics=diagnostics, + ) + + @staticmethod + def _project_modules(files: tuple[SnapshotFileFact, ...]) -> tuple[SnapshotModuleFact, ...]: + grouped: dict[str, list[SnapshotFileFact]] = defaultdict(list) + for file in files: + parts = [part for part in file.path.strip("/").split("/") if part] + if file.role in {"controller", "route"}: + key = "api" + elif file.role in {"service", "repository", "middleware", "configuration", "test", "documentation"}: + key = file.role + elif file.role in {"model", "dto", "interface", "enum"}: + key = "domain" + elif parts and parts[0] in {"app", "src", "backend", "frontend", "apps"} and len(parts) > 1: + key = parts[1].lower() + else: + key = parts[0].lower() if parts else "repository" + grouped[key].append(file) + result: list[SnapshotModuleFact] = [] + for key, members in sorted(grouped.items()): + meaningful_roles = [ + item.role for item in members if item.role not in (None, "unknown", "documentation", "test") + ] + role = ( + Counter(meaningful_roles).most_common(1)[0][0] if meaningful_roles else (members[0].role or "unknown") + ) + result.append( + SnapshotModuleFact( + name=key.replace("-", " ").title(), + role=role, + paths=tuple(sorted(item.path for item in members)), + layer=layer_for_role(role), + ) + ) + return tuple(result) + + @staticmethod + def _project_dependency(node: RiNode) -> SnapshotDependencyFact: + properties: dict[str, Any] = node.properties or {} + raw_declarations = properties.get("declarations") + if not isinstance(raw_declarations, list): + raw_declarations = [properties] + declarations: list[SnapshotDependencyDeclaration] = [] + for raw in raw_declarations: + if not isinstance(raw, dict): + continue + declarations.append( + SnapshotDependencyDeclaration( + version=raw.get("version") if isinstance(raw.get("version"), str) else None, + dependency_type=raw.get("dependency_type") if isinstance(raw.get("dependency_type"), str) else None, + manifest_path=raw.get("manifest_path") if isinstance(raw.get("manifest_path"), str) else None, + workspace_path=raw.get("workspace_path") if isinstance(raw.get("workspace_path"), str) else None, + start_line=raw.get("start_line") if isinstance(raw.get("start_line"), int) else None, + end_line=raw.get("end_line") if isinstance(raw.get("end_line"), int) else None, + extractor=raw.get("extractor") if isinstance(raw.get("extractor"), str) else None, + extractor_version=raw.get("extractor_version") + if isinstance(raw.get("extractor_version"), str) + else None, + ) + ) + return SnapshotDependencyFact( + stable_key=node.stable_key, + name=node.name or node.stable_key, + ecosystem=properties.get("ecosystem") if isinstance(properties.get("ecosystem"), str) else None, + declarations=tuple(declarations), + ) + + def symbols(self, snapshot_id: str, *, offset: int, limit: int) -> tuple[RiSnapshot, list[RiNode], int]: + snapshot = self._snapshot(snapshot_id) + where = (RiNode.snapshot_id == snapshot.snapshot_id, RiNode.node_kind == "symbol") + rows, total = self._page(RiNode, where, (RiNode.stable_key, RiNode.id), offset, limit) + return snapshot, rows, total + + def neighbours( + self, snapshot_id: str, *, node_key: str, offset: int, limit: int + ) -> tuple[RiSnapshot, list[RiEdge], int]: + snapshot = self._snapshot(snapshot_id) + where = ( + RiEdge.snapshot_id == snapshot.snapshot_id, + or_(RiEdge.subject_key == node_key, RiEdge.object_key == node_key), + ) + rows, total = self._page( + RiEdge, + where, + (RiEdge.subject_key, RiEdge.predicate, RiEdge.object_key, RiEdge.edge_id, RiEdge.id), + offset, + limit, + ) + return snapshot, rows, total + + def impact(self, snapshot_id: str, *, node_key: str, depth: int) -> SnapshotImpact: + """Traverse stored import/dependency edges from one known snapshot node. + + The traversal is read-only, owner-scoped through :meth:`_snapshot`, and + only follows resolved edges. Each direction retains a canonical shortest + path to each reached node, so cycles cannot cause unbounded work or + duplicate result nodes. + """ + + snapshot = self._snapshot(snapshot_id) + node_exists = self.db.scalar( + select(RiNode.id).where( + RiNode.snapshot_id == snapshot.snapshot_id, + RiNode.stable_key == node_key, + ) + ) + if node_exists is None: + raise NotFoundError("Node not found in snapshot.") + return SnapshotImpact( + snapshot=snapshot, + node_key=node_key, + depth=depth, + dependents=self._impact_direction(snapshot, node_key=node_key, depth=depth, outbound=False), + dependencies=self._impact_direction(snapshot, node_key=node_key, depth=depth, outbound=True), + ) + + def references(self, snapshot_id: str, *, offset: int, limit: int) -> tuple[RiSnapshot, list[RiEdge], int]: + """Return only stored resolved relationship facts, never unresolved observations.""" + + snapshot = self._snapshot(snapshot_id) + where = (RiEdge.snapshot_id == snapshot.snapshot_id,) + rows, total = self._page( + RiEdge, + where, + (RiEdge.subject_key, RiEdge.predicate, RiEdge.object_key, RiEdge.edge_id, RiEdge.id), + offset, + limit, + ) + return snapshot, rows, total + + def assertions(self, snapshot_id: str, *, offset: int, limit: int) -> tuple[RiSnapshot, list[RiAssertion], int]: + snapshot = self._snapshot(snapshot_id) + rows, total = self._page( + RiAssertion, + (RiAssertion.snapshot_id == snapshot.snapshot_id,), + (RiAssertion.subject_key, RiAssertion.predicate, RiAssertion.assertion_id, RiAssertion.id), + offset, + limit, + ) + return snapshot, rows, total + + def paths(self, snapshot_id: str, *, offset: int, limit: int) -> tuple[RiSnapshot, list[RiNode], int]: + snapshot = self._snapshot(snapshot_id) + where = (RiNode.snapshot_id == snapshot.snapshot_id, RiNode.node_kind == "file") + rows, total = self._page(RiNode, where, (RiNode.stable_key, RiNode.id), offset, limit) + return snapshot, rows, total + + def evidence(self, snapshot_id: str, *, offset: int, limit: int) -> tuple[RiSnapshot, list[RiEvidence], int]: + snapshot = self._snapshot(snapshot_id) + where = (RiEvidence.snapshot_id == snapshot.snapshot_id,) + rows, total = self._page( + RiEvidence, + where, + ( + RiEvidence.path, + RiEvidence.start_line, + RiEvidence.end_line, + RiEvidence.granularity, + RiEvidence.extractor, + RiEvidence.extractor_version, + RiEvidence.id, + ), + offset, + limit, + ) + return snapshot, rows, total + + def evidence_for_nodes(self, snapshot: RiSnapshot, nodes: list[RiNode]) -> dict[int, list[RiEvidence]]: + return self._evidence_for(snapshot, "node_ref", [node.id for node in nodes]) + + def evidence_for_edges(self, snapshot: RiSnapshot, edges: list[RiEdge]) -> dict[int, list[RiEvidence]]: + return self._evidence_for(snapshot, "edge_ref", [edge.id for edge in edges]) + + def derivations_for_edges(self, snapshot: RiSnapshot, edges: list[RiEdge]) -> dict[int, list[RiDerivation]]: + if not edges: + return {} + rows = self.db.scalars( + select(RiDerivation) + .where( + RiDerivation.snapshot_id == snapshot.snapshot_id, RiDerivation.edge_ref.in_([edge.id for edge in edges]) + ) + .order_by(RiDerivation.ref_kind, RiDerivation.ref_identity, RiDerivation.id) + ).all() + grouped: dict[int, list[RiDerivation]] = defaultdict(list) + for row in rows: + if row.edge_ref is not None: + grouped[row.edge_ref].append(row) + return grouped + + def derivations_for_assertions( + self, snapshot: RiSnapshot, assertions: list[RiAssertion] + ) -> dict[int, list[RiDerivation]]: + if not assertions: + return {} + rows = self.db.scalars( + select(RiDerivation) + .where( + RiDerivation.snapshot_id == snapshot.snapshot_id, + RiDerivation.assertion_ref.in_([assertion.id for assertion in assertions]), + ) + .order_by(RiDerivation.ref_kind, RiDerivation.ref_identity, RiDerivation.id) + ).all() + grouped: dict[int, list[RiDerivation]] = defaultdict(list) + for row in rows: + if row.assertion_ref is not None: + grouped[row.assertion_ref].append(row) + return grouped + + def fact_identity_for_evidence( + self, snapshot: RiSnapshot, evidence: list[RiEvidence] + ) -> dict[tuple[str, int], str]: + node_refs = [item.node_ref for item in evidence if item.node_ref is not None] + edge_refs = [item.edge_ref for item in evidence if item.edge_ref is not None] + observation_refs = [item.observation_ref for item in evidence if item.observation_ref is not None] + identities: dict[tuple[str, int], str] = {} + if node_refs: + for node in self.db.scalars( + select(RiNode).where(RiNode.snapshot_id == snapshot.snapshot_id, RiNode.id.in_(node_refs)) + ): + identities[("node", node.id)] = node.stable_key + if edge_refs: + for edge in self.db.scalars( + select(RiEdge).where(RiEdge.snapshot_id == snapshot.snapshot_id, RiEdge.id.in_(edge_refs)) + ): + identities[("edge", edge.id)] = edge.edge_id + if observation_refs: + from app.models.snapshot import RiObservation + + for observation in self.db.scalars( + select(RiObservation).where( + RiObservation.snapshot_id == snapshot.snapshot_id, RiObservation.id.in_(observation_refs) + ) + ): + identities[("observation", observation.id)] = observation.observation_id + return identities + + def _snapshot(self, snapshot_id: str) -> RiSnapshot: + from app.models.repository import RepositoryRecord + + snapshot = self.db.scalars( + select(RiSnapshot) + .join(RepositoryRecord, RepositoryRecord.id == RiSnapshot.repository_id) + .where( + RiSnapshot.snapshot_id == snapshot_id, + RiSnapshot.state == "completed", + RepositoryRecord.owner_id == self.owner_id, + ) + ).first() + if snapshot is None: + raise NotFoundError("Snapshot not found.") + if snapshot.schema_version not in self.supported_schema_versions: + raise UnsupportedSchemaVersionError( + f"Unsupported snapshot schema version: {snapshot.schema_version}.", + details={"received": snapshot.schema_version, "supported": list(self.supported_schema_versions)}, + ) + return snapshot + + def _latest_snapshot(self, repository_id: str) -> RiSnapshot | None: + from app.models.repository import RepositoryRecord + + snapshot = self.db.scalars( + select(RiSnapshot) + .join(RepositoryRecord, RepositoryRecord.id == RiSnapshot.repository_id) + .where( + RiSnapshot.repository_id == repository_id, + RiSnapshot.state == "completed", + RepositoryRecord.owner_id == self.owner_id, + ) + .order_by(RiSnapshot.sealed_at.desc(), RiSnapshot.snapshot_id) + ).first() + if snapshot is not None and snapshot.schema_version not in self.supported_schema_versions: + raise UnsupportedSchemaVersionError( + f"Unsupported snapshot schema version: {snapshot.schema_version}.", + details={"received": snapshot.schema_version, "supported": list(self.supported_schema_versions)}, + ) + return snapshot + + def _page(self, model, where: tuple, order_by: tuple, offset: int, limit: int): + total = self.db.scalar(select(func.count()).select_from(model).where(*where)) or 0 + rows = self.db.scalars(select(model).where(*where).order_by(*order_by).offset(offset).limit(limit)).all() + return list(rows), total + + def _impact_direction( + self, + snapshot: RiSnapshot, + *, + node_key: str, + depth: int, + outbound: bool, + ) -> SnapshotImpactDirection: + """Walk one edge direction with deterministic breadth-first ordering.""" + + frontier = [node_key] + visited = {node_key} + steps: list[SnapshotImpactStep] = [] + endpoint = RiEdge.subject_key if outbound else RiEdge.object_key + adjacent = RiEdge.object_key if outbound else RiEdge.subject_key + + for distance in range(1, depth + 1): + if not frontier: + break + # A reached node can be proven by many current-frontier edges. + # Ranking one canonical edge per adjacent node *before* the cap is + # essential: limiting raw edges could otherwise exhaust the query + # budget on duplicate paths and falsely claim the result complete. + canonical_rank = ( + func.row_number() + .over( + partition_by=adjacent, + order_by=( + RiEdge.subject_key, + RiEdge.predicate, + RiEdge.object_key, + RiEdge.edge_id, + RiEdge.id, + ), + ) + .label("canonical_rank") + ) + ranked_edges = ( + select(RiEdge.id.label("edge_row_id"), canonical_rank) + .where( + RiEdge.snapshot_id == snapshot.snapshot_id, + RiEdge.predicate.in_(IMPACT_TRAVERSAL_PREDICATES), + endpoint.in_(frontier), + adjacent.not_in(visited), + ) + .subquery() + ) + edges = self.db.scalars( + select(RiEdge) + .join(ranked_edges, RiEdge.id == ranked_edges.c.edge_row_id) + .where(ranked_edges.c.canonical_rank == 1) + .order_by( + RiEdge.subject_key, + RiEdge.predicate, + RiEdge.object_key, + RiEdge.edge_id, + RiEdge.id, + ) + .limit(IMPACT_MAX_RESULTS_PER_DIRECTION + 1) + ).all() + + next_frontier: list[str] = [] + for edge in edges: + adjacent_key = edge.object_key if outbound else edge.subject_key + if len(steps) >= IMPACT_MAX_RESULTS_PER_DIRECTION: + return SnapshotImpactDirection(steps=tuple(steps), limit_reached=True) + visited.add(adjacent_key) + next_frontier.append(adjacent_key) + steps.append(SnapshotImpactStep(depth=distance, node_key=adjacent_key, edge=edge)) + frontier = next_frontier + + return SnapshotImpactDirection(steps=tuple(steps), limit_reached=False) + + def _architecture_covered_paths(self, snapshot: RiSnapshot) -> set[str]: + """Return snapshot paths that carry non-inventory extraction evidence. + + Inventory-only file evidence proves a path exists, not that a + relationship-capable extractor ran over it, so the architecture consumer + uses this set to avoid reporting an unsupported file as genuinely + isolated. + + The result is one distinct-path column read rather than a full evidence + materialization: previously the caller derived these paths by walking + every node and observation evidence row it had already loaded, which is + what made the read unbounded on large snapshots (#133). + """ + + return set( + self.db.scalars( + select(RiEvidence.path) + .where( + RiEvidence.snapshot_id == snapshot.snapshot_id, + RiEvidence.extractor != "repository-inventory", + or_(RiEvidence.node_ref.is_not(None), RiEvidence.observation_ref.is_not(None)), + ) + .distinct() + ).all() + ) + + def _evidence_for(self, snapshot: RiSnapshot, column: str, ids: list[int]) -> dict[int, list[RiEvidence]]: + if not ids: + return {} + field = getattr(RiEvidence, column) + grouped: dict[int, list[RiEvidence]] = defaultdict(list) + for start in range(0, len(ids), ARCHITECTURE_EVIDENCE_BATCH_SIZE): + rows = self.db.scalars( + select(RiEvidence) + .where( + RiEvidence.snapshot_id == snapshot.snapshot_id, + field.in_(ids[start : start + ARCHITECTURE_EVIDENCE_BATCH_SIZE]), + ) + .order_by( + RiEvidence.path, + RiEvidence.start_line, + RiEvidence.end_line, + RiEvidence.granularity, + RiEvidence.extractor, + RiEvidence.extractor_version, + RiEvidence.id, + ) + ).all() + for row in rows: + parent = getattr(row, column) + if parent is not None: + grouped[parent].append(row) + return grouped diff --git a/apps/backend/app/intelligence/resolution.py b/apps/backend/app/intelligence/resolution.py new file mode 100644 index 00000000..30317235 --- /dev/null +++ b/apps/backend/app/intelligence/resolution.py @@ -0,0 +1,698 @@ +"""Deterministic relationship resolution over a building RI snapshot (#91). + +Resolvers read stored nodes, observations, and evidence only. They never read +the repository working tree: the extractor output is the complete input +surface. A relationship is emitted only when the documented candidate set has +exactly one member; zero and multiple candidates become visible diagnostics. +""" + +from __future__ import annotations + +from collections import defaultdict +from collections.abc import Callable +from dataclasses import dataclass +import posixpath +import re + +from sqlalchemy import select + +from app.extraction.http import parse_referent +from app.extraction.naming import package_root +from app.intelligence.snapshot_store import Evidence, SnapshotStateError, SnapshotStore +from app.models.snapshot import RiEvidence, RiNode, RiObservation, RiSnapshot + + +RI_RES_UNRESOLVED = "RI-RES-UNRESOLVED" +RI_RES_AMBIGUOUS = "RI-RES-AMBIGUOUS" + + +@dataclass(frozen=True) +class ResolutionResult: + """The materialized facts from one deterministic resolver pass.""" + + edges_added: int + diagnostics_added: int + + +@dataclass(frozen=True) +class _ObservedInput: + observation: RiObservation + evidence: RiEvidence + + +class RelationshipResolver: + """Resolve the #91 relationship kinds from snapshot-persisted observations. + + Supported stored observation kinds are deliberately small and explicit: + + ``definition`` + A symbol definition. It resolves ``contains`` and ``defines`` from its + lexical parent (or its file for top-level symbols). + ``import`` + A TypeScript/Python module specifier. It resolves a local file or an + already-observed dependency node. + ``call`` / ``call_shadowed`` / ``implements`` + A direct reference. It resolves only through proven evidence — a full + stable key, a same-file top-level definition, or an explicit import + binding — never a repository-wide same-name guess. ``call_shadowed`` + marks a call-site name that lexical extraction proved local, forcing the + paired call to remain unresolved instead of borrowing a global/imported + symbol. Multiple binding targets stay ambiguous; missing evidence stays + unresolved. + ``route`` + ``route_handler`` + A route symbol and its extractor-recorded handler reference. Both are + needed, so a literal path alone is never treated as a handler claim. + ``dependency`` + A manifest-backed dependency declaration whose subject is a dependency + node. It resolves ``repo:root -> dependency``. + ``http_call`` + An outbound HTTP call site whose referent carries a proven method, + origin, and path (#209). It resolves `` -[calls_service]-> + `` against the service node the extractor emitted for that + exact origin. The caller is the symbol whose span contains the call, or + the file/module when no symbol does. + ``iac_resource`` + A declared infrastructure resource whose subject is an ``iac_resource`` + node (#209). It resolves ``repo:root -[declares]-> ``. + ``injects`` + A ``Depends(name)`` argument (#95). Resolved exactly like a ``call``: + through proven evidence only, attributed to whichever symbol's span + contains it. Produces an ``injects`` edge from the containing + function to the referenced dependency callable. + + New extractors can add observation kinds without making this class guess: + unsupported kinds are simply not relationship inputs. ``resolution`` (a + lockfile pin, #209) is deliberately one of those: a lockfile proves that a + version was installed, not that the repository depends on the package + directly, so a resolution stays an observation on its dependency node and + never manufactures a ``depends_on`` edge for a transitive entry. + """ + + name = "relationship-resolver" + version = "1.1.0" + + def __init__(self, store: SnapshotStore) -> None: + self.store = store + self._snapshot: RiSnapshot | None = None + + @property + def producer(self) -> str: + return f"{self.name}@{self.version}" + + def resolve( + self, + snapshot: RiSnapshot, + *, + check_cancelled: Callable[[], None] | None = None, + ) -> ResolutionResult: + """Add every resolvable #91 edge to one *building* snapshot. + + Existing matching edges are consolidated by :class:`SnapshotStore`; the + result count is consequently a count of attempted resolved facts rather + than a database-row count. Inputs are sorted by observation id so a + different insertion order cannot change facts or diagnostics. + """ + + self._check_cancelled(check_cancelled) + if snapshot.state != "building": + raise SnapshotStateError("relationship resolution requires a building snapshot") + if self.producer not in set(snapshot.producer_version_set): + raise ValueError(f"snapshot producer_version_set is missing {self.producer!r}") + self._snapshot = snapshot + + nodes: list[RiNode] = [] + for node in self.store.db.scalars(select(RiNode).where(RiNode.snapshot_id == snapshot.snapshot_id)).yield_per( + 500 + ): + self._check_cancelled(check_cancelled) + nodes.append(node) + nodes_by_key = {node.stable_key: node for node in nodes} + self._check_cancelled(check_cancelled) + observations: list[RiObservation] = [] + for observation in self.store.db.scalars( + select(RiObservation) + .where(RiObservation.snapshot_id == snapshot.snapshot_id) + .order_by(RiObservation.observation_id) + ).yield_per(500): + self._check_cancelled(check_cancelled) + observations.append(observation) + evidence_by_observation: dict[int, list[RiEvidence]] = defaultdict(list) + evidence_by_node: dict[int, list[RiEvidence]] = defaultdict(list) + for evidence in self.store.db.scalars( + select(RiEvidence).where(RiEvidence.snapshot_id == snapshot.snapshot_id) + ).yield_per(500): + self._check_cancelled(check_cancelled) + if evidence.observation_ref is not None: + evidence_by_observation[evidence.observation_ref].append(evidence) + if evidence.node_ref is not None: + evidence_by_node[evidence.node_ref].append(evidence) + + inputs: list[_ObservedInput] = [] + for observation in observations: + self._check_cancelled(check_cancelled) + evidence = evidence_by_observation.get(observation.id) + if evidence: + inputs.append( + _ObservedInput( + observation, + sorted(evidence, key=self._evidence_key)[0], + ) + ) + inputs_by_kind: dict[str, list[_ObservedInput]] = defaultdict(list) + for input_ in inputs: + self._check_cancelled(check_cancelled) + inputs_by_kind[input_.observation.observed_kind].append(input_) + bindings_by_file: dict[str, list[tuple[str, str, str]]] = defaultdict(list) + for input_ in inputs_by_kind["import_binding"]: + self._check_cancelled(check_cancelled) + source = self._source_file(input_, nodes_by_key) + parsed = self._parse_import_binding(input_.observation.referent_text) + if source is not None and parsed is not None: + bindings_by_file[source.stable_key].append(parsed) + shadowed_calls: set[tuple[str, str | None, str, int, int, int]] = set() + for input_ in inputs_by_kind["call_shadowed"]: + self._check_cancelled(check_cancelled) + shadowed_calls.add(self._reference_input_key(input_)) + + edges_added = 0 + diagnostics_added = 0 + + for input_ in inputs_by_kind["definition"]: + self._check_cancelled(check_cancelled) + added, diagnosed = self._resolve_definition(input_, nodes_by_key) + edges_added += added + diagnostics_added += diagnosed + + for input_ in inputs_by_kind["import"]: + self._check_cancelled(check_cancelled) + added, diagnosed = self._resolve_import(input_, nodes_by_key, bindings_by_file) + edges_added += added + diagnostics_added += diagnosed + + for input_ in inputs_by_kind["call"]: + self._check_cancelled(check_cancelled) + if self._reference_input_key(input_) in shadowed_calls: + added, diagnosed = self._unresolved(input_, "calls target is shadowed by a local binding") + else: + added, diagnosed = self._resolve_reference( + input_, nodes_by_key, evidence_by_node, bindings_by_file, predicate="calls" + ) + edges_added += added + diagnostics_added += diagnosed + + for input_ in inputs_by_kind["implements"]: + self._check_cancelled(check_cancelled) + added, diagnosed = self._resolve_reference( + input_, nodes_by_key, evidence_by_node, bindings_by_file, predicate="implements" + ) + edges_added += added + diagnostics_added += diagnosed + + for input_ in inputs_by_kind["dependency"]: + self._check_cancelled(check_cancelled) + added, diagnosed = self._resolve_dependency(input_, nodes_by_key) + edges_added += added + diagnostics_added += diagnosed + + for input_ in inputs_by_kind["injects"]: + self._check_cancelled(check_cancelled) + added, diagnosed = self._resolve_reference( + input_, nodes_by_key, evidence_by_node, bindings_by_file, predicate="injects" + ) + edges_added += added + diagnostics_added += diagnosed + + for input_ in inputs_by_kind["http_call"]: + self._check_cancelled(check_cancelled) + added, diagnosed = self._resolve_http_call(input_, nodes_by_key, evidence_by_node) + edges_added += added + diagnostics_added += diagnosed + + for input_ in inputs_by_kind["iac_resource"]: + self._check_cancelled(check_cancelled) + added, diagnosed = self._resolve_iac_resource(input_, nodes_by_key) + edges_added += added + diagnostics_added += diagnosed + + route_handlers: dict[str, list[_ObservedInput]] = defaultdict(list) + for input_ in inputs_by_kind["route_handler"]: + self._check_cancelled(check_cancelled) + route_handlers[input_.observation.subject_key].append(input_) + for input_ in inputs_by_kind["route"]: + self._check_cancelled(check_cancelled) + added, diagnosed = self._resolve_route( + input_, + route_handlers.get(input_.observation.subject_key, ()), + nodes_by_key, + bindings_by_file, + ) + edges_added += added + diagnostics_added += diagnosed + + return ResolutionResult(edges_added=edges_added, diagnostics_added=diagnostics_added) + + @staticmethod + def _check_cancelled(check_cancelled: Callable[[], None] | None) -> None: + if check_cancelled is not None: + check_cancelled() + + def _resolve_definition(self, input_: _ObservedInput, nodes_by_key: dict[str, RiNode]) -> tuple[int, int]: + observation = input_.observation + symbol = nodes_by_key.get(observation.subject_key) + if symbol is None or symbol.node_kind != "symbol" or "::" not in symbol.stable_key: + return self._unresolved(input_, "definition has no resolvable symbol parent") + path, qualified = symbol.stable_key.split("::", 1) + if "." in qualified: + parent_key = f"{path}::{qualified.rsplit('.', 1)[0]}" + else: + parent_key = f"file:{path}" + parent = nodes_by_key.get(parent_key) + if parent is None: + return self._unresolved(input_, "definition parent is absent from the snapshot") + self._add_edge(input_, parent, "contains", symbol) + self._add_edge(input_, parent, "defines", symbol) + return 2, 0 + + def _resolve_import( + self, + input_: _ObservedInput, + nodes_by_key: dict[str, RiNode], + bindings_by_file: dict[str, list[tuple[str, str, str]]], + ) -> tuple[int, int]: + subject = self._source_file(input_, nodes_by_key) + if subject is None: + return self._unresolved(input_, "import source file is absent from the snapshot") + specifier = input_.observation.referent_text + if not specifier: + return self._unresolved(input_, "import has no module specifier") + candidates = self._import_candidates( + specifier, + input_.evidence.path, + subject, + nodes_by_key, + bindings_by_file.get(subject.stable_key, ()), + ) + return self._resolve_candidates(input_, subject, "imports", candidates) + + def _resolve_reference( + self, + input_: _ObservedInput, + nodes_by_key: dict[str, RiNode], + evidence_by_node: dict[int, list[RiEvidence]], + bindings_by_file: dict[str, list[tuple[str, str, str]]], + *, + predicate: str, + ) -> tuple[int, int]: + subject = self._reference_subject(input_, nodes_by_key, evidence_by_node) + if subject is None: + return self._unresolved(input_, f"{predicate} source symbol is absent from the snapshot") + referent = input_.observation.referent_text + if not referent: + return self._unresolved(input_, f"{predicate} has no reference text") + return self._resolve_candidates( + input_, + subject, + predicate, + self._reference_candidates( + referent, + input_.evidence.path, + nodes_by_key, + bindings_by_file.get(f"file:{input_.evidence.path}", ()), + ), + ) + + def _resolve_dependency(self, input_: _ObservedInput, nodes_by_key: dict[str, RiNode]) -> tuple[int, int]: + dependency = nodes_by_key.get(input_.observation.subject_key) + repository = nodes_by_key.get("repo:root") + if dependency is None or dependency.node_kind != "dependency" or repository is None: + return self._unresolved(input_, "dependency declaration is incomplete") + self._add_edge(input_, repository, "depends_on", dependency) + return 1, 0 + + def _resolve_http_call( + self, + input_: _ObservedInput, + nodes_by_key: dict[str, RiNode], + evidence_by_node: dict[int, list[RiEvidence]], + ) -> tuple[int, int]: + """Resolve one proven outbound call to its service node (#209). + + The destination is not searched for or guessed: the extractor that + proved the literal URL also emitted the ``service`` node for that exact + origin, so this looks up one deterministic key. A referent that does not + carry the three expected parts, or an origin with no node, stays an + explicit diagnostic rather than becoming an edge to somewhere plausible. + """ + + referent = input_.observation.referent_text + destination = parse_referent(referent) if referent else None + if destination is None: + return self._unresolved(input_, "calls_service has no parsable destination") + subject = self._reference_subject(input_, nodes_by_key, evidence_by_node) + if subject is None: + return self._unresolved(input_, "calls_service source is absent from the snapshot") + service = nodes_by_key.get(f"svc:{destination.origin}") + if service is None or service.node_kind != "service": + return self._unresolved(input_, "calls_service target service is absent from the snapshot") + self._add_edge(input_, subject, "calls_service", service) + return 1, 0 + + def _resolve_iac_resource(self, input_: _ObservedInput, nodes_by_key: dict[str, RiNode]) -> tuple[int, int]: + """Attach a declared infrastructure resource to the repository (#209).""" + + resource = nodes_by_key.get(input_.observation.subject_key) + repository = nodes_by_key.get("repo:root") + if resource is None or resource.node_kind != "iac_resource" or repository is None: + return self._unresolved(input_, "iac resource declaration is incomplete") + self._add_edge(input_, repository, "declares", resource) + return 1, 0 + + def _reference_subject( + self, + input_: _ObservedInput, + nodes_by_key: dict[str, RiNode], + evidence_by_node: dict[int, list[RiEvidence]], + ) -> RiNode | None: + """Pick the fact that owns a call site: its symbol, else its file/module. + + Shared with :meth:`_resolve_reference` so ``calls`` and ``calls_service`` + attribute a call site to the same subject. + """ + + observed_subject = nodes_by_key.get(input_.observation.subject_key) + if observed_subject is not None and observed_subject.node_kind == "symbol": + return observed_subject + subject = self._containing_symbol(input_, nodes_by_key, evidence_by_node) + if subject is None and observed_subject is not None and observed_subject.node_kind in {"file", "module"}: + return observed_subject + return subject + + def _resolve_route( + self, + route: _ObservedInput, + handlers: list[_ObservedInput] | tuple[_ObservedInput, ...], + nodes_by_key: dict[str, RiNode], + bindings_by_file: dict[str, list[tuple[str, str, str]]], + ) -> tuple[int, int]: + route_node = nodes_by_key.get(route.observation.subject_key) + if route_node is None or route_node.node_kind != "symbol": + return self._unresolved(route, "route declaration is absent from the snapshot") + if len(handlers) != 1: + if not handlers: + return self._unresolved(route, "route has no observed handler reference") + return self._ambiguous(route, "route has more than one handler reference", ()) + handler = handlers[0] + referent = handler.observation.referent_text + if not referent: + return self._unresolved(handler, "route handler has no reference text") + return self._resolve_candidates( + handler, + route_node, + "routes_to", + self._reference_candidates( + referent, + handler.evidence.path, + nodes_by_key, + bindings_by_file.get(f"file:{handler.evidence.path}", ()), + ), + ) + + def _resolve_candidates( + self, + input_: _ObservedInput, + subject: RiNode, + predicate: str, + candidates: list[RiNode], + ) -> tuple[int, int]: + unique = {candidate.stable_key: candidate for candidate in candidates} + ordered = [unique[key] for key in sorted(unique)] + if not ordered: + return self._unresolved(input_, f"{predicate} has no resolvable target") + if len(ordered) > 1: + return self._ambiguous(input_, f"{predicate} has more than one candidate target", tuple(unique)) + self._add_edge(input_, subject, predicate, ordered[0]) + return 1, 0 + + def _import_candidates( + self, + specifier: str, + source_path: str, + source: RiNode, + nodes_by_key: dict[str, RiNode], + bindings: list[tuple[str, str, str]] | tuple[tuple[str, str, str], ...] = (), + ) -> list[RiNode]: + file_candidates: set[str] = set() + if specifier.startswith("."): + file_candidates.update(self._relative_file_candidates(specifier, source_path)) + elif source_path.endswith(".py") and re.fullmatch(r"[A-Za-z_][A-Za-z0-9_.]*", specifier): + file_candidates.update(self._python_absolute_file_candidates(specifier)) + # ``from a.b import c`` is stored as import referent ``a.b.c`` whose + # module is ``a.b`` — the imported name may be a submodule (``a/b/c.py``) + # or a member of ``a/b.py``. The stored binding preserves that split, + # so the module file resolves without reparsing the source. + for binding_specifier, imported, _local in bindings: + if binding_specifier and f"{binding_specifier}.{imported}" == specifier: + file_candidates.update(self._python_absolute_file_candidates(binding_specifier)) + files = [ + node + for key, node in nodes_by_key.items() + if key in {f"file:{path}" for path in file_candidates} and node.node_kind == "file" + ] + if files: + return files + + package = package_root(specifier, source_path) + normalized_python = re.sub(r"[-_.]+", "-", package).lower() + return [ + node + for node in nodes_by_key.values() + if node.node_kind == "dependency" + and (node.stable_key == f"dep:npm:{package}" or node.stable_key == f"dep:pypi:{normalized_python}") + ] + + @staticmethod + def _relative_file_candidates(specifier: str, source_path: str) -> set[str]: + """Return all documented local-file candidates; never apply precedence.""" + + leading = len(specifier) - len(specifier.lstrip(".")) + remainder = specifier[leading:] + directory = posixpath.dirname(source_path) + # TypeScript relative imports use ``./`` / ``../``; Python uses dots. + if remainder.startswith("/"): + base = posixpath.normpath(posixpath.join(directory, specifier)) + roots = [base] + else: + for _ in range(max(0, leading - 1)): + directory = posixpath.dirname(directory) + parts = [part for part in remainder.split(".") if part] + roots = [] + # ``from .pkg import symbol`` is recorded as ``.pkg.symbol`` by the + # current extractor. Every module prefix is a candidate, rather + # than assuming the final component is a module or symbol. + for end in range(len(parts), 0, -1): + roots.append(posixpath.join(directory, *parts[:end])) + paths: set[str] = set() + for root in roots: + if root.endswith((".ts", ".tsx", ".py")): + paths.add(root) + else: + paths.update( + { + f"{root}.ts", + f"{root}.tsx", + f"{root}.py", + f"{root}/index.ts", + f"{root}/index.tsx", + f"{root}/__init__.py", + } + ) + return paths + + @staticmethod + def _python_absolute_file_candidates(specifier: str) -> set[str]: + root = specifier.replace(".", "/") + return {f"{root}.py", f"{root}/__init__.py"} + + def _reference_candidates( + self, + referent: str, + source_path: str, + nodes_by_key: dict[str, RiNode], + bindings: list[tuple[str, str, str]] | tuple[tuple[str, str, str], ...] = (), + ) -> list[RiNode]: + """Return only targets a stored syntax fact uniquely proves. + + Evidence is considered in a fixed order: a full stable-key referent, a + same-file top-level definition, then the explicit import bindings for the + source file. There is deliberately no repository-wide same-name + fallback. Without a same-file definition or an import binding, a lone + symbol with the same name elsewhere is not proof; and when a binding + exists but its module or exported symbol cannot be resolved, this returns + no candidate (an unresolved diagnostic) rather than borrowing an + unrelated same-named symbol. Multiple binding targets stay ambiguous. + """ + + direct = nodes_by_key.get(referent) + if direct is not None and direct.node_kind == "symbol": + return [direct] + same_file = nodes_by_key.get(f"{source_path}::{referent}") + if same_file is not None and same_file.node_kind == "symbol": + return [same_file] + source = nodes_by_key.get(f"file:{source_path}") + if source is None: + return [] + bound_candidates: list[RiNode] = [] + for specifier, imported, local in bindings: + if local != referent: + continue + for target_file in self._import_candidates(specifier, source_path, source, nodes_by_key): + if target_file.node_kind != "file": + continue + path = target_file.stable_key.removeprefix("file:") + if imported == "default": + bound_candidates.extend( + node + for node in nodes_by_key.values() + if node.node_kind == "symbol" + and node.stable_key.startswith(f"{path}::") + and bool((node.properties or {}).get("default_export")) + ) + continue + direct_target = nodes_by_key.get(f"{path}::{imported}") + if direct_target is not None and direct_target.node_kind == "symbol": + bound_candidates.append(direct_target) + else: + bound_candidates.extend( + node + for node in nodes_by_key.values() + if node.node_kind == "symbol" + and node.name == imported + and node.stable_key.startswith(f"{path}::") + ) + return bound_candidates + + @staticmethod + def _source_file(input_: _ObservedInput, nodes_by_key: dict[str, RiNode]) -> RiNode | None: + subject = nodes_by_key.get(input_.observation.subject_key) + if subject is not None and subject.node_kind == "file": + return subject + return nodes_by_key.get(f"file:{input_.evidence.path}") + + @staticmethod + def _parse_import_binding(value: str | None) -> tuple[str, str, str] | None: + if value is None: + return None + parts = value.split("|", 2) + if len(parts) != 3 or not all(parts): + return None + return parts[0], parts[1], parts[2] + + @staticmethod + def _containing_symbol( + input_: _ObservedInput, + nodes_by_key: dict[str, RiNode], + evidence_by_node: dict[int, list[RiEvidence]], + ) -> RiNode | None: + candidates: list[tuple[int, str, RiNode]] = [] + for node in nodes_by_key.values(): + if node.node_kind != "symbol": + continue + for evidence in evidence_by_node.get(node.id, ()): + if ( + evidence.path == input_.evidence.path + and evidence.start_line <= input_.evidence.start_line + and evidence.end_line >= input_.evidence.end_line + ): + candidates.append((evidence.end_line - evidence.start_line, node.stable_key, node)) + if not candidates: + return None + candidates.sort(key=lambda item: (item[0], item[1])) + if len(candidates) > 1 and candidates[0][0] == candidates[1][0]: + return None + return candidates[0][2] + + def _add_edge(self, input_: _ObservedInput, subject: RiNode, predicate: str, object_: RiNode) -> None: + snapshot = self._require_snapshot() + self.store.add_edge( + snapshot, + subject_kind=subject.node_kind, + subject_key=subject.stable_key, + predicate=predicate, + object_kind=object_.node_kind, + object_key=object_.stable_key, + producer=self.name, + producer_version=self.version, + evidence=(self._resolver_evidence(input_.evidence),), + derived_from=({"kind": "observation", "observation_id": input_.observation.observation_id},), + ) + + def _unresolved(self, input_: _ObservedInput, message: str) -> tuple[int, int]: + self._diagnostic(input_, RI_RES_UNRESOLVED, "unresolved reference", message) + return 0, 1 + + def _ambiguous(self, input_: _ObservedInput, message: str, candidates: tuple[str, ...]) -> tuple[int, int]: + self._diagnostic(input_, RI_RES_AMBIGUOUS, "ambiguous resolution", message, candidates) + return 0, 1 + + def _diagnostic( + self, + input_: _ObservedInput, + code: str, + category: str, + message: str, + candidates: tuple[str, ...] = (), + ) -> None: + snapshot = self._require_snapshot() + details: dict[str, object] = {"observation_id": input_.observation.observation_id} + if candidates: + details["candidates"] = sorted(candidates) + self.store.add_diagnostic( + snapshot, + code=code, + category=category, + severity="warning", + message=message, + producer=self.producer, + path=input_.evidence.path, + span=(input_.evidence.start_line, input_.evidence.end_line), + subject=input_.observation.subject_key, + details=details, + ) + + def _resolver_evidence(self, evidence: RiEvidence) -> Evidence: + return Evidence( + path=evidence.path, + start_line=evidence.start_line, + end_line=evidence.end_line, + extractor=self.name, + extractor_version=self.version, + logical_line_count=evidence.logical_line_count, + granularity=evidence.granularity, + ) + + def _require_snapshot(self) -> RiSnapshot: + if self._snapshot is None: + raise RuntimeError("relationship resolver has no active snapshot") + return self._snapshot + + @staticmethod + def _evidence_key(evidence: RiEvidence) -> tuple[str, int, int, str, str, str]: + return ( + evidence.path, + evidence.start_line, + evidence.end_line, + evidence.granularity, + evidence.extractor, + evidence.extractor_version, + ) + + @staticmethod + def _reference_input_key(input_: _ObservedInput) -> tuple[str, str | None, str, int, int, int]: + return ( + input_.observation.subject_key, + input_.observation.referent_text, + input_.evidence.path, + input_.evidence.start_line, + input_.evidence.end_line, + input_.observation.ordinal, + ) diff --git a/apps/backend/app/intelligence/snapshot_store.py b/apps/backend/app/intelligence/snapshot_store.py new file mode 100644 index 00000000..54714920 --- /dev/null +++ b/apps/backend/app/intelligence/snapshot_store.py @@ -0,0 +1,1427 @@ +"""Persistence, lifecycle, and sealing for Repository Intelligence snapshots. + +This is the persistence boundary for the ``ri.v1`` storage contract (#88, +RFC-0001 §11). It does **not** produce facts: extraction, resolution, and +classification remain the responsibility of the extraction, resolution, and +classification producers (#89-#91). The store accepts already-produced +facts, enforces the RFC invariants, seals the snapshot in one transaction, and +guarantees immutability afterwards. + +Responsibilities: + +- create ``building`` snapshots keyed on a complete semantic identity (RFC §3.3); +- accept nodes, edges, assertions, observations, evidence, derivation + references, and diagnostics, computing their deterministic identities; +- reject provenance that violates the contract at write time (absolute paths, + traversal, invalid spans); +- run pre-seal validation (RFC §11.2), compute the canonical graph hash + (RFC §12), and flip ``building`` -> ``completed`` atomically; +- reuse an existing completed snapshot for an identical semantic identity, and + create a distinct snapshot when any identity component changes (RFC §3.4); +- reject every mutation of a completed snapshot at the persistence boundary + (RFC §11.3), via a ``before_flush`` guard that catches direct ORM writes too. +""" + +from __future__ import annotations + +import uuid +import re +from collections import defaultdict +from collections.abc import Callable, Mapping, Sequence +from dataclasses import dataclass +from datetime import UTC, datetime + +from sqlalchemy import inspect, select +from sqlalchemy import event +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from app.intelligence import canonical +from app.models.snapshot import ( + RiAssertion, + RiDerivation, + RiDiagnostic, + RiEdge, + RiEvidence, + RiNode, + RiObservation, + RiSnapshot, +) +from app.models.repository import RepositoryRecord + +SCHEMA_VERSION = canonical.SCHEMA_VERSION + +_CHILD_TYPES = (RiNode, RiEdge, RiAssertion, RiObservation, RiEvidence, RiDerivation, RiDiagnostic) +_FATAL_SEVERITY = "fatal" +_GIT_REVISION_RE = re.compile(r"^[0-9a-f]{40}$") +_UPLOAD_REVISION_RE = re.compile(r"^sha256:[0-9a-f]{64}$") +_HASH_RE = re.compile(r"^sha256:[0-9a-f]{64}$") +_ALLOWED_TRANSITIONS_KEY = "ri_allowed_snapshot_transitions" +_DIAGNOSTIC_SET_ARRAY_KEYS = frozenset({"candidates"}) + + +class SnapshotError(Exception): + """Base class for snapshot persistence errors.""" + + +class SnapshotImmutableError(SnapshotError): + """A completed snapshot (or one of its facts) was modified (RFC §11.3).""" + + +class SnapshotSealError(SnapshotError): + """Pre-seal validation failed; the snapshot must not seal (RFC §11.2).""" + + +class SnapshotStateError(SnapshotError): + """An operation was attempted against a snapshot in the wrong state.""" + + +class SnapshotAlreadySealedError(SnapshotError): + """A second snapshot tried to seal for an identity that is already sealed.""" + + def __init__(self, message: str, existing: RiSnapshot | None = None) -> None: + super().__init__(message) + self.existing = existing + + +# --------------------------------------------------------------------------- +# Completed-snapshot immutability guard (RFC §11.3) +# --------------------------------------------------------------------------- + + +def _persisted_state(snapshot: RiSnapshot) -> str | None: + """Return a snapshot's database-persisted state (before the current flush). + + During the sealing flush the in-memory state is already ``completed`` while + the persisted value is still ``building``; this reads the persisted value so + the seal itself is permitted while post-seal writes are rejected. + """ + + history = inspect(snapshot).attrs.state.history + if history.deleted: + return history.deleted[0] + if history.unchanged: + return history.unchanged[0] + return None + + +def _owning_snapshot(session: Session, obj: object) -> RiSnapshot | None: + snapshot_id = getattr(obj, "snapshot_id", None) + if snapshot_id is None: + return None + key = session.identity_key(RiSnapshot, snapshot_id) + return session.identity_map.get(key) + + +def _stored_snapshot_state(session: Session, snapshot_id: str | None) -> str | None: + if snapshot_id is None: + return None + snapshot = session.identity_map.get(session.identity_key(RiSnapshot, snapshot_id)) + if snapshot is not None: + state = _persisted_state(snapshot) + if state is not None: + return state + if snapshot in session.new: + return snapshot.state + return ( + session.connection() + .execute(select(RiSnapshot.state).where(RiSnapshot.snapshot_id == snapshot_id)) + .scalar_one_or_none() + ) + + +@event.listens_for(Session, "before_flush") +def _guard_completed_snapshots(session: Session, _flush_context, _instances) -> None: + for obj in list(session.new) + list(session.dirty): + if isinstance(obj, RiSnapshot): + persisted_state = _persisted_state(obj) + if persisted_state is None and obj not in session.new: + persisted_state = ( + session.connection() + .execute(select(RiSnapshot.state).where(RiSnapshot.snapshot_id == obj.snapshot_id)) + .scalar_one_or_none() + ) + if persisted_state == "completed": + raise SnapshotImmutableError("A completed snapshot is immutable and cannot be modified.") + if persisted_state is not None and obj.state != persisted_state: + allowed = session.info.get(_ALLOWED_TRANSITIONS_KEY, set()) + if (obj.snapshot_id, persisted_state, obj.state) not in allowed: + raise SnapshotStateError( + f"snapshot lifecycle transition {persisted_state!r} -> {obj.state!r} " + "must go through SnapshotStore" + ) + elif isinstance(obj, _CHILD_TYPES): + if _stored_snapshot_state(session, getattr(obj, "snapshot_id", None)) == "completed": + raise SnapshotImmutableError("Facts of a completed snapshot are immutable.") + for obj in list(session.deleted): + # Deleting the whole snapshot (retention / repository deletion cascade) + # is allowed; surgically deleting a fact from a still-present completed + # snapshot is a mutation and is rejected. + if isinstance(obj, RiSnapshot) and _persisted_state(obj) == "completed": + raise SnapshotImmutableError("A completed snapshot cannot be deleted directly.") + if isinstance(obj, _CHILD_TYPES): + snapshot = _owning_snapshot(session, obj) + if (snapshot is None or snapshot not in session.deleted) and _stored_snapshot_state( + session, getattr(obj, "snapshot_id", None) + ) == "completed": + raise SnapshotImmutableError("Facts of a completed snapshot cannot be deleted.") + + +@event.listens_for(Session, "do_orm_execute") +def _reject_bulk_snapshot_mutation(orm_execute_state) -> None: + """Bulk ORM writes bypass per-instance history, so reject that route. + + SnapshotStore's instance-based methods are the supported persistence path; + allowing ``session.execute(update(...))`` would bypass both lifecycle and + canonical-hash maintenance. + """ + + if not (orm_execute_state.is_update or orm_execute_state.is_delete): + return + table = getattr(orm_execute_state.statement, "table", None) + if table is not None and table.name in { + "ri_snapshots", + "ri_nodes", + "ri_edges", + "ri_assertions", + "ri_observations", + "ri_evidence", + "ri_derivations", + "ri_diagnostics", + }: + raise SnapshotImmutableError("Bulk snapshot mutations are not a supported persistence route.") + + +# --------------------------------------------------------------------------- +# Input value objects +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class Revision: + kind: str + value: str + ref: str | None = None + + +@dataclass(frozen=True) +class Evidence: + path: str + start_line: int + end_line: int + extractor: str + extractor_version: str + logical_line_count: int + granularity: str = "span" + + def as_mapping(self) -> dict[str, object]: + return { + "path": self.path, + "start_line": self.start_line, + "end_line": self.end_line, + "granularity": self.granularity, + "extractor": self.extractor, + "extractor_version": self.extractor_version, + } + + +def observation_ref(observation_id: str) -> dict[str, str]: + return {"kind": "observation", "observation_id": observation_id} + + +def node_ref(stable_key: str) -> dict[str, str]: + return {"kind": "node", "stable_key": stable_key} + + +def edge_ref(edge_id: str) -> dict[str, str]: + return {"kind": "edge", "edge_id": edge_id} + + +def assertion_ref(assertion_id: str) -> dict[str, str]: + return {"kind": "assertion", "assertion_id": assertion_id} + + +# --------------------------------------------------------------------------- +# Store +# --------------------------------------------------------------------------- + + +class SnapshotStore: + def __init__(self, db: Session) -> None: + self.db = db + + # -- identity / lifecycle ------------------------------------------------ + + def find_completed( + self, + *, + repository_id: str, + revision_value: str, + schema_version: str, + producer_version_set: Sequence[str], + config_hash: str, + ) -> RiSnapshot | None: + statement = select(RiSnapshot).where( + RiSnapshot.repository_id == repository_id, + RiSnapshot.revision_value == revision_value, + RiSnapshot.schema_version == schema_version, + RiSnapshot.producer_set_hash == canonical.producer_set_hash(producer_version_set), + RiSnapshot.config_hash == config_hash, + RiSnapshot.state == "completed", + ) + return self.db.scalars(statement).first() + + def get_for_owner(self, snapshot_id: str, owner_id: str) -> RiSnapshot | None: + """Owner-scoped snapshot lookup; cross-owner and missing both return None.""" + + statement = ( + select(RiSnapshot) + .join(RepositoryRecord, RepositoryRecord.id == RiSnapshot.repository_id) + .where(RiSnapshot.snapshot_id == snapshot_id, RepositoryRecord.owner_id == owner_id) + ) + return self.db.scalars(statement).first() + + def find_completed_for_owner( + self, + *, + owner_id: str, + repository_id: str, + revision_value: str, + schema_version: str, + producer_version_set: Sequence[str], + config_hash: str, + ) -> RiSnapshot | None: + statement = ( + select(RiSnapshot) + .join(RepositoryRecord, RepositoryRecord.id == RiSnapshot.repository_id) + .where( + RepositoryRecord.owner_id == owner_id, + RiSnapshot.repository_id == repository_id, + RiSnapshot.revision_value == revision_value, + RiSnapshot.schema_version == schema_version, + RiSnapshot.producer_set_hash == canonical.producer_set_hash(producer_version_set), + RiSnapshot.config_hash == config_hash, + RiSnapshot.state == "completed", + ) + ) + return self.db.scalars(statement).first() + + def begin( + self, + *, + repository_id: str, + revision: Revision, + producer_version_set: Sequence[str], + schema_version: str = SCHEMA_VERSION, + config: Mapping[str, object] | None = None, + config_hash: str | None = None, + config_set_array_keys: frozenset[str] = frozenset(), + config_path_keys: frozenset[str] = frozenset(), + ) -> RiSnapshot: + """Create a ``building`` snapshot with a fixed semantic identity. + + The complete semantic identity — including the normalized planned + producer set and ``config_hash`` — is computed here, before any facts are + written, exactly as required for pre-enqueue idempotency (RFC §3.3). + """ + + self._require_matching_repository(repository_id, revision) + + producers = canonical.normalize_producer_version_set(producer_version_set) + if any("@" not in producer or producer.startswith("@") or producer.endswith("@") for producer in producers): + raise SnapshotSealError("producer_version_set entries must use 'producer@version'") + resolved_config_hash = self._resolve_config_hash( + config=config, + config_hash=config_hash, + set_array_keys=config_set_array_keys, + path_keys=config_path_keys, + ) + if not _HASH_RE.fullmatch(resolved_config_hash): + raise SnapshotSealError("config_hash must be a sha256 content identity") + snapshot = RiSnapshot( + snapshot_id=f"snap_{uuid.uuid4().hex}", + repository_id=repository_id, + revision_kind=revision.kind, + revision_value=revision.value, + revision_ref=revision.ref, + schema_version=schema_version, + producer_version_set=producers, + producer_set_hash=canonical.producer_set_hash(producers), + config_hash=resolved_config_hash, + state="building", + ) + self.db.add(snapshot) + self.db.flush() + return snapshot + + def get_or_reuse( + self, + *, + repository_id: str, + revision: Revision, + producer_version_set: Sequence[str], + schema_version: str = SCHEMA_VERSION, + config: Mapping[str, object] | None = None, + config_hash: str | None = None, + config_set_array_keys: frozenset[str] = frozenset(), + config_path_keys: frozenset[str] = frozenset(), + ) -> tuple[RiSnapshot, bool]: + """Return ``(snapshot, reused)`` for a semantic identity (RFC §3.4). + + An identical semantic identity reuses the existing completed snapshot; a + changed revision, schema version, producer set, or config produces a new + ``building`` snapshot. + """ + + # Validate the supplied revision and confirm it matches the repository + # *before* searching for a reusable snapshot. ``find_completed`` keys on + # ``revision_value`` alone, so an unvalidated malformed request (e.g. a + # ``git`` kind carrying an ``sha256:`` value) could otherwise be answered + # with an unrelated upload snapshot that merely shares the value. + self._require_matching_repository(repository_id, revision) + resolved_config_hash = self._resolve_config_hash( + config=config, + config_hash=config_hash, + set_array_keys=config_set_array_keys, + path_keys=config_path_keys, + ) + existing = self.find_completed( + repository_id=repository_id, + revision_value=revision.value, + schema_version=schema_version, + producer_version_set=producer_version_set, + config_hash=resolved_config_hash, + ) + if existing is not None: + return existing, True + snapshot = self.begin( + repository_id=repository_id, + revision=revision, + producer_version_set=producer_version_set, + schema_version=schema_version, + config_hash=resolved_config_hash, + config_set_array_keys=config_set_array_keys, + config_path_keys=config_path_keys, + ) + return snapshot, False + + def mark_failed(self, snapshot: RiSnapshot, *, code: str | None = None) -> RiSnapshot: + self._require_building(snapshot) + snapshot.state = "failed" + snapshot.failure_code = code + self._commit_transition(snapshot, from_state="building", to_state="failed") + return snapshot + + # -- fact writers -------------------------------------------------------- + + def add_node( + self, + snapshot: RiSnapshot, + *, + node_kind: str, + stable_key: str, + name: str | None = None, + language: str | None = None, + properties: Mapping[str, object] | None = None, + evidence: Sequence[Evidence] = (), + truth_class: str = "observed", + set_array_keys: frozenset[str] = frozenset(), + ordered_array_keys: frozenset[str] = frozenset(), + ) -> RiNode: + self._require_building(snapshot) + node_kind = canonical.nfc(node_kind) + stable_key = canonical.normalize_stable_key(node_kind, stable_key) + normalized_properties = ( + canonical.normalize_declared_arrays( + dict(properties), + set_array_keys=set_array_keys, + ordered_array_keys=ordered_array_keys, + context="node properties", + ) + if properties + else None + ) + name = canonical.nfc(name) if name is not None else None + language = canonical.nfc(language) if language is not None else None + existing = self.db.scalars( + select(RiNode).where( + RiNode.snapshot_id == snapshot.snapshot_id, + RiNode.stable_key == stable_key, + ) + ).first() + if existing is not None: + candidate = (node_kind, name, language, truth_class, normalized_properties) + stored = ( + existing.node_kind, + existing.name, + existing.language, + existing.truth_class, + existing.properties, + ) + if candidate != stored: + raise SnapshotSealError(f"conflicting node records share stable key {stable_key!r}") + for record in evidence: + self._add_evidence(snapshot, record, node_ref=existing.id) + return existing + node = RiNode( + snapshot_id=snapshot.snapshot_id, + stable_key=stable_key, + node_kind=node_kind, + name=name, + language=language, + truth_class=truth_class, + properties=normalized_properties, + ) + self.db.add(node) + self.db.flush() + for record in evidence: + self._add_evidence(snapshot, record, node_ref=node.id) + return node + + def add_observation( + self, + snapshot: RiSnapshot, + *, + observed_kind: str, + subject_kind: str, + subject_key: str, + evidence: Evidence, + referent_text: str | None = None, + ordinal: int = 1, + ) -> RiObservation: + self._require_building(snapshot) + observed_kind = canonical.validate_predicate(observed_kind) + subject_kind = canonical.nfc(subject_kind) + subject_key = canonical.normalize_stable_key(subject_kind, subject_key) + referent_text = canonical.nfc(referent_text) if referent_text is not None else None + observation_id = canonical.compute_observation_id( + revision_kind=snapshot.revision_kind, + revision_value=snapshot.revision_value, + observed_kind=observed_kind, + subject_kind=subject_kind, + subject_key=subject_key, + referent_text=referent_text, + ordinal=ordinal, + evidence=evidence.as_mapping(), + schema_version=snapshot.schema_version, + ) + existing = self.db.scalars( + select(RiObservation).where( + RiObservation.snapshot_id == snapshot.snapshot_id, + RiObservation.observation_id == observation_id, + ) + ).first() + if existing is not None: + self._add_evidence(snapshot, evidence, observation_ref=existing.id) + return existing + observation = RiObservation( + snapshot_id=snapshot.snapshot_id, + observation_id=observation_id, + observed_kind=observed_kind, + subject_kind=subject_kind, + subject_key=subject_key, + referent_text=referent_text, + ordinal=ordinal, + ) + self.db.add(observation) + self.db.flush() + self._add_evidence(snapshot, evidence, observation_ref=observation.id) + return observation + + def add_edge( + self, + snapshot: RiSnapshot, + *, + subject_kind: str, + subject_key: str, + predicate: str, + object_kind: str, + object_key: str, + producer: str, + producer_version: str, + evidence: Sequence[Evidence] = (), + derived_from: Sequence[Mapping[str, str]] = (), + truth_class: str = "resolved", + ) -> RiEdge: + self._require_building(snapshot) + subject_kind = canonical.nfc(subject_kind) + object_kind = canonical.nfc(object_kind) + subject_key = canonical.normalize_stable_key(subject_kind, subject_key) + object_key = canonical.normalize_stable_key(object_kind, object_key) + predicate = canonical.validate_predicate(predicate) + producer = canonical.nfc(producer) + producer_version = canonical.nfc(producer_version) + edge_id = canonical.compute_edge_id(subject_key, predicate, object_key) + existing = self.db.scalars( + select(RiEdge).where( + RiEdge.snapshot_id == snapshot.snapshot_id, + RiEdge.edge_id == edge_id, + ) + ).first() + if existing is not None: + candidate = (subject_kind, object_kind, truth_class, producer, producer_version) + stored = ( + existing.subject_kind, + existing.object_kind, + existing.truth_class, + existing.producer, + existing.producer_version, + ) + if candidate != stored: + raise SnapshotSealError(f"conflicting edge records share identity {edge_id!r}") + for record in evidence: + self._add_evidence(snapshot, record, edge_ref=existing.id) + for reference in derived_from: + self._add_derivation(snapshot, reference, edge_ref=existing.id) + return existing + edge = RiEdge( + snapshot_id=snapshot.snapshot_id, + edge_id=edge_id, + subject_kind=subject_kind, + subject_key=subject_key, + predicate=predicate, + object_kind=object_kind, + object_key=object_key, + truth_class=truth_class, + producer=producer, + producer_version=producer_version, + ) + self.db.add(edge) + self.db.flush() + for record in evidence: + self._add_evidence(snapshot, record, edge_ref=edge.id) + for reference in derived_from: + self._add_derivation(snapshot, reference, edge_ref=edge.id) + return edge + + def add_assertion( + self, + snapshot: RiSnapshot, + *, + subject_kind: str, + subject_key: str, + predicate: str, + value: Mapping[str, object], + producer: str, + producer_version: str, + derived_from: Sequence[Mapping[str, str]] = (), + truth_class: str = "inferred", + set_array_keys: frozenset[str] = frozenset(), + ordered_array_keys: frozenset[str] = frozenset(), + ) -> RiAssertion: + self._require_building(snapshot) + subject_kind = canonical.nfc(subject_kind) + subject_key = canonical.normalize_stable_key(subject_kind, subject_key) + predicate = canonical.validate_predicate(predicate) + normalized_value = canonical.normalize_declared_arrays( + dict(value), + set_array_keys=set_array_keys, + ordered_array_keys=ordered_array_keys, + context="assertion value", + ) + producer = canonical.nfc(producer) + producer_version = canonical.nfc(producer_version) + assertion_id = canonical.compute_assertion_id( + subject_kind=subject_kind, + subject_key=subject_key, + predicate=predicate, + value=normalized_value, + truth_class=truth_class, + producer=producer, + producer_version=producer_version, + derived_from=list(derived_from), + schema_version=snapshot.schema_version, + ) + existing = self.db.scalars( + select(RiAssertion).where( + RiAssertion.snapshot_id == snapshot.snapshot_id, + RiAssertion.assertion_id == assertion_id, + ) + ).first() + if existing is not None: + return existing + assertion = RiAssertion( + snapshot_id=snapshot.snapshot_id, + assertion_id=assertion_id, + subject_kind=subject_kind, + subject_key=subject_key, + predicate=predicate, + value=normalized_value, + truth_class=truth_class, + producer=producer, + producer_version=producer_version, + ) + self.db.add(assertion) + self.db.flush() + for reference in derived_from: + self._add_derivation(snapshot, reference, assertion_ref=assertion.id) + return assertion + + def add_diagnostic( + self, + snapshot: RiSnapshot, + *, + code: str, + category: str, + severity: str, + message: str, + producer: str, + path: str | None = None, + span: tuple[int, int] | None = None, + subject: str | None = None, + object: str | None = None, # noqa: A002 - mirrors RFC field name + details: Mapping[str, object] | None = None, + set_array_keys: frozenset[str] = frozenset(), + ordered_array_keys: frozenset[str] = frozenset(), + ) -> RiDiagnostic: + self._require_building(snapshot) + normalized_path = canonical.normalize_repo_path(path) if path is not None else None + if span is not None and not (1 <= span[0] <= span[1]): + raise SnapshotSealError("diagnostic spans must be one-based and inclusive") + normalized_details = ( + canonical.normalize_declared_arrays( + dict(details), + set_array_keys=_DIAGNOSTIC_SET_ARRAY_KEYS | set_array_keys, + ordered_array_keys=ordered_array_keys, + context="diagnostic details", + ) + if details + else None + ) + diagnostic = RiDiagnostic( + snapshot_id=snapshot.snapshot_id, + code=canonical.nfc(code), + category=canonical.nfc(category), + severity=canonical.nfc(severity), + message=canonical.nfc(message), + producer=canonical.nfc(producer), + path=normalized_path, + span_start_line=span[0] if span else None, + span_end_line=span[1] if span else None, + subject_key=canonical.nfc(subject) if subject is not None else None, + object_key=canonical.nfc(object) if object is not None else None, + details=normalized_details, + ) + self.db.add(diagnostic) + self.db.flush() + return diagnostic + + # -- sealing ------------------------------------------------------------- + + def seal( + self, + snapshot: RiSnapshot, + *, + check_cancelled: Callable[[], None] | None = None, + ) -> RiSnapshot: + """Validate, hash, and complete a snapshot in one transaction (RFC §11.2).""" + + self._require_building(snapshot) + # Load under no_autoflush so a post-insert mutation that violates a stored + # invariant is caught by _validate (and turned into a clean ``failed`` + # transition) instead of tripping a database constraint mid-flush. + with self.db.no_autoflush: + facts = self._load_facts(snapshot, check_cancelled=check_cancelled) + try: + self._check_cancelled(check_cancelled) + self._validate(snapshot, facts, check_cancelled=check_cancelled) + graph_hash = self._compute_hash(snapshot, facts, check_cancelled=check_cancelled) + # Reproducibility check (RFC §11.2 rule 10): recomputation is stable. + if graph_hash != self._compute_hash(snapshot, facts, check_cancelled=check_cancelled): + raise SnapshotSealError("canonical graph hash is not reproducible") + except SnapshotSealError: + self._fail_seal(snapshot) + raise + except (canonical.CanonicalizationError, canonical.PathEscapeError) as exc: + # A canonicalization or path failure at seal time means the stored + # graph cannot be reduced to a valid canonical form. The build must + # transition to ``failed`` rather than remain stuck in ``building``. + self._fail_seal(snapshot) + raise SnapshotSealError(f"snapshot could not be canonicalized during seal: {exc}") from exc + + snapshot.canonical_graph_hash = graph_hash + snapshot.actual_producers = self._observed_producers(facts) + snapshot.state = "completed" + snapshot.sealed_at = datetime.now(UTC) + identity = { + "repository_id": snapshot.repository_id, + "revision_value": snapshot.revision_value, + "schema_version": snapshot.schema_version, + "producer_version_set": list(snapshot.producer_version_set), + "config_hash": snapshot.config_hash, + } + try: + self._commit_transition(snapshot, from_state="building", to_state="completed") + except IntegrityError as exc: + self.db.rollback() + existing = self.find_completed(**identity) + raise SnapshotAlreadySealedError( + "A completed snapshot already exists for this semantic identity.", + existing=existing, + ) from exc + return snapshot + + # -- internals ----------------------------------------------------------- + + def _fail_seal(self, snapshot: RiSnapshot) -> None: + """Transition a rejected build to ``failed`` and commit it (RFC §11.2).""" + + self._discard_tampered_facts() + snapshot.state = "failed" + snapshot.failure_code = snapshot.failure_code or "RI-INT-VALIDATION" + self._commit_transition(snapshot, from_state="building", to_state="failed") + + def _discard_tampered_facts(self) -> None: + """Revert in-memory edits to stored facts so the fail commit can proceed. + + A post-insert mutation that :meth:`_validate` rejected (an over-long span, + an unnormalized path) is still pending in the session; expiring the dirty + fact rows restores their persisted values so the ``failed`` transition is + not itself blocked by the invalid edit or its database constraint. + """ + + for obj in list(self.db.dirty): + if isinstance(obj, _CHILD_TYPES): + self.db.expire(obj) + + def _require_building(self, snapshot: RiSnapshot) -> None: + if snapshot.state != "building": + raise SnapshotStateError(f"snapshot {snapshot.snapshot_id} is not building (state={snapshot.state})") + + def _commit_transition(self, snapshot: RiSnapshot, *, from_state: str, to_state: str) -> None: + transition = (snapshot.snapshot_id, from_state, to_state) + allowed = self.db.info.setdefault(_ALLOWED_TRANSITIONS_KEY, set()) + allowed.add(transition) + try: + self.db.commit() + finally: + allowed.discard(transition) + if not allowed: + self.db.info.pop(_ALLOWED_TRANSITIONS_KEY, None) + + def _require_matching_repository(self, repository_id: str, revision: Revision) -> RepositoryRecord: + """Validate ``revision`` and confirm it is this repository's revision. + + The moving ``revision.ref`` is never part of semantic identity, so only + ``(kind, value)`` is compared against the repository's immutable revision + columns (RFC §3.3). + """ + + self._validate_revision(revision) + repository = self.db.get(RepositoryRecord, repository_id) + if repository is None: + raise SnapshotSealError("snapshot repository does not exist") + if (repository.revision_kind, repository.revision_value) != (revision.kind, revision.value): + raise SnapshotSealError("snapshot revision does not match its repository revision") + return repository + + @staticmethod + def _validate_revision(revision: Revision) -> None: + if revision.kind == "git": + if not _GIT_REVISION_RE.fullmatch(revision.value): + raise SnapshotSealError("git revisions require a 40-character lowercase commit SHA") + if revision.ref is not None and not revision.ref.startswith("refs/"): + raise SnapshotSealError("git revision refs must be normalized under refs/") + return + if revision.kind == "upload": + if not _UPLOAD_REVISION_RE.fullmatch(revision.value) or revision.ref is not None: + raise SnapshotSealError("upload revisions require sha256 content identity and a null ref") + return + raise SnapshotSealError("revision kind must be 'git' or 'upload'") + + @staticmethod + def _resolve_config_hash( + *, + config: Mapping[str, object] | None, + config_hash: str | None, + set_array_keys: frozenset[str], + path_keys: frozenset[str], + ) -> str: + computed = canonical.compute_config_hash( + config, + set_array_keys=set_array_keys, + path_keys=path_keys, + ) + if config_hash is not None and config is not None and config_hash != computed: + raise SnapshotSealError("provided config_hash does not match canonical configuration") + return config_hash or computed + + def _add_evidence( + self, + snapshot: RiSnapshot, + record: Evidence, + *, + node_ref: int | None = None, + edge_ref: int | None = None, + observation_ref: int | None = None, + ) -> RiEvidence: + # Reject absolute paths / traversal (RFC §4.2, §13) and invalid spans + # (RFC §6.2) at write time. Normalization raises PathEscapeError. + normalized_path = canonical.normalize_repo_path(record.path) + extractor = canonical.nfc(record.extractor) + extractor_version = canonical.nfc(record.extractor_version) + if not normalized_path: + raise SnapshotSealError("evidence path must identify a repository-relative file") + if record.granularity not in {"span", "file"}: + raise SnapshotSealError("evidence granularity must be 'span' or 'file'") + if not extractor or not extractor_version: + raise SnapshotSealError("evidence extractor and extractor_version are required") + if not (1 <= record.start_line <= record.end_line <= record.logical_line_count): + raise SnapshotSealError( + f"invalid evidence span {record.start_line}..{record.end_line} " + f"for {normalized_path!r} with {record.logical_line_count} logical lines" + ) + parent_column = ( + RiEvidence.node_ref + if node_ref is not None + else RiEvidence.edge_ref + if edge_ref is not None + else RiEvidence.observation_ref + ) + parent_value = node_ref if node_ref is not None else edge_ref if edge_ref is not None else observation_ref + existing = self.db.scalars( + select(RiEvidence).where( + RiEvidence.snapshot_id == snapshot.snapshot_id, + parent_column == parent_value, + RiEvidence.path == normalized_path, + RiEvidence.start_line == record.start_line, + RiEvidence.end_line == record.end_line, + RiEvidence.granularity == record.granularity, + RiEvidence.extractor == extractor, + RiEvidence.extractor_version == extractor_version, + ) + ).first() + if existing is not None: + # The same evidence identity must carry the same logical-line count; + # a producer that reports a different file length for an identical + # span is internally inconsistent and must not be silently merged. + if existing.logical_line_count != record.logical_line_count: + raise SnapshotSealError( + f"conflicting logical_line_count for duplicate evidence on {normalized_path!r}: " + f"{existing.logical_line_count} != {record.logical_line_count}" + ) + return existing + evidence = RiEvidence( + snapshot_id=snapshot.snapshot_id, + node_ref=node_ref, + edge_ref=edge_ref, + observation_ref=observation_ref, + path=normalized_path, + start_line=record.start_line, + end_line=record.end_line, + logical_line_count=record.logical_line_count, + granularity=record.granularity, + extractor=extractor, + extractor_version=extractor_version, + ) + self.db.add(evidence) + self.db.flush() + return evidence + + def _add_derivation( + self, + snapshot: RiSnapshot, + reference: Mapping[str, str], + *, + edge_ref: int | None = None, + assertion_ref: int | None = None, + ) -> RiDerivation: + kind = reference.get("kind") + if kind not in canonical._REFERENCE_IDENTITY_FIELD: + raise SnapshotSealError("derived_from contains an unsupported reference kind") + identity_field = canonical._REFERENCE_IDENTITY_FIELD[kind] + expected_keys = {"kind", identity_field} + if set(reference) != expected_keys or not reference.get(identity_field): + raise SnapshotSealError("derived_from reference does not match the RFC tagged shape") + identity = canonical.nfc(reference[identity_field]) + parent_column = RiDerivation.edge_ref if edge_ref is not None else RiDerivation.assertion_ref + parent_value = edge_ref if edge_ref is not None else assertion_ref + existing = self.db.scalars( + select(RiDerivation).where( + RiDerivation.snapshot_id == snapshot.snapshot_id, + parent_column == parent_value, + RiDerivation.ref_kind == kind, + RiDerivation.ref_identity == identity, + ) + ).first() + if existing is not None: + return existing + derivation = RiDerivation( + snapshot_id=snapshot.snapshot_id, + edge_ref=edge_ref, + assertion_ref=assertion_ref, + ref_kind=kind, + ref_identity=identity, + ) + self.db.add(derivation) + self.db.flush() + return derivation + + def _load_facts( + self, + snapshot: RiSnapshot, + *, + check_cancelled: Callable[[], None] | None = None, + ) -> "_Facts": + snapshot_id = snapshot.snapshot_id + nodes: list[RiNode] = [] + for item in self.db.scalars(select(RiNode).where(RiNode.snapshot_id == snapshot_id)).yield_per(500): + self._check_cancelled(check_cancelled) + nodes.append(item) + edges: list[RiEdge] = [] + for item in self.db.scalars(select(RiEdge).where(RiEdge.snapshot_id == snapshot_id)).yield_per(500): + self._check_cancelled(check_cancelled) + edges.append(item) + assertions: list[RiAssertion] = [] + for item in self.db.scalars(select(RiAssertion).where(RiAssertion.snapshot_id == snapshot_id)).yield_per(500): + self._check_cancelled(check_cancelled) + assertions.append(item) + observations: list[RiObservation] = [] + for item in self.db.scalars(select(RiObservation).where(RiObservation.snapshot_id == snapshot_id)).yield_per( + 500 + ): + self._check_cancelled(check_cancelled) + observations.append(item) + evidence: list[RiEvidence] = [] + for item in self.db.scalars(select(RiEvidence).where(RiEvidence.snapshot_id == snapshot_id)).yield_per(500): + self._check_cancelled(check_cancelled) + evidence.append(item) + derivations: list[RiDerivation] = [] + for item in self.db.scalars(select(RiDerivation).where(RiDerivation.snapshot_id == snapshot_id)).yield_per(500): + self._check_cancelled(check_cancelled) + derivations.append(item) + diagnostics: list[RiDiagnostic] = [] + for item in self.db.scalars(select(RiDiagnostic).where(RiDiagnostic.snapshot_id == snapshot_id)).yield_per(500): + self._check_cancelled(check_cancelled) + diagnostics.append(item) + return _Facts(nodes, edges, assertions, observations, evidence, derivations, diagnostics) + + def _validate( + self, + snapshot: RiSnapshot, + facts: "_Facts", + *, + check_cancelled: Callable[[], None] | None = None, + ) -> None: + producer_set = set(snapshot.producer_version_set) + normalized_producers = canonical.normalize_producer_version_set(snapshot.producer_version_set) + if snapshot.producer_version_set != normalized_producers: + raise SnapshotSealError("producer_version_set is not normalized") + if snapshot.producer_set_hash != canonical.producer_set_hash(normalized_producers): + raise SnapshotSealError("producer_set_hash does not match producer_version_set") + if not _HASH_RE.fullmatch(snapshot.config_hash): + raise SnapshotSealError("config_hash is not a canonical sha256 identity") + self._validate_revision(Revision(snapshot.revision_kind, snapshot.revision_value, snapshot.revision_ref)) + + # Rule 3 / §8.4: a fatal diagnostic fails the snapshot. + if any(diagnostic.severity == _FATAL_SEVERITY for diagnostic in facts.diagnostics): + snapshot.failure_code = "RI-FATAL-DIAGNOSTIC" + raise SnapshotSealError("snapshot has a fatal diagnostic and cannot seal") + + # Rule 5: exactly one repo:root node. + repo_nodes = [node for node in facts.nodes if node.node_kind == "repository"] + if len(repo_nodes) != 1 or repo_nodes[0].stable_key != "repo:root": + raise SnapshotSealError("a completed snapshot must contain exactly one repo:root node") + + node_keys = {node.stable_key for node in facts.nodes} + node_kind_by_key: dict[str, str] = {} + for node in facts.nodes: + self._check_cancelled(check_cancelled) + try: + normalized_key = canonical.normalize_stable_key(node.node_kind, node.stable_key) + except (canonical.CanonicalizationError, canonical.PathEscapeError) as exc: + raise SnapshotSealError(f"invalid stable key {node.stable_key!r}") from exc + if normalized_key != node.stable_key: + raise SnapshotSealError(f"stable key {node.stable_key!r} is not normalized") + normalized_properties = canonical.normalize_content(node.properties or {}) + if node.properties is not None and normalized_properties != node.properties: + raise SnapshotSealError("node properties are not canonically normalized") + node_kind_by_key[node.stable_key] = node.node_kind + evidence_by_node = defaultdict(list) + evidence_by_edge = defaultdict(list) + evidence_by_observation = defaultdict(list) + for record in facts.evidence: + self._check_cancelled(check_cancelled) + if record.node_ref is not None: + evidence_by_node[record.node_ref].append(record) + elif record.edge_ref is not None: + evidence_by_edge[record.edge_ref].append(record) + elif record.observation_ref is not None: + evidence_by_observation[record.observation_ref].append(record) + # Re-check every stored evidence record against the contract, so a + # post-insert mutation cannot smuggle an invalid span, an unnormalized + # or escaping path, or an undeclared producer into a sealed snapshot + # (RFC §6, §11.2, §13). + self._validate_stored_evidence(record, producer_set) + + # Rule 1: every observed node has >=1 valid evidence record. + for node in facts.nodes: + self._check_cancelled(check_cancelled) + if node.truth_class == "observed" and not evidence_by_node.get(node.id): + raise SnapshotSealError(f"observed node {node.stable_key!r} has no evidence") + + # Every observation has exactly one evidence record (RFC §6.4). + for observation in facts.observations: + self._check_cancelled(check_cancelled) + if len(evidence_by_observation.get(observation.id, [])) != 1: + raise SnapshotSealError( + f"observation {observation.observation_id} must have exactly one evidence record" + ) + if node_kind_by_key.get(observation.subject_key) != observation.subject_kind: + raise SnapshotSealError(f"observation {observation.observation_id} has an invalid subject") + evidence = evidence_by_observation[observation.id][0] + recomputed = canonical.compute_observation_id( + revision_kind=snapshot.revision_kind, + revision_value=snapshot.revision_value, + observed_kind=observation.observed_kind, + subject_kind=observation.subject_kind, + subject_key=observation.subject_key, + referent_text=observation.referent_text, + ordinal=observation.ordinal, + evidence=self._evidence_dict(evidence), + schema_version=snapshot.schema_version, + ) + if recomputed != observation.observation_id: + raise SnapshotSealError(f"observation {observation.observation_id} identity does not recompute") + + derivations_by_edge = defaultdict(list) + derivations_by_assertion = defaultdict(list) + for derivation in facts.derivations: + self._check_cancelled(check_cancelled) + if derivation.edge_ref is not None: + derivations_by_edge[derivation.edge_ref].append(derivation) + elif derivation.assertion_ref is not None: + derivations_by_assertion[derivation.assertion_ref].append(derivation) + + edge_ids = {edge.edge_id for edge in facts.edges} + assertion_ids = {assertion.assertion_id for assertion in facts.assertions} + observation_ids = {observation.observation_id for observation in facts.observations} + + # Rule 2: edge endpoints resolve to nodes in this snapshot (also a DB FK). + for edge in facts.edges: + self._check_cancelled(check_cancelled) + if ( + node_kind_by_key.get(edge.subject_key) != edge.subject_kind + or node_kind_by_key.get(edge.object_key) != edge.object_kind + ): + raise SnapshotSealError(f"edge {edge.edge_id} references a node outside the snapshot") + if canonical.compute_edge_id(edge.subject_key, edge.predicate, edge.object_key) != edge.edge_id: + raise SnapshotSealError(f"edge {edge.edge_id} identity does not recompute") + if not evidence_by_edge.get(edge.id): + raise SnapshotSealError(f"resolved edge {edge.edge_id} has no evidence") + edge_derivations = derivations_by_edge.get(edge.id, []) + if not edge_derivations or not any(item.ref_kind == "observation" for item in edge_derivations): + raise SnapshotSealError(f"resolved edge {edge.edge_id} must derive from a source observation") + self._require_producer(edge.producer, edge.producer_version, producer_set) + + # Assertion subjects resolve; assertion identity recomputes (rule 8). + for assertion in facts.assertions: + self._check_cancelled(check_cancelled) + if node_kind_by_key.get(assertion.subject_key) != assertion.subject_kind: + raise SnapshotSealError(f"assertion {assertion.assertion_id} references a node outside the snapshot") + references = self._derivation_refs(derivations_by_assertion.get(assertion.id, [])) + if not references: + raise SnapshotSealError(f"inferred assertion {assertion.assertion_id} has no derivation") + recomputed = canonical.compute_assertion_id( + subject_kind=assertion.subject_kind, + subject_key=assertion.subject_key, + predicate=assertion.predicate, + value=assertion.value, + truth_class=assertion.truth_class, + producer=assertion.producer, + producer_version=assertion.producer_version, + derived_from=references, + schema_version=snapshot.schema_version, + ) + if recomputed != assertion.assertion_id: + raise SnapshotSealError(f"assertion {assertion.assertion_id} identity does not recompute") + normalized_value = canonical.normalize_content(assertion.value) + if normalized_value != assertion.value: + raise SnapshotSealError("assertion value is not canonically normalized") + self._require_producer(assertion.producer, assertion.producer_version, producer_set) + + # Rule 7: every derivation reference resolves in the snapshot, and the + # derivation graph is acyclic. + self._validate_derivation_graph( + facts, + node_keys=node_keys, + edge_ids=edge_ids, + assertion_ids=assertion_ids, + observation_ids=observation_ids, + derivations_by_edge=derivations_by_edge, + derivations_by_assertion=derivations_by_assertion, + ) + + # Diagnostic producers must be declared too (RFC §11.2 rule 6). + for diagnostic in facts.diagnostics: + self._check_cancelled(check_cancelled) + if diagnostic.producer not in producer_set: + raise SnapshotSealError(f"diagnostic producer {diagnostic.producer!r} is not in the producer set") + if (diagnostic.span_start_line is None) != (diagnostic.span_end_line is None): + raise SnapshotSealError("diagnostic span endpoints must either both be present or both be null") + normalized_details = canonical.normalize_content(diagnostic.details or {}) + if diagnostic.details is not None and normalized_details != diagnostic.details: + raise SnapshotSealError("diagnostic details are not canonically normalized") + + def _validate_stored_evidence(self, record: RiEvidence, producer_set: set[str]) -> None: + """Revalidate one persisted evidence record before sealing (RFC §6, §11.2). + + Every check that :meth:`_add_evidence` runs at write time is re-asserted + against the stored row, so a direct mutation after insertion cannot leave + an invalid record in a completed snapshot. Path canonicalization failures + surface as :class:`SnapshotSealError` so the build fails rather than + remaining stuck in ``building``. + """ + + if not record.extractor or not record.extractor_version: + raise SnapshotSealError("stored evidence extractor and extractor_version must be non-empty") + if record.granularity not in {"span", "file"}: + raise SnapshotSealError(f"stored evidence has invalid granularity {record.granularity!r}") + try: + normalized_path = canonical.normalize_repo_path(record.path) + except canonical.PathEscapeError as exc: + raise SnapshotSealError( + f"stored evidence path {record.path!r} is absolute or escapes the repository root" + ) from exc + if not normalized_path: + raise SnapshotSealError("stored evidence path must identify a repository-relative file") + if normalized_path != record.path: + raise SnapshotSealError(f"stored evidence path {record.path!r} is not in normalized form") + if record.logical_line_count is None or record.logical_line_count < 1: + raise SnapshotSealError("stored evidence logical_line_count must be >= 1") + if not (1 <= record.start_line <= record.end_line <= record.logical_line_count): + raise SnapshotSealError( + f"stored evidence span {record.start_line}..{record.end_line} is not bounded by " + f"{record.logical_line_count} logical lines for {record.path!r}" + ) + # Provenance extractor must be a declared producer (RFC §11.2 rule 6). + self._require_producer(record.extractor, record.extractor_version, producer_set) + + def _require_producer(self, producer: str, version: str, producer_set: set[str]) -> None: + identifier = f"{producer}@{version}" + if identifier not in producer_set: + raise SnapshotSealError(f"producer {identifier!r} is not declared in producer_version_set") + + def _derivation_refs(self, derivations: Sequence[RiDerivation]) -> list[dict[str, str]]: + references: list[dict[str, str]] = [] + for derivation in derivations: + field_name = canonical._REFERENCE_IDENTITY_FIELD[derivation.ref_kind] + references.append({"kind": derivation.ref_kind, field_name: derivation.ref_identity}) + return references + + def _validate_derivation_graph( + self, + facts: "_Facts", + *, + node_keys: set[str], + edge_ids: set[str], + assertion_ids: set[str], + observation_ids: set[str], + derivations_by_edge: Mapping[int, Sequence[RiDerivation]], + derivations_by_assertion: Mapping[int, Sequence[RiDerivation]], + ) -> None: + # Build an adjacency map keyed by fact identity for derived facts only. + outgoing: dict[str, list[str]] = {} + edge_id_by_row = {edge.id: edge.edge_id for edge in facts.edges} + assertion_id_by_row = {assertion.id: assertion.assertion_id for assertion in facts.assertions} + + def resolve(reference: RiDerivation) -> str: + kind, identity = reference.ref_kind, reference.ref_identity + table = { + "observation": observation_ids, + "node": node_keys, + "edge": edge_ids, + "assertion": assertion_ids, + }[kind] + if identity not in table: + raise SnapshotSealError(f"derived_from reference {kind}:{identity} does not resolve in the snapshot") + return f"{kind}:{identity}" + + for row_id, derivations in derivations_by_edge.items(): + key = f"edge:{edge_id_by_row[row_id]}" + outgoing[key] = [resolve(derivation) for derivation in derivations] + for row_id, derivations in derivations_by_assertion.items(): + key = f"assertion:{assertion_id_by_row[row_id]}" + outgoing[key] = [resolve(derivation) for derivation in derivations] + + # Cycle detection over the derivation graph. + WHITE, GREY, BLACK = 0, 1, 2 + color: dict[str, int] = defaultdict(int) + + def visit(node: str) -> None: + color[node] = GREY + for target in outgoing.get(node, ()): + if color[target] == GREY: + raise SnapshotSealError("derivation graph contains a cycle") + if color[target] == WHITE and target in outgoing: + visit(target) + color[node] = BLACK + + for node in list(outgoing): + if color[node] == WHITE: + visit(node) + + def _observed_producers(self, facts: "_Facts") -> list[str]: + producers: set[str] = set() + for record in facts.evidence: + producers.add(f"{record.extractor}@{record.extractor_version}") + for edge in facts.edges: + producers.add(f"{edge.producer}@{edge.producer_version}") + for assertion in facts.assertions: + producers.add(f"{assertion.producer}@{assertion.producer_version}") + for diagnostic in facts.diagnostics: + producers.add(diagnostic.producer) + return sorted(producers) + + def _compute_hash( + self, + snapshot: RiSnapshot, + facts: "_Facts", + *, + check_cancelled: Callable[[], None] | None = None, + ) -> str: + evidence_by_node = defaultdict(list) + evidence_by_edge = defaultdict(list) + evidence_by_observation: dict[int, RiEvidence] = {} + for record in facts.evidence: + self._check_cancelled(check_cancelled) + if record.node_ref is not None: + evidence_by_node[record.node_ref].append(self._evidence_dict(record)) + elif record.edge_ref is not None: + evidence_by_edge[record.edge_ref].append(self._evidence_dict(record)) + elif record.observation_ref is not None: + evidence_by_observation[record.observation_ref] = self._evidence_dict(record) + + derivations_by_edge = defaultdict(list) + derivations_by_assertion = defaultdict(list) + for derivation in facts.derivations: + self._check_cancelled(check_cancelled) + reference = { + "kind": derivation.ref_kind, + canonical._REFERENCE_IDENTITY_FIELD[derivation.ref_kind]: derivation.ref_identity, + } + if derivation.edge_ref is not None: + derivations_by_edge[derivation.edge_ref].append(reference) + elif derivation.assertion_ref is not None: + derivations_by_assertion[derivation.assertion_ref].append(reference) + + nodes: list[dict[str, object]] = [] + for node in facts.nodes: + self._check_cancelled(check_cancelled) + nodes.append( + { + "node_kind": node.node_kind, + "stable_key": node.stable_key, + "truth_class": node.truth_class, + "name": node.name, + "language": node.language, + "properties": node.properties, + "evidence": evidence_by_node.get(node.id, []), + } + ) + edges: list[dict[str, object]] = [] + for edge in facts.edges: + self._check_cancelled(check_cancelled) + edges.append( + { + "edge_id": edge.edge_id, + "subject_kind": edge.subject_kind, + "subject_key": edge.subject_key, + "predicate": edge.predicate, + "object_kind": edge.object_kind, + "object_key": edge.object_key, + "truth_class": edge.truth_class, + "producer": edge.producer, + "producer_version": edge.producer_version, + "evidence": evidence_by_edge.get(edge.id, []), + "derived_from": derivations_by_edge.get(edge.id, []), + } + ) + assertions: list[dict[str, object]] = [] + for assertion in facts.assertions: + self._check_cancelled(check_cancelled) + assertions.append( + { + "assertion_id": assertion.assertion_id, + "subject_kind": assertion.subject_kind, + "subject_key": assertion.subject_key, + "predicate": assertion.predicate, + "value": assertion.value, + "truth_class": assertion.truth_class, + "producer": assertion.producer, + "producer_version": assertion.producer_version, + "derived_from": derivations_by_assertion.get(assertion.id, []), + } + ) + observations: list[dict[str, object]] = [] + for observation in facts.observations: + self._check_cancelled(check_cancelled) + observations.append( + { + "observation_id": observation.observation_id, + "observed_kind": observation.observed_kind, + "subject_kind": observation.subject_kind, + "subject_key": observation.subject_key, + "referent_text": observation.referent_text, + "ordinal": observation.ordinal, + "evidence": evidence_by_observation[observation.id], + } + ) + diagnostics: list[dict[str, object]] = [] + for diagnostic in facts.diagnostics: + self._check_cancelled(check_cancelled) + diagnostics.append( + { + "code": diagnostic.code, + "category": diagnostic.category, + "severity": diagnostic.severity, + "message": diagnostic.message, + "producer": diagnostic.producer, + "path": diagnostic.path, + "span": {"start_line": diagnostic.span_start_line, "end_line": diagnostic.span_end_line} + if diagnostic.span_start_line is not None + else None, + "subject": diagnostic.subject_key, + "object": diagnostic.object_key, + "details": diagnostic.details, + } + ) + return canonical.compute_canonical_graph_hash( + revision_kind=snapshot.revision_kind, + revision_value=snapshot.revision_value, + producer_version_set=snapshot.producer_version_set, + config_hash=snapshot.config_hash, + nodes=nodes, + edges=edges, + assertions=assertions, + observations=observations, + diagnostics=diagnostics, + schema_version=snapshot.schema_version, + ) + + @staticmethod + def _check_cancelled(check_cancelled: Callable[[], None] | None) -> None: + if check_cancelled is not None: + check_cancelled() + + @staticmethod + def _evidence_dict(record: RiEvidence) -> dict[str, object]: + return { + "path": record.path, + "start_line": record.start_line, + "end_line": record.end_line, + "granularity": record.granularity, + "extractor": record.extractor, + "extractor_version": record.extractor_version, + } + + +@dataclass +class _Facts: + nodes: list[RiNode] + edges: list[RiEdge] + assertions: list[RiAssertion] + observations: list[RiObservation] + evidence: list[RiEvidence] + derivations: list[RiDerivation] + diagnostics: list[RiDiagnostic] diff --git a/apps/backend/app/main.py b/apps/backend/app/main.py index a7856d0f..9efeebc6 100644 --- a/apps/backend/app/main.py +++ b/apps/backend/app/main.py @@ -1,26 +1,76 @@ from collections.abc import AsyncIterator from contextlib import asynccontextmanager from os import getpid +from pathlib import Path import logging from time import perf_counter -from typing import Literal +from typing import TYPE_CHECKING, Any, Literal -from fastapi import FastAPI, Request, status +from fastapi import Depends, FastAPI, Request, status from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import JSONResponse, PlainTextResponse +from fastapi.responses import FileResponse, JSONResponse, PlainTextResponse +from fastapi.staticfiles import StaticFiles from sqlalchemy import text +from sqlalchemy import inspect as sa_inspect + from app.api.router import api_router +from app.api.deps import get_current_user +from app.api.openapi import ( + documented_responses, + remove_suppressed_automatic_validation_errors, + response_example, +) from app.core import database from app.core.config import get_settings from app.core.exceptions import ErrorResponse, register_exception_handlers from app.core.logging import configure_logging from app.core.observability import new_request_id, reset_request_id, runtime_metrics, set_request_id -from app.models import RepositoryRecord # noqa: F401 - imported so metadata includes model +from app.core.rate_limit import RateLimitMiddleware, build_rate_limit_store +from app.core.schema_sync import ensure_schema_in_sync, stamp_head +from app.core.security_headers import SecurityHeadersMiddleware from app.models.base import Base +if TYPE_CHECKING: + from app.workers.runner import AnalysisWorkerRunner + logger = logging.getLogger(__name__) +_READINESS_SCHEMA = { + "type": "object", + "required": ["status", "environment", "checks"], + "properties": { + "status": {"type": "string", "enum": ["ready", "not_ready"]}, + "environment": {"type": "string"}, + "checks": { + "type": "object", + "additionalProperties": {"type": "string", "enum": ["ok", "error"]}, + }, + }, +} +_READINESS_EXAMPLE = { + "status": "ready", + "environment": "development", + "checks": {"database": "ok", "storage": "ok"}, +} +_NOT_READY_EXAMPLE = { + "status": "not_ready", + "environment": "development", + "checks": {"database": "error", "storage": "ok"}, +} +_READINESS_RESPONSES = documented_responses( + status.HTTP_200_OK, + "The API can reach its database and write to repository storage.", + _READINESS_EXAMPLE, + status.HTTP_500_INTERNAL_SERVER_ERROR, + schema=_READINESS_SCHEMA, +) +_READINESS_RESPONSES[status.HTTP_503_SERVICE_UNAVAILABLE] = response_example( + "The API is running but one or more readiness checks failed.", + _NOT_READY_EXAMPLE, + schema=_READINESS_SCHEMA, +) + def check_database_ready() -> bool: try: @@ -43,13 +93,60 @@ def check_storage_ready() -> bool: return True +def _start_analysis_worker() -> "AnalysisWorkerRunner | None": + """Start the in-process analysis worker, unless it is switched off (#324). + + The API process hosts a worker for compatibility; it does not *own* the + queue. Worker identity, poll cadence, stale-sweep cadence and shutdown all + live in ``app.workers.runner`` behind the control-plane boundary, so a + standalone worker process would reuse the identical loop rather than + reimplementing this function. + + ``analysis_worker_autostart`` gates the runner so tests drive + ``AnalysisWorker.run_once`` deterministically instead of racing a thread. + """ + + settings = get_settings() + if not settings.analysis_worker_autostart: + return None + + from app.workers.runner import build_analysis_worker_runner + + runner = build_analysis_worker_runner(settings) + runner.start() + return runner + + @asynccontextmanager -async def lifespan(_: FastAPI) -> AsyncIterator[None]: +async def lifespan(app: FastAPI) -> AsyncIterator[None]: settings = get_settings() settings.storage_path.mkdir(parents=True, exist_ok=True) if settings.auto_create_tables: - Base.metadata.create_all(bind=database.engine) - yield + # A database with no tables at all is genuinely fresh: create_all + # builds every table directly from the current models (== head's + # shape by definition), so stamping head afterward is safe and + # bypasses the drift check entirely. An existing database (some + # tables already present) may be stamped behind head from a schema- + # changing merge -- route that through the same drift check as the + # AUTO_CREATE_TABLES=false path below instead of blindly re-stamping. + is_fresh = not sa_inspect(database.engine).get_table_names() + if is_fresh: + Base.metadata.create_all(bind=database.engine) + stamp_head(database.engine) + else: + ensure_schema_in_sync(database.engine, app_env=settings.app_env) + Base.metadata.create_all(bind=database.engine) + else: + ensure_schema_in_sync(database.engine, app_env=settings.app_env) + analysis_worker_runner = _start_analysis_worker() + try: + yield + finally: + if analysis_worker_runner is not None: + analysis_worker_runner.stop() + aclose = getattr(app.state.rate_limit_store, "aclose", None) + if aclose is not None: + await aclose() def create_app() -> FastAPI: @@ -63,12 +160,32 @@ def create_app() -> FastAPI: lifespan=lifespan, ) + default_openapi = app.openapi + + def custom_openapi() -> dict[str, Any]: + document = default_openapi() + remove_suppressed_automatic_validation_errors(document) + return document + + app.openapi = custom_openapi + + # Registered first (innermost) so it sits inside both SecurityHeadersMiddleware + # and CORSMiddleware in the wrapped stack: 429 responses still pick up security + # headers and CORS headers as they bubble back out, instead of a browser client + # seeing an opaque CORS failure or a response missing the baseline headers. + app.state.rate_limit_settings = settings + app.state.rate_limit_store = build_rate_limit_store(settings) + app.add_middleware(RateLimitMiddleware) + app.add_middleware(SecurityHeadersMiddleware) app.add_middleware( CORSMiddleware, allow_origins=settings.cors_origins, allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], + # Explicit lists instead of "*": a wildcard is invalid alongside + # allow_credentials=True and would silently drop credentialed responses. + allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], + allow_headers=["Authorization", "Content-Type", "X-Request-ID"], + expose_headers=["X-Request-ID"], ) register_exception_handlers(app) app.include_router(api_router) @@ -114,11 +231,20 @@ async def request_observability_middleware(request: Request, call_next): response.headers["X-Request-ID"] = request_id reset_request_id(token) - @app.get("/health", tags=["system"]) + @app.get( + "/health", + tags=["system"], + responses=documented_responses( + status.HTTP_200_OK, + "The API process is running.", + {"status": "ok", "environment": "development"}, + status.HTTP_500_INTERNAL_SERVER_ERROR, + ), + ) def health() -> dict[str, str]: return {"status": "ok", "environment": settings.app_env} - @app.get("/ready", tags=["system"], response_model=None) + @app.get("/ready", tags=["system"], response_model=None, responses=_READINESS_RESPONSES) def readiness() -> dict[str, object] | JSONResponse: checks: dict[str, Literal["ok", "error"]] = {} checks["database"] = "ok" if check_database_ready() else "error" @@ -134,11 +260,63 @@ def readiness() -> dict[str, object] | JSONResponse: return payload return JSONResponse(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, content=payload) - @app.get("/metrics", tags=["system"], response_class=PlainTextResponse) + @app.get( + "/metrics", + tags=["system"], + dependencies=[Depends(get_current_user)], + response_class=PlainTextResponse, + responses=documented_responses( + status.HTTP_200_OK, + "Prometheus-compatible runtime metrics.", + "partha_http_requests_total 1\\n", + status.HTTP_401_UNAUTHORIZED, + status.HTTP_500_INTERNAL_SERVER_ERROR, + media_type="text/plain", + schema={"type": "string"}, + ), + ) def metrics() -> str: return runtime_metrics.render_prometheus() + _mount_frontend(app, settings.frontend_dist_path) + return app +def _mount_frontend(app: FastAPI, dist_path: Path) -> None: + """Serve the built frontend from this same service (#339). + + Registered last, after every API route, so nothing here can shadow an + API path: FastAPI matches routes in registration order, and a request + for `/auth/login` or `/health` is already resolved by an earlier route + before this catch-all is ever considered. A missing `dist_path` (local + dev, where the frontend runs on its own Vite dev server instead) is not + an error -- there is simply nothing to mount, and every route behaves + exactly as it did before this function existed. + """ + + index_path = dist_path / "index.html" + if not index_path.is_file(): + return + + resolved_dist_path = dist_path.resolve() + + assets_path = dist_path / "assets" + if assets_path.is_dir(): + app.mount("/assets", StaticFiles(directory=assets_path), name="frontend-assets") + + @app.get("/{full_path:path}", include_in_schema=False) + async def serve_frontend(full_path: str) -> FileResponse: + # Resolve before checking containment -- otherwise a full_path like + # "../../etc/passwd" would pass a naive prefix check but still land + # outside dist_path once the OS follows the ".." segments. + candidate = (dist_path / full_path).resolve() + if full_path and candidate.is_relative_to(resolved_dist_path) and candidate.is_file(): + return FileResponse(candidate) + # Anything else -- a client-side route like /dashboard, or a direct + # refresh on one -- gets the SPA shell; react-router takes it from + # there instead of the browser seeing a bare 404. + return FileResponse(index_path) + + app = create_app() diff --git a/apps/backend/app/models/__init__.py b/apps/backend/app/models/__init__.py index bb96463c..f908aa23 100644 --- a/apps/backend/app/models/__init__.py +++ b/apps/backend/app/models/__init__.py @@ -1,3 +1,49 @@ +from app.models.account_deletion_audit import AccountDeletionAuditRecord +from app.models.ai_conversation import AiConversationMessageRecord +from app.models.ai_provider_config import AiProviderConfigRecord +from app.models.analysis_job import AnalysisJob +from app.models.approved_email import ApprovedEmail +from app.models.invite_token import InviteToken +from app.models.oauth_flow_state import OAuthFlowState +from app.models.oauth_identity import OAuthIdentity +from app.models.oauth_pending_link import OAuthPendingLink +from app.models.refresh_token import RefreshToken from app.models.repository import RepositoryRecord +from app.models.repository_lineage import RepositoryLineage +from app.models.snapshot import ( + RiAssertion, + RiDerivation, + RiDiagnostic, + RiEdge, + RiEvidence, + RiNode, + RiObservation, + RiSnapshot, +) +from app.models.user import User +from app.models.waitlist_entry import WaitlistEntry -__all__ = ["RepositoryRecord"] +__all__ = [ + "AccountDeletionAuditRecord", + "AiConversationMessageRecord", + "AiProviderConfigRecord", + "AnalysisJob", + "ApprovedEmail", + "InviteToken", + "OAuthFlowState", + "OAuthIdentity", + "OAuthPendingLink", + "RefreshToken", + "RepositoryRecord", + "RepositoryLineage", + "RiAssertion", + "RiDerivation", + "RiDiagnostic", + "RiEdge", + "RiEvidence", + "RiNode", + "RiObservation", + "RiSnapshot", + "User", + "WaitlistEntry", +] diff --git a/apps/backend/app/models/account_deletion_audit.py b/apps/backend/app/models/account_deletion_audit.py new file mode 100644 index 00000000..77eec442 --- /dev/null +++ b/apps/backend/app/models/account_deletion_audit.py @@ -0,0 +1,33 @@ +from datetime import UTC, datetime + +from sqlalchemy import CheckConstraint, DateTime, String +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import Base + + +class AccountDeletionAuditRecord(Base): + """A minimal, non-PII record that an account deletion happened. + + Deliberately holds no foreign key to ``users``: the whole point of an + audit trail is that it survives the row it describes, and by the time a + row here reaches ``completed`` the referenced user no longer exists. + Nothing beyond the id that was deleted is retained -- no email, no + repository data, no credentials (#290). + """ + + __tablename__ = "account_deletion_audits" + + id: Mapped[str] = mapped_column(String(36), primary_key=True) + deleted_user_id: Mapped[str] = mapped_column(String(36), index=True) + status: Mapped[str] = mapped_column(String(16)) + requested_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC)) + completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + failure_reason: Mapped[str | None] = mapped_column(String(255), nullable=True) + + __table_args__ = ( + CheckConstraint( + "status IN ('in_progress','completed','failed')", + name="ck_account_deletion_audits_status", + ), + ) diff --git a/apps/backend/app/models/ai_conversation.py b/apps/backend/app/models/ai_conversation.py new file mode 100644 index 00000000..5ed0d4c5 --- /dev/null +++ b/apps/backend/app/models/ai_conversation.py @@ -0,0 +1,48 @@ +from datetime import UTC, datetime + +from sqlalchemy import JSON, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import Base + + +class AiConversationMessageRecord(Base): + """One persisted turn of an AI Workspace conversation. + + Every row is scoped to both ``owner_id`` and ``repository_id``: the AI + Workspace keeps one ordered thread per repository per owner so history + survives navigation away and back, and a cross-owner read resolves to the + same 404 as a missing repository (see ``AiConversationRepository``). + ``sequence`` orders turns within a thread explicitly rather than relying + on timestamp precision, since the user and assistant turns of one query + are written back to back in the same commit. + + The ``(owner_id, repository_id, sequence)`` uniqueness constraint is the + concurrency guard: two simultaneous ``MAX(sequence) + 1`` allocations + collide here instead of silently interleaving user/assistant pairs. The + repository FK cascades, so deleting a repository removes its conversation + turns without a foreign-key violation. + """ + + __tablename__ = "ai_conversation_messages" + __table_args__ = ( + UniqueConstraint( + "owner_id", + "repository_id", + "sequence", + name="uq_ai_conversation_owner_repo_sequence", + ), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True) + owner_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id", ondelete="CASCADE"), index=True) + repository_id: Mapped[str] = mapped_column( + String(36), + ForeignKey("repositories.id", ondelete="CASCADE"), + index=True, + ) + sequence: Mapped[int] = mapped_column(Integer) + role: Mapped[str] = mapped_column(String(16)) + content: Mapped[str] = mapped_column(Text) + citations: Mapped[list | None] = mapped_column(JSON, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC)) diff --git a/apps/backend/app/models/ai_provider_config.py b/apps/backend/app/models/ai_provider_config.py new file mode 100644 index 00000000..1da5eed0 --- /dev/null +++ b/apps/backend/app/models/ai_provider_config.py @@ -0,0 +1,36 @@ +from datetime import UTC, datetime + +from sqlalchemy import DateTime, ForeignKey, String, Text +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import Base + + +class AiProviderConfigRecord(Base): + """A single user's active AI provider configuration. + + One row per owner (``owner_id`` is unique): saving a new configuration + replaces the previous one, mirroring the single-active-provider model the + UI presents. The API key is stored only as Fernet ciphertext in + ``encrypted_api_key`` — never in plaintext — and ``api_key_last4`` holds the + last four characters so the UI can confirm which key is saved without the + key ever being decrypted for display. + """ + + __tablename__ = "ai_provider_configs" + + id: Mapped[str] = mapped_column(String(36), primary_key=True) + owner_id: Mapped[str] = mapped_column( + String(36), ForeignKey("users.id", ondelete="CASCADE"), unique=True, index=True + ) + provider: Mapped[str] = mapped_column(String(32)) + encrypted_api_key: Mapped[str | None] = mapped_column(Text, nullable=True) + api_key_last4: Mapped[str | None] = mapped_column(String(4), nullable=True) + model: Mapped[str | None] = mapped_column(String(255), nullable=True) + base_url: Mapped[str | None] = mapped_column(Text, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC)) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(UTC), + onupdate=lambda: datetime.now(UTC), + ) diff --git a/apps/backend/app/models/analysis_job.py b/apps/backend/app/models/analysis_job.py new file mode 100644 index 00000000..1944172e --- /dev/null +++ b/apps/backend/app/models/analysis_job.py @@ -0,0 +1,88 @@ +from datetime import UTC, datetime + +from sqlalchemy import ( + Boolean, + CheckConstraint, + DateTime, + ForeignKeyConstraint, + Index, + Integer, + String, + text, +) +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import Base + + +def _utcnow() -> datetime: + return datetime.now(UTC) + + +class AnalysisJob(Base): + __tablename__ = "analysis_jobs" + + id: Mapped[str] = mapped_column(String(36), primary_key=True) + repository_id: Mapped[str] = mapped_column(String(36), nullable=False) + owner_id: Mapped[str] = mapped_column(String(36), nullable=False) + revision_kind: Mapped[str] = mapped_column(String(16), nullable=False) + revision_value: Mapped[str] = mapped_column(String(80), nullable=False) + config_hash: Mapped[str] = mapped_column(String(80), nullable=False) + status: Mapped[str] = mapped_column(String(16), nullable=False, default="queued") + stage: Mapped[str | None] = mapped_column(String(64), nullable=True) + progress: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + attempt: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + max_attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=3) + cancel_requested: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + worker_id: Mapped[str | None] = mapped_column(String(64), nullable=True) + lease_expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + next_attempt_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + snapshot_id: Mapped[str | None] = mapped_column(String(48), nullable=True) + error_code: Mapped[str | None] = mapped_column(String(64), nullable=True) + error_message: Mapped[str | None] = mapped_column(String(1024), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=_utcnow) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, default=_utcnow, onupdate=_utcnow + ) + started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + + __table_args__ = ( + CheckConstraint( + "status IN ('queued','running','completed','failed','cancelled')", + name="ck_analysis_jobs_status", + ), + CheckConstraint("revision_kind IN ('git','upload')", name="ck_analysis_jobs_revision_kind"), + CheckConstraint("progress >= 0 AND progress <= 100", name="ck_analysis_jobs_progress_range"), + CheckConstraint("attempt >= 0", name="ck_analysis_jobs_attempt_nonneg"), + CheckConstraint("max_attempts >= 1", name="ck_analysis_jobs_max_attempts_positive"), + ForeignKeyConstraint( + ["repository_id", "revision_kind", "revision_value"], + ["repositories.id", "repositories.revision_kind", "repositories.revision_value"], + name="fk_analysis_jobs_repository_revision", + ondelete="CASCADE", + ), + ForeignKeyConstraint(["owner_id"], ["users.id"], name="fk_analysis_jobs_owner", ondelete="CASCADE"), + ForeignKeyConstraint( + ["snapshot_id"], ["ri_snapshots.snapshot_id"], name="fk_analysis_jobs_snapshot", ondelete="SET NULL" + ), + Index("ix_analysis_jobs_repository_id", "repository_id"), + Index("ix_analysis_jobs_owner_id", "owner_id"), + Index("ix_analysis_jobs_status_lease", "status", "lease_expires_at"), + Index( + "uq_analysis_jobs_snapshot_id", + "snapshot_id", + unique=True, + sqlite_where=text("snapshot_id IS NOT NULL"), + postgresql_where=text("snapshot_id IS NOT NULL"), + ), + Index( + "uq_analysis_jobs_effective_identity", + "repository_id", + "revision_value", + "config_hash", + unique=True, + sqlite_where=text("status IN ('queued','running','completed')"), + postgresql_where=text("status IN ('queued','running','completed')"), + ), + ) diff --git a/apps/backend/app/models/approved_email.py b/apps/backend/app/models/approved_email.py new file mode 100644 index 00000000..b5107c46 --- /dev/null +++ b/apps/backend/app/models/approved_email.py @@ -0,0 +1,37 @@ +from datetime import UTC, datetime + +from sqlalchemy import DateTime, ForeignKey, String +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import Base + + +class ApprovedEmail(Base): + """One admin-approved email address, the registration gate for the + invite-only beta (#374, superseding the single-use invite codes of + #341). + + Unlike an invite code, an approved email is not a scarce secret and is + not consumed by use: it stays approved indefinitely, and re-registering + the same email a second time is already rejected by `User.email`'s own + uniqueness constraint regardless of this table's state. `used_at`/ + `used_by_user_id` are purely informational -- mirroring the spirit of + `InviteToken`'s audit trail (who/when) -- not a gate. + """ + + __tablename__ = "approved_emails" + + id: Mapped[str] = mapped_column(String(36), primary_key=True) + email: Mapped[str] = mapped_column(String(320), unique=True, index=True) + # Free-form operator note (e.g. which waitlist entry this was approved + # for) -- never displayed to the registrant. + note: Mapped[str | None] = mapped_column(String(255), nullable=True) + # Free text, not a User FK: there is no admin-role concept in this app, + # and whoever runs scripts/approve_email.py is not necessarily a PARTHA + # account at all. Purely an operator-facing audit label. + added_by: Mapped[str | None] = mapped_column(String(255), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC)) + used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + used_by_user_id: Mapped[str | None] = mapped_column( + String(36), ForeignKey("users.id", ondelete="SET NULL"), nullable=True + ) diff --git a/apps/backend/app/models/invite_token.py b/apps/backend/app/models/invite_token.py new file mode 100644 index 00000000..78542289 --- /dev/null +++ b/apps/backend/app/models/invite_token.py @@ -0,0 +1,34 @@ +from datetime import UTC, datetime + +from sqlalchemy import DateTime, ForeignKey, String +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import Base + + +class InviteToken(Base): + """One issued invite code (#341). Only the sha256 of the raw code is + stored, the same construction as RefreshToken.token_hash -- a database + read alone must never hand out a working invite. + + Redemption is single-use: ``redeemed_at`` is set exactly once, by the + registration that consumes it, and a code with ``redeemed_at`` already + set is rejected. ``redeemed_by_user_id`` is nullable and ``SET NULL`` on + the user's deletion so a redeemed invite row outlives the account it + created, the same audit-survives-deletion pattern as + ``account_deletion_audits``. + """ + + __tablename__ = "invite_tokens" + + id: Mapped[str] = mapped_column(String(36), primary_key=True) + code_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True) + # Free-form operator note (e.g. which waitlist entry this was issued + # for) -- never displayed to the registrant, purely for the owner's own + # bookkeeping when issuing codes by hand. + note: Mapped[str | None] = mapped_column(String(255), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC)) + redeemed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + redeemed_by_user_id: Mapped[str | None] = mapped_column( + String(36), ForeignKey("users.id", ondelete="SET NULL"), nullable=True + ) diff --git a/apps/backend/app/models/oauth_flow_state.py b/apps/backend/app/models/oauth_flow_state.py new file mode 100644 index 00000000..35d8758f --- /dev/null +++ b/apps/backend/app/models/oauth_flow_state.py @@ -0,0 +1,50 @@ +from datetime import UTC, datetime + +from sqlalchemy import CheckConstraint, DateTime, ForeignKey, String, Text +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import Base + + +class OAuthFlowState(Base): + """One in-flight OAuth authorization request (#288). + + ``state_hash`` is the sha256 of the actual ``state`` value sent to the + provider and returned on callback -- the raw value is a bearer secret + (the CSRF protection), so only its hash is stored, the same convention + as refresh tokens and invite codes. Single-use by construction: the + service deletes this row the moment a callback consumes it, success or + failure, so a replayed callback can never reuse a state value. A row + that outlives its ``expires_at`` (a few minutes) without a matching + callback is simply an abandoned flow -- a real callback happens within + seconds of the redirect. + """ + + __tablename__ = "oauth_flow_states" + + id: Mapped[str] = mapped_column(String(36), primary_key=True) + state_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True) + provider: Mapped[str] = mapped_column(String(32)) + # PKCE code verifier (Google only -- GitHub OAuth Apps do not support + # PKCE, only the state CSRF check applies there). + code_verifier: Mapped[str | None] = mapped_column(String(128), nullable=True) + # OIDC nonce (Google only), echoed back inside the returned id_token and + # checked there to bind this specific flow to that specific token. + nonce: Mapped[str | None] = mapped_column(String(64), nullable=True) + # "login": create-or-authenticate a session for whichever user this + # identity resolves to. "link": attach this provider identity to the + # already-authenticated `link_user_id` instead. + intent: Mapped[str] = mapped_column(String(16)) + link_user_id: Mapped[str | None] = mapped_column( + String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=True + ) + # The frontend origin to send the browser back to once the callback + # finishes, success or error -- captured from the request that started + # this flow rather than recomputed at callback time, so the two ends of + # one flow always agree even if a deployment ever serves the start and + # callback routes through more than one entry origin. + frontend_redirect_base: Mapped[str] = mapped_column(Text) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC)) + expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) + + __table_args__ = (CheckConstraint("intent IN ('login', 'link')", name="ck_oauth_flow_states_intent"),) diff --git a/apps/backend/app/models/oauth_identity.py b/apps/backend/app/models/oauth_identity.py new file mode 100644 index 00000000..a6d13d32 --- /dev/null +++ b/apps/backend/app/models/oauth_identity.py @@ -0,0 +1,38 @@ +from datetime import UTC, datetime + +from sqlalchemy import DateTime, ForeignKey, String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import Base + + +class OAuthIdentity(Base): + """One linked external sign-in identity for one PARTHA user (#288). + + A user may link at most one identity per provider (the unique + constraint on ``(provider, user_id)``), and a given external identity + may only ever be linked to one PARTHA account (the unique constraint on + ``(provider, provider_subject)``) -- linking never silently merges two + accounts. An email match alone is never sufficient to link; see + ``OAuthPendingLink`` for the explicit-confirmation path that applies + when a discovered identity's email belongs to an existing account. + """ + + __tablename__ = "oauth_identities" + + id: Mapped[str] = mapped_column(String(36), primary_key=True) + user_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id", ondelete="CASCADE"), index=True) + provider: Mapped[str] = mapped_column(String(32)) + # The provider's stable, verified subject identifier (Google: the OIDC + # `sub` claim; GitHub: the numeric account id, as a string) -- never the + # email, which can change at the provider and is not identity. + provider_subject: Mapped[str] = mapped_column(String(255)) + # The verified email at link time, kept only for display in Settings' + # linked-account list; never re-verified or kept in sync afterward. + email: Mapped[str | None] = mapped_column(Text, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC)) + + __table_args__ = ( + UniqueConstraint("provider", "provider_subject", name="uq_oauth_identities_provider_subject"), + UniqueConstraint("provider", "user_id", name="uq_oauth_identities_provider_user"), + ) diff --git a/apps/backend/app/models/oauth_pending_link.py b/apps/backend/app/models/oauth_pending_link.py new file mode 100644 index 00000000..1ede5bfa --- /dev/null +++ b/apps/backend/app/models/oauth_pending_link.py @@ -0,0 +1,29 @@ +from datetime import UTC, datetime + +from sqlalchemy import DateTime, String, Text +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import Base + + +class OAuthPendingLink(Base): + """A verified external identity discovered during sign-in whose email + matches an existing PARTHA account that has not linked this provider + yet (#288). + + Never auto-linked -- a matching email is not proof of ownership. The + account owner must confirm with their password + (``POST /auth/oauth/link/confirm``) before the two identities are + connected. Single-use and short-lived: deleted once confirmed, expired, + or superseded by a fresh attempt. + """ + + __tablename__ = "oauth_pending_links" + + id: Mapped[str] = mapped_column(String(36), primary_key=True) + provider: Mapped[str] = mapped_column(String(32)) + provider_subject: Mapped[str] = mapped_column(String(255)) + email: Mapped[str] = mapped_column(Text) + display_name: Mapped[str | None] = mapped_column(Text, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC)) + expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) diff --git a/apps/backend/app/models/refresh_token.py b/apps/backend/app/models/refresh_token.py new file mode 100644 index 00000000..b563a56c --- /dev/null +++ b/apps/backend/app/models/refresh_token.py @@ -0,0 +1,26 @@ +from datetime import UTC, datetime + +from sqlalchemy import DateTime, ForeignKey, String +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import Base + + +class RefreshToken(Base): + """One issued refresh token. Only the sha256 of the raw token is stored. + + Tokens form rotation families: each /auth/refresh marks the presented row + used and issues a successor in the same ``family_id``. Presenting an + already-used token is treated as theft and revokes the entire family. + """ + + __tablename__ = "refresh_tokens" + + id: Mapped[str] = mapped_column(String(36), primary_key=True) + user_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id", ondelete="CASCADE"), index=True) + token_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True) + family_id: Mapped[str] = mapped_column(String(36), index=True) + expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) + used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC)) diff --git a/apps/backend/app/models/repository.py b/apps/backend/app/models/repository.py index 749d9568..913e9ea3 100644 --- a/apps/backend/app/models/repository.py +++ b/apps/backend/app/models/repository.py @@ -1,25 +1,60 @@ from datetime import UTC, datetime -from sqlalchemy import BigInteger, DateTime, Integer, JSON, String, Text -from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy import ( + BigInteger, + CheckConstraint, + DateTime, + ForeignKey, + ForeignKeyConstraint, + Index, + Integer, + JSON, + String, + Text, + UniqueConstraint, +) +from sqlalchemy.orm import Mapped, mapped_column, validates from app.models.base import Base +def _hex_only_sql(expression: str) -> str: + """Portable SQLite/PostgreSQL check that ``expression`` is lowercase hex.""" + + for character in "0123456789abcdef": + expression = f"replace({expression}, '{character}', '')" + return f"{expression} = ''" + + class RepositoryRecord(Base): __tablename__ = "repositories" id: Mapped[str] = mapped_column(String(36), primary_key=True) + owner_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id", ondelete="CASCADE"), index=True) name: Mapped[str] = mapped_column(String(255), index=True) description: Mapped[str | None] = mapped_column(Text, nullable=True) source: Mapped[str] = mapped_column(String(32), index=True) source_url: Mapped[str | None] = mapped_column(Text, nullable=True) branch: Mapped[str | None] = mapped_column(String(255), nullable=True) + # First-class revision identity (#87, RFC §3). ``revision_value`` is the + # immutable, indexed identity: the 40-char lowercase git commit SHA for + # GitHub imports, or the ``sha256:`` archive content hash for uploads. + # ``revision_ref`` is the resolved-but-moving ref (e.g. ``refs/heads/main``) + # and is descriptive metadata only — never identity. A moving branch name is + # never a substitute for the revision value. + revision_kind: Mapped[str | None] = mapped_column(String(16), nullable=True) + revision_value: Mapped[str | None] = mapped_column(String(80), nullable=True) + revision_ref: Mapped[str | None] = mapped_column(String(255), nullable=True) + # Durable logical grouping above revisions (#299, RFC-0002). Both remain + # permanently nullable: an upload or an unresolved-ref legacy GitHub row + # is a standalone import with no lineage, by design (RFC §4.3/§6), not a + # transitional state to be tightened later. + lineage_id: Mapped[str | None] = mapped_column(String(36), nullable=True) + sequence: Mapped[int | None] = mapped_column(Integer, nullable=True) local_path: Mapped[str] = mapped_column(Text) size: Mapped[int] = mapped_column(BigInteger, default=0) file_count: Mapped[int] = mapped_column(Integer, default=0) status: Mapped[str] = mapped_column(String(32), index=True) - data_source: Mapped[str] = mapped_column(String(32), default="real") analysis_stage: Mapped[str | None] = mapped_column(String(64), nullable=True) analysis_progress: Mapped[int] = mapped_column(Integer, default=0) uploaded_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC)) @@ -33,3 +68,83 @@ class RepositoryRecord(Base): default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC), ) + + __table_args__ = ( + # Composite target for snapshot revision ownership. ``id`` is already + # unique, but including the revision columns lets the snapshot FK prove + # that a snapshot cannot be attached to a different repository revision. + UniqueConstraint( + "id", + "revision_kind", + "revision_value", + name="uq_repositories_id_revision", + ), + CheckConstraint( + "revision_kind IS NULL OR revision_kind IN ('git', 'upload')", + name="ck_repositories_revision_kind", + ), + CheckConstraint( + "(revision_kind IS NULL AND revision_value IS NULL AND revision_ref IS NULL) OR " + "(revision_kind IS NOT NULL AND revision_value IS NOT NULL)", + name="ck_repositories_revision_complete", + ), + CheckConstraint( + "revision_kind <> 'upload' OR " + f"(revision_ref IS NULL AND length(revision_value) = 71 AND " + f"substr(revision_value, 1, 7) = 'sha256:' AND {_hex_only_sql('substr(revision_value, 8)')})", + name="ck_repositories_upload_revision", + ), + CheckConstraint( + "revision_kind <> 'git' OR " + f"(length(revision_value) = 40 AND {_hex_only_sql('revision_value')} AND " + "(revision_ref IS NULL OR revision_ref LIKE 'refs/%'))", + name="ck_repositories_git_revision", + ), + Index("ix_repositories_revision_value", "revision_value"), + CheckConstraint( + "(lineage_id IS NULL AND sequence IS NULL) OR " + "(lineage_id IS NOT NULL AND sequence IS NOT NULL AND sequence >= 1)", + name="ck_repositories_lineage_sequence_pair", + ), + # Standalone rows (both null) never collide under a unique constraint: + # SQL uniqueness treats every NULL as distinct. Also serves ordered + # lineage reads. + UniqueConstraint("lineage_id", "sequence", name="uq_repositories_lineage_sequence"), + # Composite target proving a lineage's latest-member pointer names a + # repository that actually belongs to that exact lineage. + UniqueConstraint("id", "lineage_id", name="uq_repositories_id_lineage"), + # Cross-owner attachment is invalid at the database layer even if + # service code is wrong (#299 §9). Deferred: a lineage and its first + # repository are written in one transaction (RFC §5.2), so this must + # not be checked until commit. No automatic delete action -- deletion + # updates or clears the lineage's latest pointer explicitly first + # (RFC §8.3), it is never left to a database cascade/set-null here. + # This is one half of a cyclic FK pair with `repository_lineages`; + # see the known `create_all()`-on-SQLite enforcement limitation + # documented on `RepositoryLineage.fk_repository_lineages_latest_member` + # (app/models/repository_lineage.py) -- it applies equally to + # whichever of the two constraints ends up as the forward reference. + ForeignKeyConstraint( + ["lineage_id", "owner_id"], + ["repository_lineages.id", "repository_lineages.owner_id"], + name="fk_repositories_lineage_owner", + deferrable=True, + initially="DEFERRED", + ), + ) + + @validates("revision_kind", "revision_value") + def _enforce_immutable_revision_identity(self, key: str, value: str | None) -> str | None: + """Revision identity is immutable once written (#87, RFC §3.2). + + A new commit or upload content hash produces a *new* repository record + rather than mutating an existing identity, so any attempt to change an + already-written ``revision_value`` is rejected. Setting it for the first + time (``None`` -> value) and idempotent re-writes are allowed; loads from + the database do not pass through this validator. + """ + + current = getattr(self, key, None) + if current is not None and value != current: + raise ValueError("Repository revision identity is immutable once written.") + return value diff --git a/apps/backend/app/models/repository_lineage.py b/apps/backend/app/models/repository_lineage.py new file mode 100644 index 00000000..734bfdb5 --- /dev/null +++ b/apps/backend/app/models/repository_lineage.py @@ -0,0 +1,113 @@ +from datetime import UTC, datetime + +from sqlalchemy import ( + CheckConstraint, + DateTime, + ForeignKeyConstraint, + Index, + Integer, + String, + Text, + UniqueConstraint, + text, +) +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import Base + +_CANONICAL_PARTIAL_WHERE = text("canonical_source_key IS NOT NULL AND canonical_branch IS NOT NULL") + + +class RepositoryLineage(Base): + """Durable, owner-scoped logical grouping above repository revisions (#299, RFC-0002). + + A row here groups repeated imports of the same GitHub repository/branch + into one ordered history. Uploads and unresolved-ref GitHub rows never get + a row here -- they stay unlineaged standalone imports (RFC §4.3/§6) -- so + ``canonical_source_key``/``canonical_branch`` and every ``repositories`` + attachment are permanently optional, not a transitional NULL. + """ + + __tablename__ = "repository_lineages" + + id: Mapped[str] = mapped_column(String(36), primary_key=True) + owner_id: Mapped[str] = mapped_column(String(36), index=True) + canonical_source_key: Mapped[str | None] = mapped_column(Text, nullable=True) + canonical_branch: Mapped[str | None] = mapped_column(Text, nullable=True) + display_name: Mapped[str] = mapped_column(Text) + # The current highest surviving sequence in this lineage, or null for an + # empty lineage. This FK is deferrable/initially-deferred and cyclic with + # `repositories`: a lineage must exist (with a null pointer) before its + # first repository can be inserted, and the pointer is only set afterward, + # in the same transaction (RFC §5.1/§5.2). + latest_repository_id: Mapped[str | None] = mapped_column(String(36), nullable=True) + # Durable, never-reused, transactionally-allocated next ordinal. Starts at + # 1; deleting a repository never decrements it (RFC §4.3). + next_sequence: Mapped[int] = mapped_column(Integer, default=1) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC)) + + __table_args__ = ( + # Composite ownership FK target for `repositories.lineage_id`. + UniqueConstraint("id", "owner_id", name="uq_repository_lineages_id_owner"), + CheckConstraint( + "(canonical_source_key IS NULL AND canonical_branch IS NULL) OR " + "(canonical_source_key IS NOT NULL AND canonical_branch IS NOT NULL)", + name="ck_repository_lineages_canonical_pair", + ), + CheckConstraint("next_sequence >= 1", name="ck_repository_lineages_next_sequence_positive"), + ForeignKeyConstraint( + ["owner_id"], + ["users.id"], + name="fk_repository_lineages_owner_id_users", + ondelete="CASCADE", + ), + # The owner-scoped canonical lookup index, and the sole source of + # "does this lineage already exist" truth if two imports race to + # create the first one (RFC §5.2). + Index( + "uq_repository_lineages_owner_source_branch", + "owner_id", + "canonical_source_key", + "canonical_branch", + unique=True, + postgresql_where=_CANONICAL_PARTIAL_WHERE, + sqlite_where=_CANONICAL_PARTIAL_WHERE, + ), + # The cyclic half of the lineage/repository integrity boundary (RFC + # §4.2): a latest-pointer can never name a repository outside this + # exact lineage, even if service code is wrong. Declared here (rather + # than only in the migration) so `create_all()` in development/test + # produces the identical *declared* shape a migrated database + # reaches (`PRAGMA foreign_key_list`/`inspector.get_foreign_keys()` + # show it either way, on both dialects). + # + # Known SQLite limitation (confirmed in CI, #299): `create_all()` + # cannot avoid embedding one of these two cyclic FKs (this one, or + # `repositories.fk_repositories_lineage_owner`) as an inline forward + # reference to a table that doesn't exist yet -- SQLite must create + # one of `repositories`/`repository_lineages` before the other, and + # there is no `ALTER TABLE ADD CONSTRAINT` to add the missing half + # afterward the way the migration does. Which of the two ends up as + # the forward reference depends on `create_all()`'s internal + # cyclic-dependency tie-break, not something this code controls. At + # least one real SQLite build does not enforce a deferred FK + # declared that way -- it still reports the constraint correctly, + # but a genuine violation neither raises at COMMIT nor shows up in + # `PRAGMA foreign_key_check`. The + # Alembic migration (0013/0014) never creates this forward + # reference -- it adds each cyclic FK in its own revision, after + # both tables already exist -- so a database built by migrating, + # including every real deployment and this repo's own CI rehearsal, + # is unaffected. This is a `create_all()`-only (development/test + # bootstrap) gap, not a production one; see + # tests/test_repository_lineage_migration.py's + # test_cross_owner_lineage_attachment_is_rejected_by_the_database_even_if_forced + # for the migration-backed, reliable version of this proof. + ForeignKeyConstraint( + ["latest_repository_id", "id"], + ["repositories.id", "repositories.lineage_id"], + name="fk_repository_lineages_latest_member", + deferrable=True, + initially="DEFERRED", + ), + ) diff --git a/apps/backend/app/models/snapshot.py b/apps/backend/app/models/snapshot.py new file mode 100644 index 00000000..c5de1151 --- /dev/null +++ b/apps/backend/app/models/snapshot.py @@ -0,0 +1,498 @@ +"""Immutable Repository Intelligence snapshot persistence (RFC-0001 §11, #88). + +These ORM tables are the normalized, revision-addressed system of record that +replaces the mutable ``repo_metadata['intelligence']`` blob. They store the +complete ``ri.v1`` storage contract: snapshots, nodes, edges, assertions, +observations, evidence, derivation references, and diagnostics. + +Integrity that can be expressed as a database invariant is enforced here with +constraints and foreign keys (RFC §11.2, §3.3): + +- ``UNIQUE(snapshot_id, stable_key)`` — one node per entity per snapshot; +- a partial unique index giving **at most one** ``repo:root`` node per snapshot; +- composite foreign keys tying every edge endpoint, assertion subject, and + evidence/derivation parent to a row **in the same snapshot** (no + cross-snapshot or cross-repository leakage); +- deterministic ``edge_id`` / ``assertion_id`` / ``observation_id`` uniqueness + per snapshot; +- a partial unique index giving **at most one** ``completed`` snapshot per + complete semantic identity ``(repository_id, revision_value, schema_version, + producer_set_hash, config_hash)``. + +Graph-shape invariants that cannot be a single SQL constraint (derivation +target resolution, acyclicity, provenance completeness, canonical ordering) are +enforced in the sealing transaction (:mod:`app.intelligence.snapshot_store`), +and completed-snapshot immutability is enforced at the persistence boundary by a +``before_flush`` guard in the same module. +""" + +from __future__ import annotations + +from datetime import UTC, datetime + +from sqlalchemy import ( + CheckConstraint, + ForeignKey, + ForeignKeyConstraint, + Index, + Integer, + JSON, + String, + Text, + UniqueConstraint, + text, +) +from sqlalchemy import DateTime +from sqlalchemy.ext.mutable import MutableDict, MutableList +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import Base +from app.models.repository import _hex_only_sql + + +def _utcnow() -> datetime: + return datetime.now(UTC) + + +class RiSnapshot(Base): + __tablename__ = "ri_snapshots" + + snapshot_id: Mapped[str] = mapped_column(String(48), primary_key=True) + repository_id: Mapped[str] = mapped_column(String(36), nullable=False) + revision_kind: Mapped[str] = mapped_column(String(16), nullable=False) + revision_value: Mapped[str] = mapped_column(String(80), nullable=False) + revision_ref: Mapped[str | None] = mapped_column(String(255), nullable=True) + schema_version: Mapped[str] = mapped_column(String(16), nullable=False) + producer_version_set: Mapped[list] = mapped_column(MutableList.as_mutable(JSON), nullable=False) + producer_set_hash: Mapped[str] = mapped_column(String(80), nullable=False) + config_hash: Mapped[str] = mapped_column(String(80), nullable=False) + state: Mapped[str] = mapped_column(String(16), nullable=False, default="building") + canonical_graph_hash: Mapped[str | None] = mapped_column(String(80), nullable=True) + actual_producers: Mapped[list | None] = mapped_column(MutableList.as_mutable(JSON), nullable=True) + failure_code: Mapped[str | None] = mapped_column(String(64), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=_utcnow) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, default=_utcnow, onupdate=_utcnow + ) + sealed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + + __table_args__ = ( + CheckConstraint( + "state IN ('building', 'completed', 'failed')", + name="ck_ri_snapshots_state", + ), + CheckConstraint( + "revision_kind IN ('git', 'upload')", + name="ck_ri_snapshots_revision_kind", + ), + CheckConstraint( + "revision_kind <> 'upload' OR " + f"(revision_ref IS NULL AND length(revision_value) = 71 AND " + f"substr(revision_value, 1, 7) = 'sha256:' AND {_hex_only_sql('substr(revision_value, 8)')})", + name="ck_ri_snapshots_upload_revision", + ), + CheckConstraint( + "revision_kind <> 'git' OR " + f"(length(revision_value) = 40 AND {_hex_only_sql('revision_value')} AND " + "(revision_ref IS NULL OR revision_ref LIKE 'refs/%'))", + name="ck_ri_snapshots_git_revision", + ), + CheckConstraint( + f"length(producer_set_hash) = 71 AND substr(producer_set_hash, 1, 7) = 'sha256:' AND " + f"{_hex_only_sql('substr(producer_set_hash, 8)')}", + name="ck_ri_snapshots_producer_hash", + ), + CheckConstraint( + f"length(config_hash) = 71 AND substr(config_hash, 1, 7) = 'sha256:' AND " + f"{_hex_only_sql('substr(config_hash, 8)')}", + name="ck_ri_snapshots_config_hash", + ), + CheckConstraint( + "(state = 'completed' AND canonical_graph_hash IS NOT NULL AND sealed_at IS NOT NULL) OR " + "(state <> 'completed' AND canonical_graph_hash IS NULL AND sealed_at IS NULL)", + name="ck_ri_snapshots_seal_fields", + ), + CheckConstraint( + "canonical_graph_hash IS NULL OR " + f"(length(canonical_graph_hash) = 71 AND substr(canonical_graph_hash, 1, 7) = 'sha256:' AND " + f"{_hex_only_sql('substr(canonical_graph_hash, 8)')})", + name="ck_ri_snapshots_canonical_hash", + ), + ForeignKeyConstraint( + ["repository_id", "revision_kind", "revision_value"], + ["repositories.id", "repositories.revision_kind", "repositories.revision_value"], + name="fk_ri_snapshots_repository_revision", + ondelete="CASCADE", + ), + Index("ix_ri_snapshots_repository_id", "repository_id"), + Index("ix_ri_snapshots_repository_revision", "repository_id", "revision_value"), + # At most one completed snapshot per complete semantic identity (RFC §3.3). + Index( + "uq_ri_snapshots_completed_identity", + "repository_id", + "revision_value", + "schema_version", + "producer_set_hash", + "config_hash", + unique=True, + sqlite_where=text("state = 'completed'"), + postgresql_where=text("state = 'completed'"), + ), + ) + + +class RiNode(Base): + __tablename__ = "ri_nodes" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + snapshot_id: Mapped[str] = mapped_column( + String(48), ForeignKey("ri_snapshots.snapshot_id", ondelete="CASCADE"), nullable=False + ) + stable_key: Mapped[str] = mapped_column(String(1024), nullable=False) + node_kind: Mapped[str] = mapped_column(String(16), nullable=False) + name: Mapped[str | None] = mapped_column(String(512), nullable=True) + language: Mapped[str | None] = mapped_column(String(64), nullable=True) + truth_class: Mapped[str] = mapped_column(String(16), nullable=False, default="observed") + properties: Mapped[dict | None] = mapped_column(MutableDict.as_mutable(JSON), nullable=True) + + __table_args__ = ( + UniqueConstraint("snapshot_id", "stable_key", name="uq_ri_nodes_snapshot_stable_key"), + # Target for same-snapshot composite foreign keys from edges/evidence. + UniqueConstraint("snapshot_id", "id", name="uq_ri_nodes_snapshot_row"), + CheckConstraint("truth_class = 'observed'", name="ck_ri_nodes_truth_class"), + Index("ix_ri_nodes_snapshot_id", "snapshot_id"), + # Exactly one repository (repo:root) node per snapshot: the DB enforces + # "at most one"; the sealing transaction enforces "at least one". + Index( + "uq_ri_nodes_single_root", + "snapshot_id", + unique=True, + sqlite_where=text("node_kind = 'repository'"), + postgresql_where=text("node_kind = 'repository'"), + ), + ) + + +class RiEdge(Base): + __tablename__ = "ri_edges" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + snapshot_id: Mapped[str] = mapped_column( + String(48), ForeignKey("ri_snapshots.snapshot_id", ondelete="CASCADE"), nullable=False + ) + edge_id: Mapped[str] = mapped_column(String(96), nullable=False) + subject_kind: Mapped[str] = mapped_column(String(16), nullable=False) + subject_key: Mapped[str] = mapped_column(String(1024), nullable=False) + predicate: Mapped[str] = mapped_column(String(32), nullable=False) + object_kind: Mapped[str] = mapped_column(String(16), nullable=False) + object_key: Mapped[str] = mapped_column(String(1024), nullable=False) + truth_class: Mapped[str] = mapped_column(String(16), nullable=False, default="resolved") + producer: Mapped[str] = mapped_column(String(128), nullable=False) + producer_version: Mapped[str] = mapped_column(String(64), nullable=False) + + __table_args__ = ( + UniqueConstraint("snapshot_id", "edge_id", name="uq_ri_edges_snapshot_edge_id"), + UniqueConstraint( + "snapshot_id", + "subject_key", + "predicate", + "object_key", + name="uq_ri_edges_snapshot_triple", + ), + UniqueConstraint("snapshot_id", "id", name="uq_ri_edges_snapshot_row"), + CheckConstraint("truth_class = 'resolved'", name="ck_ri_edges_truth_class"), + # Valid, same-snapshot endpoints (RFC §5.2): both endpoints must be a + # node in this snapshot. Composite keys carry snapshot_id so an endpoint + # can never resolve into a different snapshot. + ForeignKeyConstraint( + ["snapshot_id", "subject_key"], + ["ri_nodes.snapshot_id", "ri_nodes.stable_key"], + name="fk_ri_edges_subject_node", + ondelete="CASCADE", + ), + ForeignKeyConstraint( + ["snapshot_id", "object_key"], + ["ri_nodes.snapshot_id", "ri_nodes.stable_key"], + name="fk_ri_edges_object_node", + ondelete="CASCADE", + ), + Index("ix_ri_edges_snapshot_id", "snapshot_id"), + Index( + "ix_ri_edges_snapshot_subject_predicate", + "snapshot_id", + "subject_key", + "predicate", + ), + Index( + "ix_ri_edges_snapshot_object_predicate", + "snapshot_id", + "object_key", + "predicate", + ), + ) + + +class RiAssertion(Base): + __tablename__ = "ri_assertions" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + snapshot_id: Mapped[str] = mapped_column( + String(48), ForeignKey("ri_snapshots.snapshot_id", ondelete="CASCADE"), nullable=False + ) + assertion_id: Mapped[str] = mapped_column(String(96), nullable=False) + subject_kind: Mapped[str] = mapped_column(String(16), nullable=False) + subject_key: Mapped[str] = mapped_column(String(1024), nullable=False) + predicate: Mapped[str] = mapped_column(String(32), nullable=False) + value: Mapped[dict] = mapped_column(MutableDict.as_mutable(JSON), nullable=False) + truth_class: Mapped[str] = mapped_column(String(16), nullable=False, default="inferred") + producer: Mapped[str] = mapped_column(String(128), nullable=False) + producer_version: Mapped[str] = mapped_column(String(64), nullable=False) + + __table_args__ = ( + UniqueConstraint("snapshot_id", "assertion_id", name="uq_ri_assertions_snapshot_assertion_id"), + UniqueConstraint("snapshot_id", "id", name="uq_ri_assertions_snapshot_row"), + CheckConstraint("truth_class = 'inferred'", name="ck_ri_assertions_truth_class"), + # An assertion subject must resolve to an existing node in the same + # snapshot (RFC §5.6). + ForeignKeyConstraint( + ["snapshot_id", "subject_key"], + ["ri_nodes.snapshot_id", "ri_nodes.stable_key"], + name="fk_ri_assertions_subject_node", + ondelete="CASCADE", + ), + Index("ix_ri_assertions_snapshot_id", "snapshot_id"), + ) + + +class RiObservation(Base): + __tablename__ = "ri_observations" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + snapshot_id: Mapped[str] = mapped_column( + String(48), ForeignKey("ri_snapshots.snapshot_id", ondelete="CASCADE"), nullable=False + ) + observation_id: Mapped[str] = mapped_column(String(96), nullable=False) + observed_kind: Mapped[str] = mapped_column(String(16), nullable=False) + subject_kind: Mapped[str] = mapped_column(String(16), nullable=False) + subject_key: Mapped[str] = mapped_column(String(1024), nullable=False) + referent_text: Mapped[str | None] = mapped_column(Text, nullable=True) + ordinal: Mapped[int] = mapped_column(Integer, nullable=False) + + __table_args__ = ( + UniqueConstraint("snapshot_id", "observation_id", name="uq_ri_observations_snapshot_obs_id"), + UniqueConstraint("snapshot_id", "id", name="uq_ri_observations_snapshot_row"), + CheckConstraint("ordinal >= 1", name="ck_ri_observations_ordinal"), + ForeignKeyConstraint( + ["snapshot_id", "subject_key"], + ["ri_nodes.snapshot_id", "ri_nodes.stable_key"], + name="fk_ri_observations_subject_node", + ondelete="CASCADE", + ), + Index("ix_ri_observations_snapshot_id", "snapshot_id"), + ) + + +class RiEvidence(Base): + __tablename__ = "ri_evidence" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + snapshot_id: Mapped[str] = mapped_column( + String(48), ForeignKey("ri_snapshots.snapshot_id", ondelete="CASCADE"), nullable=False + ) + node_ref: Mapped[int | None] = mapped_column(Integer, nullable=True) + edge_ref: Mapped[int | None] = mapped_column(Integer, nullable=True) + observation_ref: Mapped[int | None] = mapped_column(Integer, nullable=True) + path: Mapped[str] = mapped_column(String(1024), nullable=False) + start_line: Mapped[int] = mapped_column(Integer, nullable=False) + end_line: Mapped[int] = mapped_column(Integer, nullable=False) + # Producer-supplied file length used only to bound the span (RFC §6.2). It is + # internal validation metadata: it is deliberately excluded from the canonical + # evidence record and the graph hash, so two equivalent graphs still hash + # identically regardless of the logical-line counts their producers reported. + logical_line_count: Mapped[int] = mapped_column(Integer, nullable=False) + granularity: Mapped[str] = mapped_column(String(16), nullable=False, default="span") + extractor: Mapped[str] = mapped_column(String(128), nullable=False) + extractor_version: Mapped[str] = mapped_column(String(64), nullable=False) + + __table_args__ = ( + # Exactly one parent fact (node, edge, or observation). + CheckConstraint( + "(CASE WHEN node_ref IS NOT NULL THEN 1 ELSE 0 END + " + "CASE WHEN edge_ref IS NOT NULL THEN 1 ELSE 0 END + " + "CASE WHEN observation_ref IS NOT NULL THEN 1 ELSE 0 END) = 1", + name="ck_ri_evidence_single_parent", + ), + # One-based inclusive span bounded by the persisted logical-line count + # (RFC §6.2). The upper bound is what stops a post-insert mutation from + # stretching a span past the file it cites. + CheckConstraint( + "start_line >= 1 AND end_line >= start_line AND logical_line_count >= 1 AND end_line <= logical_line_count", + name="ck_ri_evidence_span", + ), + CheckConstraint("granularity IN ('span', 'file')", name="ck_ri_evidence_granularity"), + # Defence in depth against absolute paths and traversal; full RFC §4.2 + # normalization/rejection happens in the persistence layer before insert. + CheckConstraint("path NOT LIKE '/%'", name="ck_ri_evidence_relative_path"), + # Same-snapshot provenance (RFC §6, §13): a NULL ref column disables its + # composite FK, so exactly the one populated parent link is enforced. + ForeignKeyConstraint( + ["snapshot_id", "node_ref"], + ["ri_nodes.snapshot_id", "ri_nodes.id"], + name="fk_ri_evidence_node", + ondelete="CASCADE", + ), + ForeignKeyConstraint( + ["snapshot_id", "edge_ref"], + ["ri_edges.snapshot_id", "ri_edges.id"], + name="fk_ri_evidence_edge", + ondelete="CASCADE", + ), + ForeignKeyConstraint( + ["snapshot_id", "observation_ref"], + ["ri_observations.snapshot_id", "ri_observations.id"], + name="fk_ri_evidence_observation", + ondelete="CASCADE", + ), + Index("ix_ri_evidence_snapshot_id", "snapshot_id"), + Index("ix_ri_evidence_node_ref", "snapshot_id", "node_ref"), + Index("ix_ri_evidence_edge_ref", "snapshot_id", "edge_ref"), + Index("ix_ri_evidence_observation_ref", "snapshot_id", "observation_ref"), + Index( + "uq_ri_evidence_node_fact", + "snapshot_id", + "node_ref", + "path", + "start_line", + "end_line", + "granularity", + "extractor", + "extractor_version", + unique=True, + sqlite_where=text("node_ref IS NOT NULL"), + postgresql_where=text("node_ref IS NOT NULL"), + ), + Index( + "uq_ri_evidence_edge_fact", + "snapshot_id", + "edge_ref", + "path", + "start_line", + "end_line", + "granularity", + "extractor", + "extractor_version", + unique=True, + sqlite_where=text("edge_ref IS NOT NULL"), + postgresql_where=text("edge_ref IS NOT NULL"), + ), + Index( + "uq_ri_evidence_observation_fact", + "snapshot_id", + "observation_ref", + "path", + "start_line", + "end_line", + "granularity", + "extractor", + "extractor_version", + unique=True, + sqlite_where=text("observation_ref IS NOT NULL"), + postgresql_where=text("observation_ref IS NOT NULL"), + ), + ) + + +class RiDerivation(Base): + __tablename__ = "ri_derivations" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + snapshot_id: Mapped[str] = mapped_column( + String(48), ForeignKey("ri_snapshots.snapshot_id", ondelete="CASCADE"), nullable=False + ) + edge_ref: Mapped[int | None] = mapped_column(Integer, nullable=True) + assertion_ref: Mapped[int | None] = mapped_column(Integer, nullable=True) + ref_kind: Mapped[str] = mapped_column(String(16), nullable=False) + ref_identity: Mapped[str] = mapped_column(String(1024), nullable=False) + + __table_args__ = ( + CheckConstraint( + "(CASE WHEN edge_ref IS NOT NULL THEN 1 ELSE 0 END + " + "CASE WHEN assertion_ref IS NOT NULL THEN 1 ELSE 0 END) = 1", + name="ck_ri_derivations_single_parent", + ), + CheckConstraint( + "ref_kind IN ('observation', 'node', 'edge', 'assertion')", + name="ck_ri_derivations_ref_kind", + ), + # The derived fact (edge or assertion) must live in the same snapshot. + # The referenced *target* is resolved (and cycle-checked) in the sealing + # transaction because it can point at four different fact tables by + # content identity, not a single-column primary key (RFC §11.2 rule 7). + ForeignKeyConstraint( + ["snapshot_id", "edge_ref"], + ["ri_edges.snapshot_id", "ri_edges.id"], + name="fk_ri_derivations_edge", + ondelete="CASCADE", + ), + ForeignKeyConstraint( + ["snapshot_id", "assertion_ref"], + ["ri_assertions.snapshot_id", "ri_assertions.id"], + name="fk_ri_derivations_assertion", + ondelete="CASCADE", + ), + Index("ix_ri_derivations_snapshot_id", "snapshot_id"), + Index( + "uq_ri_derivations_edge_fact", + "snapshot_id", + "edge_ref", + "ref_kind", + "ref_identity", + unique=True, + sqlite_where=text("edge_ref IS NOT NULL"), + postgresql_where=text("edge_ref IS NOT NULL"), + ), + Index( + "uq_ri_derivations_assertion_fact", + "snapshot_id", + "assertion_ref", + "ref_kind", + "ref_identity", + unique=True, + sqlite_where=text("assertion_ref IS NOT NULL"), + postgresql_where=text("assertion_ref IS NOT NULL"), + ), + ) + + +class RiDiagnostic(Base): + __tablename__ = "ri_diagnostics" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + snapshot_id: Mapped[str] = mapped_column( + String(48), ForeignKey("ri_snapshots.snapshot_id", ondelete="CASCADE"), nullable=False + ) + code: Mapped[str] = mapped_column(String(64), nullable=False) + category: Mapped[str] = mapped_column(String(64), nullable=False) + severity: Mapped[str] = mapped_column(String(16), nullable=False) + message: Mapped[str] = mapped_column(Text, nullable=False) + path: Mapped[str | None] = mapped_column(String(1024), nullable=True) + span_start_line: Mapped[int | None] = mapped_column(Integer, nullable=True) + span_end_line: Mapped[int | None] = mapped_column(Integer, nullable=True) + producer: Mapped[str] = mapped_column(String(160), nullable=False) + subject_key: Mapped[str | None] = mapped_column(String(1024), nullable=True) + object_key: Mapped[str | None] = mapped_column(String(1024), nullable=True) + details: Mapped[dict | None] = mapped_column(MutableDict.as_mutable(JSON), nullable=True) + + __table_args__ = ( + CheckConstraint( + "severity IN ('fatal', 'error', 'warning', 'info')", + name="ck_ri_diagnostics_severity", + ), + CheckConstraint( + "(span_start_line IS NULL AND span_end_line IS NULL) OR " + "(span_start_line >= 1 AND span_end_line >= span_start_line)", + name="ck_ri_diagnostics_span", + ), + CheckConstraint("path IS NULL OR path NOT LIKE '/%'", name="ck_ri_diagnostics_relative_path"), + Index("ix_ri_diagnostics_snapshot_id", "snapshot_id"), + ) diff --git a/apps/backend/app/models/user.py b/apps/backend/app/models/user.py new file mode 100644 index 00000000..1bbf7c58 --- /dev/null +++ b/apps/backend/app/models/user.py @@ -0,0 +1,32 @@ +from datetime import UTC, datetime + +from sqlalchemy import Boolean, DateTime, String +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import Base + +# The system/seed user that owns repositories created before authentication +# existed. The 0002 migration backfills those rows to this owner. It has no +# credential and cannot log in, and since E1.3 removed the pre-auth fallback no +# live request is ever attributed to it — it exists only so historical data has +# a valid owner. The id is fixed so the migration backfill agrees with it. +SEED_USER_ID = "00000000-0000-0000-0000-000000000000" +SEED_USER_EMAIL = "system@partha.local" + + +class User(Base): + __tablename__ = "users" + + id: Mapped[str] = mapped_column(String(36), primary_key=True) + # 320 is the RFC 5321 maximum length for an email address. + email: Mapped[str] = mapped_column(String(320), unique=True, index=True) + # Nullable: the seed user and rows created before authentication have no + # credential and must never be able to log in (login rejects a null hash). + password_hash: Mapped[str | None] = mapped_column(String(255), nullable=True) + is_active: Mapped[bool] = mapped_column(Boolean, default=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC)) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(UTC), + onupdate=lambda: datetime.now(UTC), + ) diff --git a/apps/backend/app/models/waitlist_entry.py b/apps/backend/app/models/waitlist_entry.py new file mode 100644 index 00000000..4538674a --- /dev/null +++ b/apps/backend/app/models/waitlist_entry.py @@ -0,0 +1,22 @@ +from datetime import UTC, datetime + +from sqlalchemy import DateTime, String +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import Base + + +class WaitlistEntry(Base): + """One public waitlist signup (#334). + + Deliberately unauthenticated and separate from `User`/registration: a + waitlist submission is not an account and does not consume an invite -- + it is the owner's manual queue for deciding who to invite next. + """ + + __tablename__ = "waitlist_entries" + + id: Mapped[str] = mapped_column(String(36), primary_key=True) + email: Mapped[str] = mapped_column(String(320), unique=True, index=True) + name: Mapped[str | None] = mapped_column(String(200), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC)) diff --git a/apps/backend/app/parsers/repository_parser.py b/apps/backend/app/parsers/repository_parser.py index 40e786d8..2b3dee40 100644 --- a/apps/backend/app/parsers/repository_parser.py +++ b/apps/backend/app/parsers/repository_parser.py @@ -1,4 +1,5 @@ import json +import os from collections import Counter from pathlib import Path from uuid import uuid4 @@ -16,8 +17,32 @@ ".venv", "venv", "__pycache__", + ".cache", + ".mypy_cache", + ".pytest_cache", + ".tox", + "vendor", + "vendors", + "generated", + "__MACOSX", } + +def is_macos_artifact(name: str) -> bool: + """True for a macOS Finder/Archive Utility artifact, not real repository content. + + ``.DS_Store`` is Finder's per-directory bookkeeping file. ``._`` is + an AppleDouble sidecar -- the resource-fork half of a file, written + whenever the destination filesystem can't hold one natively (a plain zip, + for one) -- and it shares its real counterpart's extension while being + opaque binary, not source: left unfiltered, ``._app.py`` looks like a + second Python module to every downstream extension check and either + extracts as garbage or fails to parse. + """ + + return name == ".DS_Store" or name.startswith("._") + + LANGUAGE_BY_EXTENSION = { "py": "Python", "ts": "TypeScript", @@ -57,8 +82,37 @@ } +class RepositoryFileLimitExceeded(Exception): + def __init__(self, max_file_count: int, file_count: int) -> None: + super().__init__(f"repository contains more than {max_file_count} files") + self.max_file_count = max_file_count + self.file_count = file_count + + +class UnsafeRepositoryPath(Exception): + """A repository's checked-out tree contains a symlink. + + Archive uploads already can't reach this: TAR extraction rejects + symlink/link/device members before writing (storage/local.py), and + Python's zipfile.extractall() never materializes a real OS symlink from + a zip entry in the first place. A GitHub import has no such guard -- + `git clone` faithfully recreates whatever real symlinks the source + repository committed, including ones that point outside the checkout + (e.g. a repo containing ``ln -s /etc some_dir``). Walking that with + plain is_dir()/is_file()/stat() (which all follow symlinks) would + recurse into and catalog host filesystem content that was never part of + the imported repository. + """ + + def __init__(self, relative_path: str) -> None: + super().__init__(f"repository contains a symlink at {relative_path}") + self.relative_path = relative_path + + class RepositoryParser: - def parse(self, root: Path) -> tuple[list[FileTreeNode], RepositoryMeta, int]: + def parse(self, root: Path, *, max_file_count: int | None = None) -> tuple[list[FileTreeNode], RepositoryMeta, int]: + if max_file_count is not None: + self._enforce_file_count(root, max_file_count, [0]) tree = self._build_tree(root, root) flat = self._flatten(tree) file_nodes = [node for node in flat if node.type == "file"] @@ -87,12 +141,34 @@ def parse(self, root: Path) -> tuple[list[FileTreeNode], RepositoryMeta, int]: ) return tree, meta, total_size + def _enforce_file_count( + self, path: Path, max_file_count: int, file_count: list[int], root: Path | None = None + ) -> None: + """Stream the tree and abort before sorting or allocating file nodes.""" + + root = root or path + with os.scandir(path) as entries: + for entry in entries: + if entry.name in IGNORED_DIRS or is_macos_artifact(entry.name): + continue + if entry.is_symlink(): + relative = "/" + str(Path(entry.path).relative_to(root)).replace("\\", "/") + raise UnsafeRepositoryPath(relative) + if entry.is_dir(): + self._enforce_file_count(Path(entry.path), max_file_count, file_count, root) + elif entry.is_file(): + file_count[0] += 1 + if file_count[0] > max_file_count: + raise RepositoryFileLimitExceeded(max_file_count, file_count[0]) + def _build_tree(self, path: Path, root: Path) -> list[FileTreeNode]: nodes: list[FileTreeNode] = [] for child in sorted(path.iterdir(), key=lambda item: (item.is_file(), item.name.lower())): - if child.name in IGNORED_DIRS: + if child.name in IGNORED_DIRS or is_macos_artifact(child.name): continue relative = "/" + str(child.relative_to(root)).replace("\\", "/") + if child.is_symlink(): + raise UnsafeRepositoryPath(relative) if child.is_dir(): nodes.append( FileTreeNode( @@ -126,22 +202,44 @@ def _flatten(self, nodes: list[FileTreeNode]) -> list[FileTreeNode]: result.extend(self._flatten(node.children)) return result + def _safe_file(self, root: Path, relative: str) -> Path | None: + """Return `root/relative` only if it's a real file that resolves inside root. + + This is best-effort metadata detection (framework/license/package + manager guesses), unlike the file tree itself, so a symlinked + candidate is treated as simply absent rather than failing the whole + import: there's no recursion here, so no cycle risk, and a repo + that happens to symlink e.g. package.json to another path within + its own checkout is legitimate and still resolves inside root. A + symlink pointing outside root (the actual attack: e.g. `package.json + -> /etc/passwd` committed to a malicious repo, read here as JSON/text) + fails the containment check and is skipped. + """ + candidate = root / relative + if not candidate.is_file(): + return None + try: + candidate.resolve().relative_to(root.resolve()) + except ValueError: + return None + return candidate + def _detect_package_manager(self, root: Path) -> str | None: - if (root / "pnpm-lock.yaml").exists(): + if self._safe_file(root, "pnpm-lock.yaml"): return "pnpm" - if (root / "yarn.lock").exists(): + if self._safe_file(root, "yarn.lock"): return "yarn" - if (root / "package-lock.json").exists() or (root / "package.json").exists(): + if self._safe_file(root, "package-lock.json") or self._safe_file(root, "package.json"): return "npm" - if (root / "poetry.lock").exists(): + if self._safe_file(root, "poetry.lock"): return "poetry" - if (root / "requirements.txt").exists() or (root / "pyproject.toml").exists(): + if self._safe_file(root, "requirements.txt") or self._safe_file(root, "pyproject.toml"): return "pip" return None def _detect_framework(self, root: Path) -> str: - package_json = root / "package.json" - if package_json.exists(): + package_json = self._safe_file(root, "package.json") + if package_json is not None: try: data = json.loads(package_json.read_text(encoding="utf-8")) except json.JSONDecodeError: @@ -153,12 +251,12 @@ def _detect_framework(self, root: Path) -> str: return "React" if "vue" in dependencies: return "Vue" - pyproject = root / "pyproject.toml" - requirements = root / "requirements.txt" + pyproject = self._safe_file(root, "pyproject.toml") + requirements = self._safe_file(root, "requirements.txt") text = "" - if pyproject.exists(): + if pyproject is not None: text += pyproject.read_text(encoding="utf-8", errors="ignore") - if requirements.exists(): + if requirements is not None: text += requirements.read_text(encoding="utf-8", errors="ignore") lowered = text.lower() if "fastapi" in lowered: @@ -181,14 +279,15 @@ def _detect_entry_point(self, root: Path) -> str | None: "package.json", ] for candidate in candidates: - if (root / candidate).exists(): + if self._safe_file(root, candidate): return f"/{candidate}" return None def _detect_license(self, root: Path) -> str | None: for candidate in ("LICENSE", "LICENSE.md", "COPYING"): - if (root / candidate).exists(): - text = (root / candidate).read_text(encoding="utf-8", errors="ignore")[:500].lower() + safe_path = self._safe_file(root, candidate) + if safe_path is not None: + text = safe_path.read_text(encoding="utf-8", errors="ignore")[:500].lower() if "mit license" in text: return "MIT" if "apache license" in text: diff --git a/apps/backend/app/parsers/tree_sitter_parser.py b/apps/backend/app/parsers/tree_sitter_parser.py deleted file mode 100644 index 29120aa3..00000000 --- a/apps/backend/app/parsers/tree_sitter_parser.py +++ /dev/null @@ -1,28 +0,0 @@ -from dataclasses import dataclass - - -@dataclass(frozen=True) -class SyntaxParseResult: - language: str | None - symbols: list[str] - - -class TreeSitterParser: - """Thin integration point for future language-specific tree-sitter grammars.""" - - def parse_symbols(self, content: bytes, extension: str | None) -> SyntaxParseResult: - if not extension or not content: - return SyntaxParseResult(language=None, symbols=[]) - return SyntaxParseResult(language=self._language_from_extension(extension), symbols=[]) - - def _language_from_extension(self, extension: str) -> str | None: - return { - "py": "Python", - "ts": "TypeScript", - "tsx": "TypeScript", - "js": "JavaScript", - "jsx": "JavaScript", - "go": "Go", - "rs": "Rust", - "java": "Java", - }.get(extension) diff --git a/apps/backend/app/reports/builders.py b/apps/backend/app/reports/builders.py index b541a03a..29330b3d 100644 --- a/apps/backend/app/reports/builders.py +++ b/apps/backend/app/reports/builders.py @@ -1,7 +1,7 @@ """Build a `ReportDocument` from an existing analysis response. These builders never analyse a repository; they only reshape data already -produced by the Repository Intelligence Engine / existing analysis builders. +produced by the existing analysis builders. """ from app.reports.report_document import ReportDocument, Section, Table @@ -14,38 +14,60 @@ def build_review_document(review: EngineeringReviewResponse) -> ReportDocument: summary = review.summary + severities = summary.findings_by_severity sections: list[Section] = [ Section( heading="Executive Summary", + paragraphs=[ + summary.message, + "PARTHA reports only findings supported by the selected sealed snapshot. " + "Vulnerability scanning and categories without sufficient evidence are marked Not assessed.", + ], table=Table( headers=["Metric", "Value"], rows=[ - ["Overall Score", f"{summary.overall_score}/100"], - ["Overall Trend", summary.overall_trend], - ["Total Findings", str(summary.total_findings)], - ["Critical", str(summary.critical_count)], - ["High", str(summary.high_count)], - ["Medium", str(summary.medium_count)], - ["Low", str(summary.low_count)], + ["Evidence-backed findings", str(summary.evidence_backed_finding_count)], + ["Critical", str(severities.critical)], + ["High", str(severities.high)], + ["Medium", str(severities.medium)], + ["Low", str(severities.low)], + ["Info", str(severities.info)], + ["File-scoped findings", str(summary.file_scoped_finding_count)], + ["Omitted unsupported diagnostics", str(summary.omitted_unsupported_diagnostic_count)], + ["Vulnerability scanning", "Not assessed"], ], ), ), Section( - heading="Health Scores", + heading="Category Assessment", table=Table( - headers=["Category", "Score", "Risk", "Findings"], + headers=["Category", "State", "Findings", "Scope"], rows=[ [ - score.category.replace("-", " "), - f"{score.score}/100", - score.risk_level, - str(score.findings_count), + category.label, + category.state.replace("_", " "), + str(category.finding_count), + category.explanation, ] - for score in review.scores + for category in review.categories ], ), ), - Section(heading="Findings", paragraphs=[] if review.findings else ["No findings were generated."]), + Section( + heading="Snapshot Identity", + fields=[ + ("Revision", f"{review.revision_kind}:{review.revision_value}"), + ("Snapshot", review.snapshot_id), + ("Snapshot schema", review.snapshot_schema_version), + ("Manifest digest", review.manifest_digest), + ("Canonical graph hash", review.canonical_graph_hash), + ("Provenance", review.provenance.source), + ], + ), + Section( + heading="Findings", + paragraphs=[] if review.findings else ["No evidence-backed findings were identified."], + ), ] for finding in review.findings: @@ -54,24 +76,19 @@ def build_review_document(review: EngineeringReviewResponse) -> ReportDocument: heading=f"{finding.severity.upper()}: {finding.title}", level=3, fields=[ - ("Category", finding.category.replace("-", " ")), - ("Effort", finding.estimated_effort), - ("Problem", finding.problem), - ("Impact", finding.impact), - ("Recommendation", finding.recommendation), + ("Category", finding.category.replace("_", " ")), + ("Diagnostic", finding.diagnostic_code), + ("Rule", finding.rule_id), + ("Explanation", finding.explanation), + ("Remediation guidance", finding.remediation_guidance), + ("Extractor", f"{finding.extractor_name}@{finding.extractor_version}"), + ("Support", finding.support_status), ], - bullets=[f"Affected file: {path}" for path in finding.affected_files] - + [f"Affected module: {module}" for module in finding.affected_modules], - ) - ) - - if review.roadmap: - sections.append( - Section( - heading="Improvement Roadmap", bullets=[ - f"{index + 1}. {step.title} ({step.estimated_effort}) — {step.description}" - for index, step in enumerate(review.roadmap) + f"Evidence: {finding.path}:{finding.start_line}-{finding.end_line}", + f"Fact: {finding.fact_id}", + f"Evidence ID: {finding.evidence_id}", + f"Snapshot: {finding.snapshot_id}", ], ) ) @@ -104,10 +121,7 @@ def build_architecture_document(architecture: ArchitectureResponse) -> ReportDoc heading="Layers", table=Table( headers=["Layer", "Order", "Components"], - rows=[ - [layer.name, str(layer.order), str(len(layer.nodes))] - for layer in architecture.detected_layers - ], + rows=[[layer.name, str(layer.order), str(len(layer.nodes))] for layer in architecture.detected_layers], ), ), Section( @@ -130,7 +144,6 @@ def build_architecture_document(architecture: ArchitectureResponse) -> ReportDoc fields=[ ("Type", node.type.replace("-", " ")), ("Layer", node.layer.replace("-", " ")), - ("Size Class", node.estimated_complexity), ], bullets=[f"File: {path}" for path in node.files[:10]], ) @@ -175,6 +188,14 @@ def build_dependencies_document(dependencies: DependencyGraphResponse, repositor ["Peer", str(counts["peer"])], ["Optional", str(counts["optional"])], ["Relationships", str(len(dependencies.edges))], + [ + "Vulnerability assessment", + dependencies.vulnerability_assessment.status.replace("_", " ").capitalize(), + ], + [ + "Outdated-version assessment", + dependencies.outdated_assessment.status.replace("_", " ").capitalize(), + ], ], ), ), @@ -183,10 +204,7 @@ def build_dependencies_document(dependencies: DependencyGraphResponse, repositor paragraphs=[] if dependencies.nodes else ["No dependencies were detected."], table=Table( headers=["Name", "Version", "Type"], - rows=[ - [node.name, node.version or "unknown", node.type] - for node in dependencies.nodes - ], + rows=[[node.name, node.version or "unknown", node.type] for node in dependencies.nodes], ) if dependencies.nodes else None, diff --git a/apps/backend/app/reports/export_service.py b/apps/backend/app/reports/export_service.py index dde25fc6..a4d409b9 100644 --- a/apps/backend/app/reports/export_service.py +++ b/apps/backend/app/reports/export_service.py @@ -1,8 +1,8 @@ """Turn existing analysis reports into downloadable JSON / Markdown / HTML / PDF. The service only consumes data from the existing analysis and documentation -builders (which read the Repository Intelligence Engine); it never re-analyses a -repository. Content is returned inline so the API stays JSON and is testable: +builders (which read the current revision's sealed ``ri.v1`` snapshot); it +never re-analyses a repository. Content is returned inline so the API stays JSON and is testable: text formats as UTF-8, PDF as base64. """ @@ -75,7 +75,9 @@ def _build_document(self, request: ExportRequest) -> ReportDocument: return build_architecture_document(self.analysis.architecture_model(request.repository_id)) if request.target == "dependencies": graph = self.analysis.dependency_graph(request.repository_id) - record = self.analysis.repository.get(request.repository_id) + # Owner-scoped lookup for the report title; dependency_graph above + # already resolved (and thus authorised) the repository for this user. + record = self.analysis.repository.get_for_owner(request.repository_id, self.analysis.owner_id) return build_dependencies_document(graph, record.name if record else request.repository_id) return self.documentation.build_document(request.repository_id) diff --git a/apps/backend/app/reports/renderers.py b/apps/backend/app/reports/renderers.py index 0df4379d..904b664c 100644 --- a/apps/backend/app/reports/renderers.py +++ b/apps/backend/app/reports/renderers.py @@ -86,8 +86,7 @@ def _render_section_html(section: Section) -> str: if section.table: head = "".join(f"" for header in section.table.headers) body = "".join( - "" + "".join(f"" for cell in row) + "" - for row in section.table.rows + "" + "".join(f"" for cell in row) + "" for row in section.table.rows ) parts.append(f"
{escape(header)}
{escape(cell)}
{escape(cell)}
{head}{body}
") if section.bullets: diff --git a/apps/backend/app/repositories/ai_conversation_repository.py b/apps/backend/app/repositories/ai_conversation_repository.py new file mode 100644 index 00000000..6861e12b --- /dev/null +++ b/apps/backend/app/repositories/ai_conversation_repository.py @@ -0,0 +1,99 @@ +"""Owner- and repository-scoped persistence for AI Workspace conversation turns. + +Every read and write here is scoped to both ``owner_id`` and ``repository_id``, +mirroring ``RepositoryRepository``'s owner-scoped accessors: a thread survives +navigation but a caller can never read or extend another owner's conversation. +""" + +from datetime import datetime +from uuid import uuid4 + +from sqlalchemy import func, select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from app.models.ai_conversation import AiConversationMessageRecord +from app.schemas.ai import AiCitation, AiMessage + +# (role, content, citations, created_at) — one turn awaiting a sequence number. +AiConversationTurn = tuple[str, str, list[dict] | None, datetime] + +# Bound the retries for sequence collisions. Concurrent allocations are rare in +# practice (one user, one thread), so a small cap is plenty and prevents an +# infinite loop if something else is wrong. +_MAX_SEQUENCE_RETRIES = 5 + + +class AiConversationRepository: + def __init__(self, db: Session) -> None: + self.db = db + + def list_conversation(self, repository_id: str, owner_id: str) -> list[AiMessage]: + statement = ( + select(AiConversationMessageRecord) + .where( + AiConversationMessageRecord.repository_id == repository_id, + AiConversationMessageRecord.owner_id == owner_id, + ) + .order_by(AiConversationMessageRecord.sequence) + ) + turns = self.db.scalars(statement).all() + return [ + AiMessage( + role=turn.role, # type: ignore[arg-type] + content=turn.content, + timestamp=turn.created_at, + citations=[AiCitation(**citation) for citation in turn.citations] if turn.citations else None, + ) + for turn in turns + ] + + def append_turns(self, repository_id: str, owner_id: str, turns: list[AiConversationTurn]) -> None: + """Persist ordered turns in one commit. + + Called with both the user turn and its assistant reply together, so a + query can never leave a user turn stored without its answer, or + vice versa. + + The ``(owner_id, repository_id, sequence)`` uniqueness constraint is + the concurrency guard: if two requests allocate the same next sequence + simultaneously, one of them hits IntegrityError. We retry the whole + allocation a bounded number of times so the collision resolves instead + of silently interleaving pairs or 500-ing. + """ + + last_error: Exception | None = None + for _ in range(_MAX_SEQUENCE_RETRIES): + try: + self._append_turns_once(repository_id, owner_id, turns) + return + except IntegrityError as exc: + last_error = exc + self.db.rollback() + assert last_error is not None + raise last_error + + def _append_turns_once(self, repository_id: str, owner_id: str, turns: list[AiConversationTurn]) -> None: + next_sequence = self._next_sequence(repository_id, owner_id) + for offset, (role, content, citations, created_at) in enumerate(turns): + self.db.add( + AiConversationMessageRecord( + id=str(uuid4()), + owner_id=owner_id, + repository_id=repository_id, + sequence=next_sequence + offset, + role=role, + content=content, + citations=citations, + created_at=created_at, + ) + ) + self.db.commit() + + def _next_sequence(self, repository_id: str, owner_id: str) -> int: + statement = select(func.max(AiConversationMessageRecord.sequence)).where( + AiConversationMessageRecord.repository_id == repository_id, + AiConversationMessageRecord.owner_id == owner_id, + ) + current_max = self.db.scalar(statement) + return 0 if current_max is None else current_max + 1 diff --git a/apps/backend/app/repositories/repository_repository.py b/apps/backend/app/repositories/repository_repository.py index e12d156e..31f0849a 100644 --- a/apps/backend/app/repositories/repository_repository.py +++ b/apps/backend/app/repositories/repository_repository.py @@ -1,28 +1,83 @@ -from sqlalchemy import select +from datetime import UTC, datetime +from uuid import uuid4 + +from sqlalchemy import select, update +from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session from app.models.repository import RepositoryRecord +from app.models.repository_lineage import RepositoryLineage + +# Bound retries for two distinct races (#299, RFC-0002 §5.2): two importers +# racing to create the *first* lineage for a canonical key (one loses the +# unique index and must reload/retry), and a lineage-scoped duplicate-commit +# rollback. Five matches AiConversationRepository's own sequence-collision cap. +_MAX_LINEAGE_RETRIES = 5 + + +class LineageDuplicateRevision(Exception): + """The transactional insert found this exact commit already in the lineage. + + Carries the existing record so the caller can build the same 409 detail + (`repositoryId`, `name`) the pre-clone fast-path check already returns, + without a second query. + """ + + def __init__(self, existing: RepositoryRecord) -> None: + self.existing = existing + super().__init__("Repository revision already exists in this lineage.") class RepositoryRepository: def __init__(self, db: Session) -> None: self.db = db - def list(self) -> list[RepositoryRecord]: - statement = select(RepositoryRecord).order_by(RepositoryRecord.uploaded_at.desc()) + # Owner-scoped access only. Every repository lookup a request can reach goes + # through one of these so a caller can only ever see its own repositories, + # and ownership can't be forgotten by a new service: the unscoped variants + # were removed in E1.3 (#63) once analysis/ai/documentation/export were + # threaded onto the current user. Add owner-scoped accessors here, not + # unscoped ones. + def list_for_owner(self, owner_id: str) -> list[RepositoryRecord]: + statement = ( + select(RepositoryRecord) + .where(RepositoryRecord.owner_id == owner_id) + .order_by(RepositoryRecord.uploaded_at.desc()) + ) return list(self.db.scalars(statement).all()) - def get(self, repository_id: str) -> RepositoryRecord | None: - return self.db.get(RepositoryRecord, repository_id) + def get_for_owner(self, repository_id: str, owner_id: str) -> RepositoryRecord | None: + record = self.db.get(RepositoryRecord, repository_id) + if record is None or record.owner_id != owner_id: + return None + return record - def find_by_name(self, name: str) -> RepositoryRecord | None: - statement = select(RepositoryRecord).where(RepositoryRecord.name == name) - return self.db.scalars(statement).first() + def get_lineage_for_owner(self, lineage_id: str, owner_id: str) -> RepositoryLineage | None: + return self.db.execute( + select(RepositoryLineage).where(RepositoryLineage.id == lineage_id, RepositoryLineage.owner_id == owner_id) + ).scalar_one_or_none() - def find_by_source(self, source_url: str, branch: str | None) -> RepositoryRecord | None: + def list_lineage_members(self, lineage_id: str, owner_id: str) -> list[RepositoryRecord]: + """Every repository in `lineage_id`, most recently imported first. + + Owner-scoped on both columns, matching every other accessor here -- + `lineage_id` alone would already be unambiguous (a lineage belongs to + exactly one owner), but this stays defensive against a caller passing + a lineage id it never actually verified against the requesting owner. + """ + + statement = ( + select(RepositoryRecord) + .where(RepositoryRecord.lineage_id == lineage_id, RepositoryRecord.owner_id == owner_id) + .order_by(RepositoryRecord.sequence.desc()) + ) + return list(self.db.scalars(statement).all()) + + def find_by_revision_for_owner(self, revision_value: str, owner_id: str) -> RepositoryRecord | None: + """Find an owner's upload already imported at this exact content hash.""" statement = select(RepositoryRecord).where( - RepositoryRecord.source_url == source_url, - RepositoryRecord.branch == branch, + RepositoryRecord.revision_value == revision_value, + RepositoryRecord.owner_id == owner_id, ) return self.db.scalars(statement).first() @@ -41,3 +96,146 @@ def save(self, record: RepositoryRecord) -> RepositoryRecord: def delete(self, record: RepositoryRecord) -> None: self.db.delete(record) self.db.commit() + + def delete_with_lineage_update(self, record: RepositoryRecord) -> None: + """Delete `record`, rolling its lineage's latest pointer back first if needed. + + A standalone record (``lineage_id is None``) deletes exactly as + ``delete()`` does. A lineaged record's deletion and any resulting + latest-pointer change happen in one transaction (#299 §8.3): the + counter never decreases, and an empty lineage is kept (null latest, + preserved ``next_sequence``) rather than removed. + """ + if record.lineage_id is not None: + lineage = self.db.execute( + select(RepositoryLineage).where(RepositoryLineage.id == record.lineage_id).with_for_update() + ).scalar_one() + if lineage.latest_repository_id == record.id: + next_latest = self.db.scalars( + select(RepositoryRecord.id) + .where( + RepositoryRecord.lineage_id == record.lineage_id, + RepositoryRecord.id != record.id, + ) + .order_by(RepositoryRecord.sequence.desc()) + .limit(1) + ).first() + lineage.latest_repository_id = next_latest + self.db.delete(record) + self.db.commit() + + def add_with_lineage( + self, + record: RepositoryRecord, + *, + owner_id: str, + canonical_source_key: str | None, + canonical_branch: str | None, + display_name: str, + ) -> RepositoryRecord: + """Insert `record`, allocating it into an owner-scoped lineage keyed by + the given canonical pair (#299, RFC-0002 §5.2). A null pair inserts a + standalone import with no lineage, identical to plain ``add()`` -- + uploads and unresolved-ref imports always take this branch. + + Lineage find-or-create, sequence allocation, the same-lineage + duplicate check, the repository insert, and the latest-pointer update + all happen in one transaction, so a partial allocation can never be + observed or committed. + + Raises ``LineageDuplicateRevision`` (not retried) if this exact commit + already exists in the target lineage. + """ + if canonical_source_key is None: + return self.add(record) + + last_error: IntegrityError | None = None + for _ in range(_MAX_LINEAGE_RETRIES): + try: + return self._add_with_lineage_once( + record, + owner_id=owner_id, + canonical_source_key=canonical_source_key, + canonical_branch=canonical_branch, + display_name=display_name, + ) + except IntegrityError as exc: + last_error = exc + self.db.rollback() + assert last_error is not None + raise last_error + + def _find_lineage( + self, owner_id: str, canonical_source_key: str, canonical_branch: str | None + ) -> RepositoryLineage | None: + statement = select(RepositoryLineage).where( + RepositoryLineage.owner_id == owner_id, + RepositoryLineage.canonical_source_key == canonical_source_key, + RepositoryLineage.canonical_branch == canonical_branch, + ) + return self.db.scalars(statement).first() + + def _add_with_lineage_once( + self, + record: RepositoryRecord, + *, + owner_id: str, + canonical_source_key: str, + canonical_branch: str | None, + display_name: str, + ) -> RepositoryRecord: + lineage = self._find_lineage(owner_id, canonical_source_key, canonical_branch) + if lineage is None: + lineage = RepositoryLineage( + id=str(uuid4()), + owner_id=owner_id, + canonical_source_key=canonical_source_key, + canonical_branch=canonical_branch, + display_name=display_name, + latest_repository_id=None, + next_sequence=1, + created_at=datetime.now(UTC), + ) + self.db.add(lineage) + # Flush (not commit) so a racing winner's canonical unique index + # raises IntegrityError here, before this transaction allocates a + # sequence for a lineage that turns out not to be the real one. + self.db.flush() + + # Atomic, race-free allocation (RFC §5.1): the write lock this UPDATE + # takes is held through commit on both dialects, so two concurrent + # allocations against the same lineage always serialize here rather + # than both reading the same `next_sequence`. + result = self.db.execute( + update(RepositoryLineage) + .where(RepositoryLineage.id == lineage.id, RepositoryLineage.owner_id == owner_id) + .values(next_sequence=RepositoryLineage.next_sequence + 1) + ) + if result.rowcount != 1: # type: ignore[attr-defined] + # Unreachable except as a defensive guard: the lineage was just + # selected or inserted in this same transaction and cannot vanish + # underneath it (deletion of an in-flight, uncommitted lineage is + # not possible from another connection). + raise RuntimeError("Repository lineage row vanished during sequence allocation.") + self.db.refresh(lineage) + allocated_sequence = lineage.next_sequence - 1 + + existing = self.db.scalars( + select(RepositoryRecord).where( + RepositoryRecord.lineage_id == lineage.id, + RepositoryRecord.revision_value == record.revision_value, + ) + ).first() + if existing is not None: + self.db.rollback() + raise LineageDuplicateRevision(existing) + + record.lineage_id = lineage.id + record.sequence = allocated_sequence + self.db.add(record) + self.db.flush() + + lineage.latest_repository_id = record.id + self.db.commit() + self.db.refresh(record) + return record diff --git a/apps/backend/app/review/import_dispositions.py b/apps/backend/app/review/import_dispositions.py new file mode 100644 index 00000000..d1237e85 --- /dev/null +++ b/apps/backend/app/review/import_dispositions.py @@ -0,0 +1,128 @@ +"""Disposition for unresolved ``import``-kind ``RI-RES-UNRESOLVED`` diagnostics. + +Importing a standard-library module, or a package the repository actually +declares as a dependency, is normal code — not a gap worth a review finding. +The resolver correctly leaves it unresolved either way, because it isn't +part of the *scanned* repository and there is nothing in-repo to point an +edge at; that stays true, honest data. This module decides, at review time +only, whether that unresolved fact should be *promoted* to a finding: never +whether it resolves. A relative import, or a bare specifier that is neither +stdlib/builtin nor a declared dependency, still looks like it should be a +same-repo reference and stays a finding — that is the genuine case (a typo, +a missing dependency declaration, or a real extractor gap). + +Mirrors the builtin-call-noise fix at the extraction layer: same "this +identifier plainly belongs to the language/platform, not to this repository" +judgment, applied to import specifiers instead of bare calls. +""" + +from __future__ import annotations + +import sys + +from app.extraction.naming import dependency_stable_key, package_root + +#: The running interpreter's own standard library, used as the reference set +#: the same way extraction/python.py's builtin-call skip uses ``dir(builtins)`` +#: -- an approximation of "the language/platform," not a per-repo Python +#: version lookup. Available from Python 3.10. +PYTHON_STDLIB_MODULES: frozenset[str] = frozenset(sys.stdlib_module_names) + +#: Node.js builtin modules (https://nodejs.org/api/), importable bare or with +#: an explicit ``node:`` prefix. Curated the same way extraction/typescript.py +#: curates ``_GLOBAL_CALL_NAMES`` for globals -- a bounded, documented list, +#: not a runtime introspection (there is no Node runtime to introspect here). +NODE_BUILTIN_MODULES: frozenset[str] = frozenset( + { + "assert", + "async_hooks", + "buffer", + "child_process", + "cluster", + "console", + "constants", + "crypto", + "dgram", + "diagnostics_channel", + "dns", + "domain", + "events", + "fs", + "http", + "http2", + "https", + "inspector", + "module", + "net", + "os", + "path", + "perf_hooks", + "process", + "punycode", + "querystring", + "readline", + "repl", + "stream", + "string_decoder", + "test", + "timers", + "tls", + "trace_events", + "tty", + "url", + "util", + "v8", + "vm", + "wasi", + "worker_threads", + "zlib", + } +) + + +def is_platform_import(specifier: str, source_path: str) -> bool: + """True if ``specifier``'s package root is stdlib (Python) or a Node + builtin (TypeScript/JavaScript) -- language/platform, never this repo.""" + + if specifier.startswith("."): + return False + root = package_root(specifier, source_path) + if not root: + return False + if source_path.endswith(".py"): + return root in PYTHON_STDLIB_MODULES + return root in NODE_BUILTIN_MODULES or root.removeprefix("node:") in NODE_BUILTIN_MODULES + + +def declared_dependency_key(specifier: str, source_path: str) -> str | None: + """The dependency stable key ``specifier`` would need to match against a + declared, sealed-snapshot dependency node -- or ``None`` if it can never + be an external dependency, which must stay eligible to be a genuine + finding: a relative import (``package_root`` reduces one to ``""`` for + Python, but only to ``"."``/``".."`` for JS/TS -- checked explicitly + here rather than relying on no real npm package ever being named that), + or a specifier with no package root at all. + """ + + if specifier.startswith("."): + return None + root = package_root(specifier, source_path) + if not root: + return None + ecosystem = "pypi" if source_path.endswith(".py") else "npm" + return dependency_stable_key(ecosystem, root) + + +def is_recognized_external_import( + specifier: str, + source_path: str, + declared_dependency_keys: frozenset[str], +) -> bool: + """True if this unresolved import's target is a recognized external + dependency (stdlib/builtin, or declared in the repo's own manifest) -- + the case that should never surface as a finding.""" + + if is_platform_import(specifier, source_path): + return True + key = declared_dependency_key(specifier, source_path) + return key is not None and key in declared_dependency_keys diff --git a/apps/backend/app/review/review_service.py b/apps/backend/app/review/review_service.py index d5438232..70812c98 100644 --- a/apps/backend/app/review/review_service.py +++ b/apps/backend/app/review/review_service.py @@ -1,249 +1,610 @@ -from datetime import UTC, datetime +"""Deterministic, revision-bound Engineering Review generation (#154). -from app.intelligence.engine import RepositoryIntelligenceEngine +The legacy implementation subtracted arbitrary severity costs from 100 and +generated generic recommendations from mutable repository metadata. This +builder deliberately has no dependency on a mutable compatibility read model. +It emits only sealed ``ri.v1`` diagnostics that have an authentic supporting +evidence span in the same snapshot. +""" + +from __future__ import annotations + +import hashlib +from collections import Counter, defaultdict +from dataclasses import dataclass + +from sqlalchemy import func, select + +from app.analysis.manifest import build_manifest, manifest_digest +from app.intelligence.canonical import canonical_json_bytes +from app.intelligence.query_service import SnapshotQueryService, batched_ids from app.models.repository import RepositoryRecord -from app.schemas.review import EngineeringReviewResponse, ImprovementStep, ReviewFinding, ReviewScore, ReviewSummary +from app.models.snapshot import RiDiagnostic, RiEvidence, RiNode, RiObservation +from app.review.import_dispositions import is_recognized_external_import +from app.schemas.review import ( + AssessmentState, + EngineeringReviewResponse, + ReviewCategoryAssessment, + ReviewCategoryId, + ReviewEvidenceReference, + ReviewFinding, + ReviewPagination, + ReviewProvenance, + ReviewSeverity, + ReviewSeverityCounts, + ReviewSummary, + ReviewSupportStatus, +) + +# Stored extractor severity -> product finding severity. This is the complete, +# documented mapping; no language model or score calculation is involved. +DIAGNOSTIC_SEVERITY_MAPPING: dict[str, ReviewSeverity] = { + "fatal": "critical", + "error": "high", + "warning": "medium", + "info": "info", +} + +_RULES: dict[str, tuple[ReviewCategoryId, str, str]] = { + "RI-RES-UNRESOLVED": ( + "relationship_resolution", + "Unresolved relationship", + "Inspect the referenced name at this source span and make its target explicit or add extractor support for the construct.", + ), + "RI-RES-AMBIGUOUS": ( + "relationship_resolution", + "Ambiguous relationship", + "Disambiguate the referenced name at this source span so it resolves to one target.", + ), + "RI-EXT-UNSUPPORTED": ( + "source_extraction", + "Unsupported source construct", + "Rewrite the recorded construct into a supported form or extend the named extractor before relying on it for repository relationships.", + ), + "RI-SRC-MALFORMED": ( + "source_extraction", + "Malformed source", + "Store this source as valid UTF-8 before attempting line-addressed semantic extraction.", + ), + "RI-LIMIT-SKIP": ( + "source_extraction", + "Source excluded by extraction limit", + "Reduce the file below the configured extraction limit or raise the documented limit and rerun analysis.", + ), +} + +#: Codes whose category is source extraction, used to state that category's +#: assessment from extraction evidence rather than from unrelated diagnostics. +_SOURCE_EXTRACTION_CODES = frozenset(code for code, rule in _RULES.items() if rule[0] == "source_extraction") + +_CATEGORY_LABELS: dict[ReviewCategoryId, str] = { + "architecture_boundaries": "Architecture and boundaries", + "relationship_resolution": "Relationship resolution", + "source_extraction": "Source extraction", + "dependency_declarations": "Dependency declarations", + "security_vulnerability_scanning": "Security vulnerability scanning", + "authentication_evidence": "Authentication evidence", + "repository_structure": "Repository structure", + "analysis_integrity": "Analysis integrity", +} + + +@dataclass(frozen=True) +class _SupportedEvidence: + fact_id: str + evidence: RiEvidence + #: ``supported`` means the evidence span is the diagnostic's own recorded + #: span. ``file_scoped`` means the diagnostic named a file but no span, so + #: the finding is honestly scoped to the whole file and says so. + support_status: ReviewSupportStatus -LARGE_FILE_BYTES = 40_000 -LARGE_SOURCE_SURFACE = 300 -SEVERITY_PRIORITY = {"critical": 1, "high": 2, "medium": 3, "low": 4} -SEVERITY_RANK = {"critical": 0, "high": 1, "medium": 2, "low": 3} -EFFORT_BY_SEVERITY = {"critical": "1 sprint", "high": "1 sprint", "medium": "a few days", "low": "a few hours"} + +def _stable_id(prefix: str, payload: dict[str, object]) -> str: + digest = hashlib.sha256(canonical_json_bytes(payload)).hexdigest() + return f"{prefix}:{digest}" + + +def _overall_assessment_status(states: Counter[AssessmentState]) -> AssessmentState: + """Summarise the category states instead of asserting a fixed status. + + A constant here would claim a coverage level the categories may not support, + which is the same class of unearned claim the v2 contract removed. + """ + + if states["assessed"] and not ( + states["partially_assessed"] or states["not_assessed"] or states["insufficient_evidence"] + ): + return "assessed" + if states["assessed"] or states["partially_assessed"]: + return "partially_assessed" + if states["insufficient_evidence"]: + return "insufficient_evidence" + return "not_assessed" class EngineeringReviewBuilder: - def __init__(self, intelligence: RepositoryIntelligenceEngine | None = None) -> None: - self.intelligence = intelligence or RepositoryIntelligenceEngine() - - def build(self, record: RepositoryRecord) -> EngineeringReviewResponse: - repository_intelligence = self.intelligence.from_record(record) - findings = self._findings(repository_intelligence) - scores = self._scores(findings) - summary = self._summary(findings, scores) - roadmap = self._roadmap(findings) - return EngineeringReviewResponse( - repository_id=record.id, - repository_name=record.name, - generated_at=datetime.now(UTC), - summary=summary, - scores=scores, - findings=findings, - roadmap=roadmap, - ) + """Build the public review solely from an owner-scoped sealed snapshot.""" - def _findings(self, intelligence) -> list[ReviewFinding]: - discovery = intelligence.discovery - statistics = discovery.statistics - files = intelligence.files - findings: list[ReviewFinding] = [] + def __init__(self, snapshots: SnapshotQueryService) -> None: + self.snapshots = snapshots - if not intelligence.metadata.has_readme: - findings.append( - self._finding( - "doc-no-readme", - "Missing README", - "documentation", - "high", - problem=f"No README file was detected among {statistics.documentation_files} documentation file(s).", - impact="Contributors and reviewers lack setup, architecture, and usage guidance, which slows onboarding and increases misuse.", - recommendation="Add a README covering setup, architecture, and contribution guidance.", - affected_files=[], - affected_modules=["documentation"], + def build( + self, + record: RepositoryRecord, + *, + category: ReviewCategoryId | None = None, + severity: ReviewSeverity | None = None, + diagnostic_code: str | None = None, + offset: int | None = None, + limit: int | None = None, + ) -> EngineeringReviewResponse: + snapshot = self.snapshots.require_sealed_snapshot_for_current_revision(record.id) + + # Only diagnostics with a rule can become findings, so only those are + # hydrated. Diagnostics without a rule are counted in SQL instead: a + # snapshot can hold far more diagnostic rows than a review will ever + # publish, and loading all of them to discard most was unbounded. + diagnostics = list( + self.snapshots.db.scalars( + select(RiDiagnostic) + .where( + RiDiagnostic.snapshot_id == snapshot.snapshot_id, + RiDiagnostic.code.in_(_RULES), ) - ) - if not intelligence.metadata.has_license: - findings.append( - self._finding( - "doc-no-license", - "Missing License", - "documentation", - "medium", - problem="No license file or explicit license declaration was detected.", - impact="Without a declared license the code's usage and distribution rights are ambiguous, blocking adoption and reuse.", - recommendation="Add an explicit open-source license or a proprietary notice.", - affected_files=[], - affected_modules=["documentation"], + .order_by( + RiDiagnostic.code, + RiDiagnostic.path, + RiDiagnostic.span_start_line, + RiDiagnostic.span_end_line, + RiDiagnostic.producer, + RiDiagnostic.message, + RiDiagnostic.id, ) - ) - if statistics.test_files == 0: - findings.append( - self._finding( - "test-missing", - "No Tests Detected", - "testing", - "high", - problem=f"No test files were detected among {statistics.source_files} source file(s).", - impact="Changes cannot be validated automatically, so regressions can reach production undetected.", - recommendation="Add automated unit and integration tests for critical paths.", - affected_files=[], - affected_modules=["tests"], + ).all() + ) + omitted_without_rule = ( + self.snapshots.db.scalar( + select(func.count(RiDiagnostic.id)).where( + RiDiagnostic.snapshot_id == snapshot.snapshot_id, + RiDiagnostic.code.not_in(_RULES), ) ) - if discovery.environment_files: - findings.append( - self._finding( - "env-file-present", - "Environment File Present", - "security", - "critical", - problem=f"Committed environment file(s) that may contain secrets: {', '.join(discovery.environment_files[:5])}.", - impact="Secrets committed to version control can be leaked and abused, and remain recoverable from history.", - recommendation="Remove committed secret-bearing environment files, rotate exposed secrets, and ignore them going forward.", - affected_files=discovery.environment_files, - affected_modules=["configuration"], - ) + or 0 + ) + evidence_by_fact = self._evidence_by_fact(snapshot.snapshot_id, diagnostics) + import_specifiers = self._unresolved_import_specifiers(snapshot.snapshot_id, diagnostics) + declared_dependency_keys = ( + self._declared_dependency_keys(snapshot.snapshot_id) if import_specifiers else frozenset[str]() + ) + provenance = ReviewProvenance( + snapshot_id=snapshot.snapshot_id, + snapshot_schema_version=snapshot.schema_version, + canonical_graph_hash=snapshot.canonical_graph_hash, + ) + + findings: list[ReviewFinding] = [] + omitted = omitted_without_rule + for diagnostic in diagnostics: + rule = _RULES.get(diagnostic.code) + supported = self._support_for(diagnostic, evidence_by_fact) + if rule is None or supported is None: + omitted += 1 + continue + if self._is_suppressed_import(diagnostic, import_specifiers, declared_dependency_keys): + omitted += 1 + continue + category, title, remediation = rule + evidence = supported.evidence + evidence_id = _stable_id( + "evidence", + { + "snapshotId": snapshot.snapshot_id, + "factId": supported.fact_id, + "path": evidence.path, + "startLine": evidence.start_line, + "endLine": evidence.end_line, + "extractor": evidence.extractor, + "extractorVersion": evidence.extractor_version, + }, + ) + finding_id = _stable_id( + "finding", + { + "snapshotId": snapshot.snapshot_id, + "code": diagnostic.code, + "producer": diagnostic.producer, + "message": diagnostic.message, + "factId": supported.fact_id, + "evidenceId": evidence_id, + }, ) - if statistics.source_files > LARGE_SOURCE_SURFACE: findings.append( - self._finding( - "large-codebase", - "Large Source Surface", - "architecture", - "medium", - problem=f"The repository has {statistics.source_files} source files, a large surface to keep coherent.", - impact="A large undivided surface makes ownership, change impact, and public interfaces hard to reason about.", - recommendation="Define module boundaries and public interfaces to contain change impact.", - affected_files=[file.path for file in files[:25]], + ReviewFinding( + id=finding_id, + category=category, + severity=DIAGNOSTIC_SEVERITY_MAPPING[diagnostic.severity], + title=title, + explanation=diagnostic.message, + path=evidence.path, + start_line=evidence.start_line, + end_line=evidence.end_line, + snapshot_id=snapshot.snapshot_id, + fact_id=supported.fact_id, + evidence_id=evidence_id, + extractor_name=evidence.extractor, + extractor_version=evidence.extractor_version, + diagnostic_code=diagnostic.code, + rule_id=f"engineering-review.v2/{diagnostic.code}", + remediation_guidance=remediation, + support_status=supported.support_status, + provenance=provenance, + evidence=ReviewEvidenceReference( + evidence_id=evidence_id, + snapshot_id=snapshot.snapshot_id, + fact_id=supported.fact_id, + path=evidence.path, + start_line=evidence.start_line, + end_line=evidence.end_line, + extractor_name=evidence.extractor, + extractor_version=evidence.extractor_version, + ), ) ) - large_files = sorted( - (file for file in files if file.role != "documentation" and file.size > LARGE_FILE_BYTES), - key=lambda file: file.size, - reverse=True, + + severity_rank = {"critical": 0, "high": 1, "medium": 2, "low": 3, "info": 4} + findings.sort( + key=lambda item: ( + severity_rank[item.severity], + item.category, + item.path, + item.start_line, + item.diagnostic_code, + item.id, + ) ) - if large_files: - findings.append( - self._finding( - "large-files", - "Oversized Source Files", - "maintainability", - "medium", - problem=f"{len(large_files)} file(s) exceed {LARGE_FILE_BYTES // 1000} KB, a sign of low cohesion or god-files.", - impact="Oversized files are harder to review, test, and refactor safely, concentrating risk in a few places.", - recommendation="Split large files along clear responsibilities and extract cohesive units.", - affected_files=[file.path for file in large_files[:10]], - ) + + # Assessment matrix, severity chips, and the summary message always + # describe the whole sealed snapshot -- filtering/pagination below + # narrows only which findings are returned in this response page, the + # same split the frontend's own client-side filters already made. + categories = self._categories(snapshot.snapshot_id, findings, diagnostics) + states = Counter(category.state for category in categories) + severities = Counter(finding.severity for finding in findings) + count = len(findings) + file_scoped = sum(1 for finding in findings if finding.support_status == "file_scoped") + message = ( + f"{count} evidence-backed finding{' was' if count == 1 else 's were'} identified in this revision. " + "Security vulnerability scanning was not performed." + ) + manifest = build_manifest(snapshot) + + matched = [ + finding + for finding in findings + if (category is None or finding.category == category) + and (severity is None or finding.severity == severity) + and (diagnostic_code is None or finding.diagnostic_code == diagnostic_code) + ] + total_matched = len(matched) + # offset/limit are None only for the internal (non-API) callers that + # need the complete matched set in one call -- the PDF/JSON export + # path (#154), and the SQL-batching test that calls _evidence_by_fact + # directly. Every HTTP request supplies both explicitly (see the + # /analysis/{repository_id}/review route), so a real client always + # gets a bounded page. + page_offset = 0 if offset is None else offset + page_limit = total_matched if limit is None else limit + page_findings = matched[page_offset : page_offset + page_limit] + pagination = ReviewPagination(offset=page_offset, limit=page_limit, total=total_matched) + return EngineeringReviewResponse( + repository_id=record.id, + repository_name=record.name, + revision_kind=snapshot.revision_kind, # type: ignore[arg-type] + revision_value=snapshot.revision_value, + snapshot_id=snapshot.snapshot_id, + snapshot_schema_version=snapshot.schema_version, + canonical_graph_hash=snapshot.canonical_graph_hash, + manifest_digest=manifest_digest(manifest), + provenance=provenance, + generated_at=snapshot.sealed_at, + assessment_status=_overall_assessment_status(states), + categories=categories, + findings=page_findings, + pagination=pagination, + summary=ReviewSummary( + message=message, + findings_by_severity=ReviewSeverityCounts( + info=severities["info"], + low=severities["low"], + medium=severities["medium"], + high=severities["high"], + critical=severities["critical"], + ), + assessed_categories=states["assessed"], + partially_assessed_categories=states["partially_assessed"], + not_assessed_categories=states["not_assessed"], + insufficient_evidence_categories=states["insufficient_evidence"], + evidence_backed_finding_count=count, + file_scoped_finding_count=file_scoped, + omitted_unsupported_diagnostic_count=omitted, + ), + ) + + def _evidence_by_fact( + self, + snapshot_id: str, + diagnostics: list[RiDiagnostic], + ) -> dict[str, list[RiEvidence]]: + observation_ids = { + value + for diagnostic in diagnostics + for value in [(diagnostic.details or {}).get("observation_id")] + if isinstance(value, str) + } + node_keys = { + value + for diagnostic in diagnostics + for value in (diagnostic.subject_key, diagnostic.object_key) + if value is not None + } + node_keys.update(f"file:{diagnostic.path}" for diagnostic in diagnostics if diagnostic.path) + + # Every id set below grows with repository size, so each read is split + # into bounded batches. Binding them in one statement exceeded SQLite's + # per-statement parameter cap on large snapshots, turning a review into + # a 500 rather than a bounded set of queries. + observation_identity: dict[int, str] = {} + for batch in batched_ids(sorted(observation_ids)): + observation_identity.update( + { + item.id: item.observation_id + for item in self.snapshots.db.scalars( + select(RiObservation).where( + RiObservation.snapshot_id == snapshot_id, + RiObservation.observation_id.in_(batch), + ) + ).all() + } ) - if not discovery.ci_files: - findings.append( - self._finding( - "ci-missing", - "No CI Workflow Detected", - "code-quality", - "medium", - problem="No continuous-integration workflow configuration was detected.", - impact="Build, lint, and test checks are not enforced on changes, letting regressions merge unnoticed.", - recommendation="Add CI to run build, lint, and test checks on pull requests.", - affected_files=[], - affected_modules=["configuration"], - ) + node_identity: dict[int, str] = {} + for batch in batched_ids(sorted(node_keys)): + node_identity.update( + { + item.id: item.stable_key + for item in self.snapshots.db.scalars( + select(RiNode).where( + RiNode.snapshot_id == snapshot_id, + RiNode.stable_key.in_(batch), + ) + ).all() + } ) - if not findings: - findings.append( - self._finding( - "baseline-review", - "Baseline Review Complete", - "maintainability", - "low", - problem="No blocking issues were detected by the current repository intelligence checks.", - impact="The repository meets the baseline checks; continued discipline keeps quality high.", - recommendation="Keep quality gates active as repository intelligence deepens.", - affected_files=[], + + if not observation_identity and not node_identity: + return {} + grouped: dict[str, list[RiEvidence]] = defaultdict(list) + for column, identity in ( + (RiEvidence.observation_ref, observation_identity), + (RiEvidence.node_ref, node_identity), + ): + for batch in batched_ids(sorted(identity)): + rows = self.snapshots.db.scalars( + select(RiEvidence) + .where(RiEvidence.snapshot_id == snapshot_id, column.in_(batch)) + .order_by( + RiEvidence.path, + RiEvidence.start_line, + RiEvidence.end_line, + RiEvidence.extractor, + RiEvidence.extractor_version, + RiEvidence.id, + ) + ).all() + for evidence in rows: + fact_id = identity.get( + evidence.observation_ref if column is RiEvidence.observation_ref else evidence.node_ref + ) + if fact_id is not None: + grouped[fact_id].append(evidence) + # Batching splits the ordered read, so restore the documented ordering + # per fact: support selection must not depend on batch boundaries. + for rows in grouped.values(): + rows.sort( + key=lambda evidence: ( + evidence.path, + evidence.start_line, + evidence.end_line, + evidence.extractor, + evidence.extractor_version, + evidence.id, ) ) - return findings + return dict(grouped) - def _finding( + def _unresolved_import_specifiers( self, - finding_id: str, - title: str, - category: str, - severity: str, - problem: str, - impact: str, - recommendation: str, - affected_files: list[str], - affected_modules: list[str] | None = None, - ) -> ReviewFinding: - return ReviewFinding( - id=finding_id, - title=title, - category=category, # type: ignore[arg-type] - severity=severity, # type: ignore[arg-type] - status="open", - problem=problem, - impact=impact, - recommendation=recommendation, - priority=SEVERITY_PRIORITY[severity], - estimated_effort="small" if severity in {"low", "medium"} else "medium", - affected_files=affected_files, - affected_modules=affected_modules or ["repository"], - tags=[category], - ) + snapshot_id: str, + diagnostics: list[RiDiagnostic], + ) -> dict[str, str]: + """``observation_id`` -> the import specifier it named, for every + unresolved ``import``-kind diagnostic only. + + Used solely to decide whether an unresolved import's target is a + recognized external dependency (never surfaced in a finding's own + text -- the diagnostic's message stays generic per RFC §13). + """ - def _scores(self, findings: list[ReviewFinding]) -> list[ReviewScore]: - categories = ["architecture", "security", "performance", "maintainability", "scalability", "code-quality", "documentation", "testing", "dependency-health", "configuration"] - severity_cost = {"critical": 35, "high": 20, "medium": 10, "low": 5} - scores: list[ReviewScore] = [] - for index, category in enumerate(categories, start=1): - category_findings = [finding for finding in findings if finding.category == category] - score = max(0, 100 - sum(severity_cost[finding.severity] for finding in category_findings)) - risk = "critical" if score < 40 else "high" if score < 65 else "medium" if score < 85 else "low" - scores.append( - ReviewScore( - category=category, # type: ignore[arg-type] - score=score, - trend="stable", - risk_level=risk, # type: ignore[arg-type] - priority=index, - findings_count=len(category_findings), + observation_ids = { + value + for diagnostic in diagnostics + if diagnostic.code == "RI-RES-UNRESOLVED" + for value in [(diagnostic.details or {}).get("observation_id")] + if isinstance(value, str) + } + if not observation_ids: + return {} + specifiers: dict[str, str] = {} + for batch in batched_ids(sorted(observation_ids)): + rows = self.snapshots.db.scalars( + select(RiObservation).where( + RiObservation.snapshot_id == snapshot_id, + RiObservation.observation_id.in_(batch), + RiObservation.observed_kind == "import", ) + ).all() + for row in rows: + if row.referent_text: + specifiers[row.observation_id] = row.referent_text + return specifiers + + def _declared_dependency_keys(self, snapshot_id: str) -> frozenset[str]: + return frozenset( + self.snapshots.db.scalars( + select(RiNode.stable_key).where( + RiNode.snapshot_id == snapshot_id, + RiNode.node_kind == "dependency", + ) + ).all() + ) + + @staticmethod + def _is_suppressed_import( + diagnostic: RiDiagnostic, + import_specifiers: dict[str, str], + declared_dependency_keys: frozenset[str], + ) -> bool: + """True only for an unresolved *import* whose target is stdlib/a + Node builtin, or a package the repository's own manifest declares. + + A relative import, or a bare specifier that matches neither, still + looks like a same-repo reference the resolver genuinely couldn't + find -- a real gap, not noise -- and this returns False for it. + """ + + if diagnostic.code != "RI-RES-UNRESOLVED" or not diagnostic.path: + return False + observation_id = (diagnostic.details or {}).get("observation_id") + specifier = import_specifiers.get(observation_id) if isinstance(observation_id, str) else None + if specifier is None: + return False + return is_recognized_external_import(specifier, diagnostic.path, declared_dependency_keys) + + @staticmethod + def _support_for( + diagnostic: RiDiagnostic, + evidence_by_fact: dict[str, list[RiEvidence]], + ) -> _SupportedEvidence | None: + """Find evidence that genuinely addresses this diagnostic, or nothing. + + A finding's ``path``/``startLine``/``endLine`` are presented to a user as + the location of the problem, so they may only come from evidence that + actually addresses the diagnostic: + + * A diagnostic that recorded a span is supported only by evidence at + exactly that path and span. + * A diagnostic that recorded a path but no span (``RI-SRC-MALFORMED`` + and ``RI-LIMIT-SKIP`` are file-level by construction) is supported + only by file-granularity evidence for that same path, and the result + is marked ``file_scoped`` so the whole-file span is never presented as + a line-addressed finding. + * A diagnostic with neither is unsupported. Borrowing whichever span + sorted first would fabricate a location, which is the exact failure + this contract exists to prevent. + """ + + observation_id = (diagnostic.details or {}).get("observation_id") + candidates = [ + value + for value in ( + observation_id if isinstance(observation_id, str) else None, + diagnostic.subject_key, + diagnostic.object_key, + f"file:{diagnostic.path}" if diagnostic.path else None, + ) + if value is not None + ] + if diagnostic.path is None: + return None + + has_span = diagnostic.span_start_line is not None + for fact_id in candidates: + for evidence in evidence_by_fact.get(fact_id, []): + if evidence.path != diagnostic.path: + continue + if has_span: + if ( + evidence.start_line != diagnostic.span_start_line + or evidence.end_line != diagnostic.span_end_line + ): + continue + return _SupportedEvidence(fact_id=fact_id, evidence=evidence, support_status="supported") + if evidence.granularity != "file": + continue + return _SupportedEvidence(fact_id=fact_id, evidence=evidence, support_status="file_scoped") + return None + + def _categories( + self, + snapshot_id: str, + findings: list[ReviewFinding], + diagnostics: list[RiDiagnostic], + ) -> list[ReviewCategoryAssessment]: + finding_counts = Counter(finding.category for finding in findings) + has_extraction_diagnostics = any(diagnostic.code in _SOURCE_EXTRACTION_CODES for diagnostic in diagnostics) + has_dependency_nodes = ( + self.snapshots.db.scalar( + select(RiNode.id).where(RiNode.snapshot_id == snapshot_id, RiNode.node_kind == "dependency").limit(1) ) - return scores - - def _summary(self, findings: list[ReviewFinding], scores: list[ReviewScore]) -> ReviewSummary: - return ReviewSummary( - overall_score=round(sum(score.score for score in scores) / max(len(scores), 1)), - overall_trend="stable", - critical_count=len([finding for finding in findings if finding.severity == "critical"]), - high_count=len([finding for finding in findings if finding.severity == "high"]), - medium_count=len([finding for finding in findings if finding.severity == "medium"]), - low_count=len([finding for finding in findings if finding.severity == "low"]), - total_findings=len(findings), + is not None + ) + has_file_nodes = ( + self.snapshots.db.scalar( + select(RiNode.id).where(RiNode.snapshot_id == snapshot_id, RiNode.node_kind == "file").limit(1) + ) + is not None ) - def _roadmap(self, findings: list[ReviewFinding]) -> list[ImprovementStep]: - actionable = [finding for finding in findings if finding.id != "baseline-review"] - if not actionable: - return [ - ImprovementStep( - id="maintain-quality-gates", - title="Maintain Quality Gates", - description="Keep linting, type checking, tests, and dependency scanning active as the repository grows.", - priority="low", - estimated_effort="ongoing", - category="code-quality", - related_findings=[finding.id for finding in findings], - ) - ] - - grouped: dict[str, list[ReviewFinding]] = {} - for finding in actionable: - grouped.setdefault(finding.category, []).append(finding) - - steps: list[ImprovementStep] = [] - for category, group in grouped.items(): - top_severity = min((finding.severity for finding in group), key=lambda severity: SEVERITY_RANK[severity]) - steps.append( - ImprovementStep( - id=f"roadmap-{category}", - title=f"Address {category.replace('-', ' ')} findings", - description="; ".join(dict.fromkeys(finding.recommendation for finding in group)), - priority=top_severity, # type: ignore[arg-type] - estimated_effort=EFFORT_BY_SEVERITY[top_severity], - category=category, # type: ignore[arg-type] - related_findings=[finding.id for finding in group], - ) + states: dict[ReviewCategoryId, tuple[AssessmentState, str]] = { + "architecture_boundaries": ( + "partially_assessed", + "Observed nodes and resolved relationships are available; no architectural boundary rating is produced.", + ), + "relationship_resolution": ( + "assessed", + "Resolver diagnostics were assessed and only same-snapshot evidence-backed diagnostics became findings.", + ), + "source_extraction": ( + "partially_assessed" if has_extraction_diagnostics else "assessed", + "Extractor diagnostics were assessed; diagnostics without an authentic line-addressed evidence span remain omitted.", + ), + "dependency_declarations": ( + "partially_assessed" if has_dependency_nodes else "insufficient_evidence", + "Declared dependencies are inventoried when present. Vulnerability and outdated-version assessments were not performed.", + ), + "security_vulnerability_scanning": ( + "not_assessed", + "No vulnerability database, advisory feed, lockfile audit, or exploitability scanner was run for this revision.", + ), + "authentication_evidence": ( + "partially_assessed", + "Authentication-relevant observed facts are available, but exploitability and security posture were not assessed.", + ), + "repository_structure": ( + "partially_assessed" if has_file_nodes else "insufficient_evidence", + "The sealed file inventory is available; maintainability and code quality were not inferred from filenames or size.", + ), + "analysis_integrity": ( + "assessed", + "The selected snapshot is sealed, revision-bound, and has a canonical graph hash.", + ), + } + return [ + ReviewCategoryAssessment( + id=category_id, + label=_CATEGORY_LABELS[category_id], + state=states[category_id][0], + explanation=states[category_id][1], + finding_count=finding_counts[category_id], ) - steps.sort(key=lambda step: SEVERITY_RANK[step.priority]) - return steps + for category_id in _CATEGORY_LABELS + ] diff --git a/apps/backend/app/schemas/ai.py b/apps/backend/app/schemas/ai.py index afdc1c62..5adf9fc9 100644 --- a/apps/backend/app/schemas/ai.py +++ b/apps/backend/app/schemas/ai.py @@ -1,6 +1,8 @@ from datetime import UTC, datetime from typing import Literal +from pydantic import Field + from app.schemas.base import CamelModel AiProvider = Literal["openai", "anthropic", "gemini", "openrouter", "ollama"] @@ -37,6 +39,11 @@ class AiQueryResponse(CamelModel): suggestions: list[str] = [] +class AiConversationResponse(CamelModel): + repository_id: str + messages: list[AiMessage] + + class AiProviderConfig(CamelModel): provider: AiProvider api_key: str | None = None @@ -49,6 +56,10 @@ class AiProviderPublicConfig(CamelModel): model: str | None = None base_url: str | None = None has_api_key: bool = False + # Write-only contract: the stored API key is never returned in full. The + # last four characters are surfaced so the UI can confirm which key is + # saved without ever exposing the secret. + api_key_last4: str | None = None class AiProviderTestRequest(CamelModel): @@ -61,4 +72,26 @@ class AiProviderTestRequest(CamelModel): class AiProviderTestResponse(CamelModel): ok: bool message: str - checked_at: datetime = datetime.now(UTC) + checked_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) + + +class AiProviderCapability(CamelModel): + """Safe, non-secret setup metadata for one provider (#291). + + Never carries an API key, provider token, environment value, or private + endpoint -- only public facts about what the save/test flow requires and + where to go set it up. + """ + + provider: AiProvider + display_name: str + requires_api_key: bool + requires_base_url: bool + default_model: str + setup_url: str + setup_steps: list[str] + support_state: str + + +class AiProviderCapabilitiesResponse(CamelModel): + providers: list[AiProviderCapability] diff --git a/apps/backend/app/schemas/analysis.py b/apps/backend/app/schemas/analysis.py index b477056a..33eda8f6 100644 --- a/apps/backend/app/schemas/analysis.py +++ b/apps/backend/app/schemas/analysis.py @@ -4,15 +4,22 @@ from app.schemas.base import CamelModel from app.schemas.repository import AnalysisStage +# The five explicit lifecycle states from #93 ("No implicit states"). The legacy +# ``processing`` value was renamed to ``running`` and ``cancelled`` was added; the +# frontend is updated to match in Task 5. +AnalysisJobStatus = Literal["queued", "running", "completed", "failed", "cancelled"] + class AnalysisStartResponse(CamelModel): repository_id: str - status: Literal["queued", "processing", "completed", "failed"] + status: AnalysisJobStatus + job_id: str | None = None class AnalysisStatusResponse(CamelModel): repository_id: str - status: Literal["queued", "processing", "completed", "failed"] + status: AnalysisJobStatus + job_id: str | None = None stage: AnalysisStage | None = None progress: int started_at: datetime | None = None diff --git a/apps/backend/app/schemas/architecture.py b/apps/backend/app/schemas/architecture.py index 875903bd..61da69a2 100644 --- a/apps/backend/app/schemas/architecture.py +++ b/apps/backend/app/schemas/architecture.py @@ -1,10 +1,13 @@ from typing import Literal +from pydantic import Field + from app.schemas.base import CamelModel ArchNodeType = Literal[ "frontend", "backend", + "entrypoint", "controller", "route", "service", @@ -21,7 +24,33 @@ "queue", "cache", ] -ArchEdgeType = Literal["dependency", "import", "api-call", "data-flow", "event", "reads", "writes", "calls", "config-usage"] +ArchEdgeType = Literal[ + "dependency", "import", "api-call", "data-flow", "event", "reads", "writes", "calls", "config-usage" +] +RelationshipState = Literal["connected", "no-observed-relationships", "unresolved", "not-extracted"] +TruthClass = Literal["resolved", "inferred"] + + +class ArchEvidence(CamelModel): + snapshot_id: str + fact_id: str + path: str + start_line: int + end_line: int + + +class ArchitectureDiagnostic(CamelModel): + code: str + category: str + severity: Literal["fatal", "error", "warning", "info"] + message: str + path: str | None = None + start_line: int | None = None + end_line: int | None = None + subject_key: str | None = None + object_key: str | None = None + details: dict[str, object] | None = None + node_ids: list[str] | None = None class ArchNode(CamelModel): @@ -33,11 +62,16 @@ class ArchNode(CamelModel): files: list[str] dependencies: list[str] dependents: list[str] - estimated_complexity: Literal["low", "medium", "high"] - estimated_lines: int + # No repository-intelligence producer measures complexity or line counts + # today (#217): these are never a synthesized guess (e.g. file count * 80). + # A real value can only appear once a named heuristic with a truth class + # backs it; until then every node reports "not_computed" explicitly. + estimated_complexity: Literal["low", "medium", "high", "not_computed"] + estimated_lines: int | Literal["not_computed"] tags: list[str] layer: str parent_module: str | None = None + relationship_state: RelationshipState = "not-extracted" class ArchEdge(CamelModel): @@ -46,6 +80,9 @@ class ArchEdge(CamelModel): target: str label: str | None = None type: ArchEdgeType + predicate: str + truth_class: TruthClass + evidence: list[ArchEvidence] class ArchLayer(CamelModel): @@ -91,3 +128,5 @@ class ArchitectureResponse(CamelModel): modules: list[ArchModule] request_flow: list[RequestFlowStep] summary: ArchitectureSummary + relationship_snapshot_id: str | None = None + diagnostics: list[ArchitectureDiagnostic] = Field(default_factory=list) diff --git a/apps/backend/app/schemas/auth.py b/apps/backend/app/schemas/auth.py new file mode 100644 index 00000000..08abfd8b --- /dev/null +++ b/apps/backend/app/schemas/auth.py @@ -0,0 +1,74 @@ +from datetime import datetime +from typing import Literal + +from pydantic import EmailStr, Field + +from app.schemas.base import CamelModel + +# Minimum length is the only enforced policy; complexity rules push users +# toward predictable substitutions instead of longer passphrases. +PASSWORD_MIN_LENGTH = 10 +PASSWORD_MAX_LENGTH = 128 + + +class RegisterRequest(CamelModel): + email: EmailStr + password: str = Field(min_length=PASSWORD_MIN_LENGTH, max_length=PASSWORD_MAX_LENGTH) + + +class LoginRequest(CamelModel): + email: EmailStr + # No minimum here: login validates against the stored hash, and rejecting + # short inputs early would leak the registration policy on the login form. + password: str = Field(max_length=PASSWORD_MAX_LENGTH) + + +class UserResponse(CamelModel): + id: str + email: str + created_at: datetime + + +class AuthResponse(CamelModel): + access_token: str + token_type: Literal["bearer"] = "bearer" + user: UserResponse + + +class AccountDeletionRequest(CamelModel): + # No minimum length here for the same reason as LoginRequest: this + # verifies against the stored hash, and a short-password rejection would + # only leak the registration policy without adding any real protection. + password: str = Field(max_length=PASSWORD_MAX_LENGTH) + # Deliberate confirmation gate: the caller must type back their own + # account email, not just click a button, before an irreversible delete. + confirm_email: EmailStr + + +class OAuthProvidersResponse(CamelModel): + """Which providers have real credentials configured (#288) -- the + frontend only ever renders a "Continue with ..." button for one of + these, the same capability-gating pattern as GET /ai/providers.""" + + providers: list[str] + + +class OAuthStartResponse(CamelModel): + authorize_url: str + + +class OAuthLinkConfirmRequest(CamelModel): + pending_link_id: str = Field(min_length=1, max_length=36) + # No minimum length: same reasoning as LoginRequest, this only ever + # verifies against an existing stored hash. + password: str = Field(max_length=PASSWORD_MAX_LENGTH) + + +class OAuthLinkedIdentity(CamelModel): + provider: str + email: str | None + created_at: datetime + + +class OAuthLinkedIdentitiesResponse(CamelModel): + identities: list[OAuthLinkedIdentity] diff --git a/apps/backend/app/schemas/authentication.py b/apps/backend/app/schemas/authentication.py new file mode 100644 index 00000000..db3086d3 --- /dev/null +++ b/apps/backend/app/schemas/authentication.py @@ -0,0 +1,72 @@ +from typing import Literal + +from pydantic import Field + +from app.schemas.base import CamelModel + +AuthSchemaVersion = Literal["auth-explanation.v1"] +AuthClaimKind = Literal["route", "middleware", "service", "model", "dependency"] +AuthRelationshipNodeKind = Literal["route", "handler", "middleware", "service", "model", "dependency"] +AuthConfidence = Literal["observed", "heuristic"] +AuthStatus = Literal["ready", "missing_snapshot"] + + +class AuthEvidenceRef(CamelModel): + snapshot_id: str + fact_id: str + path: str + start_line: int + end_line: int + + +class AuthClaim(CamelModel): + kind: AuthClaimKind + name: str + confidence: AuthConfidence + evidence: list[AuthEvidenceRef] + + +class AuthRelationship(CamelModel): + subject: str + subject_kind: AuthRelationshipNodeKind + predicate: str + object: str + object_kind: AuthRelationshipNodeKind + evidence: list[AuthEvidenceRef] + + +class AuthChain(CamelModel): + """One ordered, evidence-backed path from a route to everything it reaches. + + ``hops`` is ordered route -> handler -> guard -> (service/model/dependency + calls, in traversal order) so a consumer can render a single readable + chain instead of reconstructing one from the flat ``relationships`` list. + """ + + route: str + hops: list[AuthRelationship] + + +class AuthenticationDiagnostic(CamelModel): + code: str + category: str + severity: Literal["fatal", "error", "warning", "info"] + message: str + path: str | None = None + start_line: int | None = None + end_line: int | None = None + + +class AuthenticationExplanationResponse(CamelModel): + schema_version: AuthSchemaVersion + repository_id: str + repository_name: str + revision_kind: str | None + revision_value: str | None + snapshot_id: str | None + status: AuthStatus + summary: str + claims: list[AuthClaim] = Field(default_factory=list) + relationships: list[AuthRelationship] = Field(default_factory=list) + chains: list[AuthChain] = Field(default_factory=list) + diagnostics: list[AuthenticationDiagnostic] = Field(default_factory=list) diff --git a/apps/backend/app/schemas/dependencies.py b/apps/backend/app/schemas/dependencies.py index 19857542..3cb34f65 100644 --- a/apps/backend/app/schemas/dependencies.py +++ b/apps/backend/app/schemas/dependencies.py @@ -1,28 +1,86 @@ +"""Snapshot-bound Dependency Graph public contract (#158). + +This replaces the legacy-heuristic response: every field is built from sealed +``ri.v1`` facts (``app/graph/dependency_graph.py``), never from the mutable +``repo_metadata['intelligence']`` blob. +""" + +from datetime import datetime from typing import Literal +from pydantic import Field + from app.schemas.base import CamelModel +DependencySchemaVersion = Literal["dependency-graph.v2"] + + +class DependencyProvenance(CamelModel): + source: Literal["ri.v1"] = "ri.v1" + snapshot_id: str + snapshot_schema_version: str + canonical_graph_hash: str + + +class DependencyDeclaration(CamelModel): + name: str + manifest_path: str + workspace_path: str + start_line: int + end_line: int + extractor: str + extractor_version: str + ecosystem: str + version: str | None + type: Literal["production", "development", "peer", "optional"] + + +class DependencyDiagnostic(CamelModel): + code: str + category: str + severity: Literal["fatal", "error", "warning", "info"] + message: str + path: str | None = None + producer: str + details: dict[str, object] | None = None + class DependencyNode(CamelModel): id: str name: str - version: str - type: Literal["production", "development", "peer", "optional"] - has_vulnerabilities: bool - is_outdated: bool - size: int | None = None + version: str | None + type: Literal["production", "development", "peer", "optional", "multiple"] + ecosystem: str + declarations: list[DependencyDeclaration] class DependencyEdge(CamelModel): + id: str source: str target: str - type: Literal["depends-on", "peer", "optional"] + type: Literal["depends-on"] + + +class DependencyAssessment(CamelModel): + status: Literal["not_computed"] class DependencyGraphResponse(CamelModel): + schema_version: DependencySchemaVersion = "dependency-graph.v2" repository_id: str + repository_name: str + revision_kind: Literal["git", "upload"] + revision_value: str + snapshot_id: str + snapshot_schema_version: str + canonical_graph_hash: str + manifest_digest: str + provenance: DependencyProvenance + generated_at: datetime nodes: list[DependencyNode] edges: list[DependencyEdge] total_dependencies: int - vulnerabilities: int - outdated: int + manifest_count: int = 0 + diagnostics: list[DependencyDiagnostic] = Field(default_factory=list) + vulnerability_assessment: DependencyAssessment + outdated_assessment: DependencyAssessment diff --git a/apps/backend/app/schemas/documentation.py b/apps/backend/app/schemas/documentation.py index 2f675b0a..79e537ac 100644 --- a/apps/backend/app/schemas/documentation.py +++ b/apps/backend/app/schemas/documentation.py @@ -14,3 +14,9 @@ class GenerateDocResponse(CamelModel): content: str format: Literal["markdown", "html"] generated_at: datetime + source: Literal["ri.v1"] + snapshot_id: str + snapshot_schema_version: Literal["ri.v1"] + revision_kind: Literal["git", "upload"] + revision_value: str + revision_ref: str | None = None diff --git a/apps/backend/app/schemas/evidence.py b/apps/backend/app/schemas/evidence.py new file mode 100644 index 00000000..2ba27786 --- /dev/null +++ b/apps/backend/app/schemas/evidence.py @@ -0,0 +1,32 @@ +from typing import Literal + +from app.schemas.base import CamelModel + +EvidenceSchemaVersion = Literal["evidence-source.v1"] +EvidenceSourceStatus = Literal["ready", "unavailable"] + + +class EvidenceSourceResponse(CamelModel): + """The exact source text a citation points at, bound to its cited snapshot. + + ``status="unavailable"`` covers every case where the exact cited fact and + revision cannot be honestly displayed (fact/span mismatch, source-hash + mismatch, missing source, out-of-range span, binary content): ``content`` + is always ``None`` in that case rather than falling back to different + content under a trusted-looking snapshot/revision identity. + """ + + schema_version: EvidenceSchemaVersion + repository_id: str + snapshot_id: str + fact_id: str + revision_kind: str | None + revision_value: str | None + path: str + start_line: int + end_line: int + status: EvidenceSourceStatus + reason: str | None = None + content: str | None = None + truncated: bool = False + size: int = 0 diff --git a/apps/backend/app/schemas/insights.py b/apps/backend/app/schemas/insights.py new file mode 100644 index 00000000..d84ad08f --- /dev/null +++ b/apps/backend/app/schemas/insights.py @@ -0,0 +1,71 @@ +"""Snapshot-backed repository Insights contract (#154).""" + +from datetime import datetime +from typing import Literal + +from pydantic import Field + +from app.schemas.base import CamelModel + +InsightsSchemaVersion = Literal["repository-insights.v1"] +MetricAssessmentState = Literal["assessed", "not_assessed", "insufficient_evidence"] + + +class InsightProvenance(CamelModel): + source: Literal["ri.v1"] = "ri.v1" + snapshot_id: str + snapshot_schema_version: str + canonical_graph_hash: str + + +class InsightExtractor(CamelModel): + name: str + version: str + evidence_record_count: int + + +class InsightMetric(CamelModel): + id: str + label: str + value: int | float | str | None + unit: str + definition: str + provenance: InsightProvenance + assessment_state: MetricAssessmentState + snapshot_id: str + numerator: int | None = None + denominator: int | None = None + + +class InsightBreakdown(CamelModel): + key: str + label: str + value: int + + +class ChangeOverTimeAssessment(CamelModel): + assessment_state: Literal["not_assessed"] = "not_assessed" + message: str = "Change-over-time insights are not available yet." + + +class RepositoryInsightsResponse(CamelModel): + schema_version: InsightsSchemaVersion = "repository-insights.v1" + repository_id: str + repository_name: str + revision_kind: Literal["git", "upload"] + revision_value: str + snapshot_id: str + snapshot_schema_version: str + canonical_graph_hash: str + manifest_digest: str + provenance: InsightProvenance + computed_at: datetime + snapshot_created_at: datetime + snapshot_sealed_at: datetime + extractor_set: list[InsightExtractor] = Field(default_factory=list) + metrics: list[InsightMetric] = Field(default_factory=list) + relationships_by_predicate: list[InsightBreakdown] = Field(default_factory=list) + diagnostics_by_severity: list[InsightBreakdown] = Field(default_factory=list) + diagnostics_by_code: list[InsightBreakdown] = Field(default_factory=list) + languages: list[InsightBreakdown] = Field(default_factory=list) + change_over_time: ChangeOverTimeAssessment = Field(default_factory=ChangeOverTimeAssessment) diff --git a/apps/backend/app/schemas/intelligence.py b/apps/backend/app/schemas/intelligence.py new file mode 100644 index 00000000..dcf70c50 --- /dev/null +++ b/apps/backend/app/schemas/intelligence.py @@ -0,0 +1,152 @@ +"""Versioned Repository Intelligence snapshot query responses (#92).""" + +from datetime import datetime +from typing import Literal + +from pydantic import Field + +from app.schemas.base import CamelModel + + +FactKind = Literal["node", "edge", "observation"] +SchemaVersion = Literal["ri.v1"] + + +class RiEvidenceResponse(CamelModel): + schema_version: SchemaVersion + fact_kind: FactKind + fact_id: str + path: str + start_line: int + end_line: int + granularity: Literal["span", "file"] + extractor: str + extractor_version: str + + +class RiNodeResponse(CamelModel): + stable_key: str + node_kind: str + name: str | None = None + language: str | None = None + truth_class: Literal["observed"] + properties: dict | None = None + evidence: list[RiEvidenceResponse] = Field(default_factory=list) + + +class RiEdgeResponse(CamelModel): + edge_id: str + subject_kind: str + subject_key: str + predicate: str + object_kind: str + object_key: str + truth_class: Literal["resolved"] + producer: str + producer_version: str + evidence: list[RiEvidenceResponse] = Field(default_factory=list) + derived_from: list[dict[str, str]] = Field(default_factory=list) + + +class RiAssertionResponse(CamelModel): + assertion_id: str + subject_kind: str + subject_key: str + predicate: str + value: dict + truth_class: Literal["inferred"] + producer: str + producer_version: str + derived_from: list[dict[str, str]] = Field(default_factory=list) + + +class RiPathResponse(CamelModel): + path: str + node: RiNodeResponse + + +class RiPagination(CamelModel): + offset: int + limit: int + total: int + + +class RiSnapshotMetadataResponse(CamelModel): + schema_version: SchemaVersion + snapshot_id: str + repository_id: str + revision_kind: Literal["git", "upload"] + revision_value: str + revision_ref: str | None = None + state: Literal["completed"] + producer_version_set: list[str] + producer_set_hash: str + config_hash: str + canonical_graph_hash: str + created_at: datetime + updated_at: datetime + sealed_at: datetime + + +class RiSymbolsResponse(CamelModel): + schema_version: SchemaVersion + data: list[RiNodeResponse] + pagination: RiPagination + + +class RiNeighboursResponse(CamelModel): + schema_version: SchemaVersion + node_key: str + data: list[RiEdgeResponse] + pagination: RiPagination + + +class RiImpactStepResponse(CamelModel): + """A reached node and the stored edge that proves the traversal hop.""" + + depth: int = Field(ge=1) + node_key: str + via: RiEdgeResponse + + +class RiImpactDirectionResponse(CamelModel): + """One bounded traversal direction. + + ``limit_reached`` means the response omitted further reachable nodes at the + requested depth rather than claiming that the listed nodes are exhaustive. + """ + + data: list[RiImpactStepResponse] + limit_reached: bool + + +class RiImpactResponse(CamelModel): + schema_version: SchemaVersion + node_key: str + depth: int = Field(ge=1) + dependents: RiImpactDirectionResponse + dependencies: RiImpactDirectionResponse + + +class RiReferencesResponse(CamelModel): + schema_version: SchemaVersion + data: list[RiEdgeResponse] + pagination: RiPagination + + +class RiAssertionsResponse(CamelModel): + schema_version: SchemaVersion + data: list[RiAssertionResponse] + pagination: RiPagination + + +class RiPathsResponse(CamelModel): + schema_version: SchemaVersion + data: list[RiPathResponse] + pagination: RiPagination + + +class RiEvidenceResponsePage(CamelModel): + schema_version: SchemaVersion + data: list[RiEvidenceResponse] + pagination: RiPagination diff --git a/apps/backend/app/schemas/manifest.py b/apps/backend/app/schemas/manifest.py new file mode 100644 index 00000000..6105c66e --- /dev/null +++ b/apps/backend/app/schemas/manifest.py @@ -0,0 +1,95 @@ +"""Revision manifest: the verifiable identity of one sealed snapshot (#113). + +The manifest answers "which exact repository revision produced this answer, and +by which extractors" in a form a user can copy out, keep, and later check +against the running system. + +Verification here is a canonical-hash comparison, not a digital signature. +Nothing in this module signs anything and no key material is involved, so the +manifest proves *integrity against this deployment's stored snapshot* — it does +not prove authorship or protect against an operator who controls the database. +The wording of ``VERIFICATION_METHOD`` is deliberately literal for that reason. +""" + +from datetime import datetime +from typing import Literal + +from app.schemas.base import CamelModel + +ManifestSchemaVersion = Literal["revision-manifest.v1"] + +#: How ``manifestDigest`` is produced. Not a signature scheme. +VERIFICATION_METHOD = "sha256-canonical-json" + +#: ``verified`` the manifest matches the snapshot for the current revision. +#: ``superseded`` the manifest matches a sealed snapshot it names, but that +#: snapshot is no longer the repository's current revision. +#: The manifest is authentic; only the repository moved on. +#: ``mismatch`` the manifest does not match stored facts. +#: ``unverifiable`` the snapshot is not sealed, so it has no revision identity. +VerificationState = Literal["verified", "superseded", "mismatch", "unverifiable"] + + +class ManifestExtractor(CamelModel): + """One producer that contributed facts to the snapshot.""" + + name: str + version: str + + +class RevisionManifest(CamelModel): + """The manifest body. Every field is copied from the sealed snapshot. + + This model is what gets hashed into ``manifestDigest``; the digest is + therefore a function of exactly these fields, in canonical JSON form. + """ + + schema_version: ManifestSchemaVersion = "revision-manifest.v1" + repository_id: str + #: How the revision is identified: a git commit, or the content hash of an + #: uploaded archive. + revision_kind: Literal["git", "upload"] + revision_value: str + revision_ref: str | None = None + snapshot_id: str + #: Schema version of the snapshot itself (for example ``ri.v1``). + snapshot_schema_version: str + extractors: list[ManifestExtractor] + producer_set_hash: str + config_hash: str + #: Canonical digest of the sealed snapshot graph, computed when the + #: snapshot was sealed. ``None`` means the snapshot is not sealed and the + #: manifest cannot be trusted as a revision identity. + canonical_graph_hash: str | None + created_at: datetime + sealed_at: datetime | None + + +class RevisionManifestResponse(CamelModel): + manifest: RevisionManifest + #: ``sha256:`` over the canonical JSON encoding of ``manifest``. + manifest_digest: str + verification_method: str = VERIFICATION_METHOD + #: ``verified`` when the snapshot is sealed and the digest was recomputed + #: from stored facts; ``unverifiable`` when the snapshot is not sealed. + verification_state: VerificationState + #: Plain-language statement of what the digest does and does not prove. + verification_note: str + + +class RevisionManifestVerificationRequest(CamelModel): + """A manifest a user previously exported, submitted for re-checking.""" + + manifest: RevisionManifest + manifest_digest: str + + +class RevisionManifestVerificationResponse(CamelModel): + verification_state: VerificationState + verification_method: str = VERIFICATION_METHOD + #: True only when the submitted manifest matches the stored snapshot *and* + #: the submitted digest matches the recomputed one. + matches_stored_snapshot: bool + #: Which manifest fields differ from the currently stored snapshot. + mismatched_fields: list[str] + detail: str diff --git a/apps/backend/app/schemas/repository.py b/apps/backend/app/schemas/repository.py index 448c4c41..488b193b 100644 --- a/apps/backend/app/schemas/repository.py +++ b/apps/backend/app/schemas/repository.py @@ -6,8 +6,7 @@ from app.schemas.base import CamelModel RepositorySource = Literal["upload", "github"] -RepositoryStatus = Literal["uploading", "analysing", "completed", "error"] -DataSource = Literal["real"] +RepositoryStatus = Literal["uploading", "analysing", "completed", "cancelled", "error"] AnalysisStage = Literal[ "uploading", "extracting", @@ -46,6 +45,20 @@ class RepositoryMeta(CamelModel): license_name: str | None +class RepositoryRevision(CamelModel): + """First-class repository revision identity (#87, RFC §3.2). + + ``value`` is the immutable identity: a 40-char lowercase git commit SHA for + GitHub imports, or a ``sha256:`` archive content hash for uploads. + ``ref`` (e.g. ``refs/heads/main``) is a moving pointer — descriptive + metadata only, never identity, and always ``null`` for uploads. + """ + + kind: Literal["git", "upload"] + value: str + ref: str | None = None + + class RepositoryResponse(CamelModel): id: str name: str @@ -56,12 +69,16 @@ class RepositoryResponse(CamelModel): size: int file_count: int status: RepositoryStatus - data_source: DataSource analysis_stage: AnalysisStage | None = None analysis_progress: int uploaded_at: datetime analysed_at: datetime | None = None error_message: str | None = None + # First-class revision identity, sourced from indexed immutable columns and + # no longer from the mutable ``repo_metadata`` blob (#87). ``commit_sha`` is + # retained as a backward-compatible alias of ``revision.value``. + revision: RepositoryRevision | None = None + commit_sha: str | None = None meta: RepositoryMeta | None = None file_tree: list[FileTreeNode] = Field(default_factory=list) @@ -84,3 +101,32 @@ class RepositoryFileResponse(CamelModel): is_binary: bool = False is_image: bool = False media_type: str | None = None + + +class RepositoryLineageEntry(CamelModel): + """One repository row belonging to a lineage (#299, RFC-0002), or the + lone entry for a standalone (unlineaged) repository.""" + + repository_id: str + sequence: int | None = None + name: str + status: RepositoryStatus + revision: RepositoryRevision | None = None + uploaded_at: datetime + is_current: bool + + +class RepositoryLineageResponse(CamelModel): + """History for the repository requested, most recent import first. + + An unlineaged repository (an upload, or a GitHub import whose ref never + resolved -- RFC §4.3/§6) is never fabricated a lineage: ``is_lineaged`` is + ``false``, ``lineage_id``/``canonical_source_key``/``canonical_branch`` + stay ``null``, and ``entries`` holds exactly the one requested repository. + """ + + is_lineaged: bool + lineage_id: str | None = None + canonical_source_key: str | None = None + canonical_branch: str | None = None + entries: list[RepositoryLineageEntry] diff --git a/apps/backend/app/schemas/review.py b/apps/backend/app/schemas/review.py index fe15f34b..bfc1e19e 100644 --- a/apps/backend/app/schemas/review.py +++ b/apps/backend/app/schemas/review.py @@ -1,74 +1,143 @@ +"""Evidence-backed Engineering Review public contract (#154). + +This intentionally replaces the legacy score/grade/roadmap response. A review +finding is a deterministic view over a sealed ``ri.v1`` diagnostic and an +exact, same-snapshot evidence span. Diagnostics without support are omitted +rather than promoted into repository claims. +""" + from datetime import datetime from typing import Literal +from pydantic import Field + from app.schemas.base import CamelModel -ReviewSeverity = Literal["critical", "high", "medium", "low"] -ReviewStatus = Literal["open", "acknowledged", "resolved"] -ReviewCategory = Literal[ - "architecture", - "security", - "performance", - "maintainability", - "scalability", - "code-quality", - "documentation", - "testing", - "dependency-health", - "configuration", +ReviewSchemaVersion = Literal["engineering-review.v2"] +ReviewSeverity = Literal["info", "low", "medium", "high", "critical"] +AssessmentState = Literal[ + "assessed", + "partially_assessed", + "not_assessed", + "insufficient_evidence", ] +ReviewCategoryId = Literal[ + "architecture_boundaries", + "relationship_resolution", + "source_extraction", + "dependency_declarations", + "security_vulnerability_scanning", + "authentication_evidence", + "repository_structure", + "analysis_integrity", +] +#: How precisely the finding's evidence addresses the diagnostic. +#: +#: ``supported`` the evidence span is the diagnostic's own recorded span. +#: ``file_scoped`` the diagnostic named a file but recorded no span, so the +#: evidence is that file's file-granularity record and the +#: reported lines cover the whole file, not the exact defect. +ReviewSupportStatus = Literal["supported", "file_scoped"] + + +class ReviewProvenance(CamelModel): + source: Literal["ri.v1"] = "ri.v1" + snapshot_id: str + snapshot_schema_version: str + canonical_graph_hash: str + + +class ReviewEvidenceReference(CamelModel): + evidence_id: str + snapshot_id: str + fact_id: str + path: str + start_line: int + end_line: int + extractor_name: str + extractor_version: str class ReviewFinding(CamelModel): id: str - title: str - category: ReviewCategory + category: ReviewCategoryId severity: ReviewSeverity - status: ReviewStatus - problem: str - impact: str - recommendation: str - priority: int - estimated_effort: Literal["trivial", "small", "medium", "large", "major"] - affected_files: list[str] - affected_modules: list[str] - tags: list[str] - - -class ReviewScore(CamelModel): - category: ReviewCategory - score: int - trend: Literal["improving", "stable", "declining"] - risk_level: ReviewSeverity - priority: int - findings_count: int + title: str + explanation: str + path: str + start_line: int + end_line: int + snapshot_id: str + fact_id: str + evidence_id: str + extractor_name: str + extractor_version: str + diagnostic_code: str + rule_id: str + remediation_guidance: str + support_status: ReviewSupportStatus = "supported" + provenance: ReviewProvenance + evidence: ReviewEvidenceReference -class ReviewSummary(CamelModel): - overall_score: int - overall_trend: Literal["improving", "stable", "declining"] - critical_count: int - high_count: int - medium_count: int - low_count: int - total_findings: int +class ReviewCategoryAssessment(CamelModel): + id: ReviewCategoryId + label: str + state: AssessmentState + explanation: str + finding_count: int = 0 -class ImprovementStep(CamelModel): - id: str - title: str - description: str - priority: ReviewSeverity - estimated_effort: str - category: ReviewCategory - related_findings: list[str] +class ReviewSeverityCounts(CamelModel): + info: int = 0 + low: int = 0 + medium: int = 0 + high: int = 0 + critical: int = 0 + + +class ReviewPagination(CamelModel): + offset: int + limit: int + total: int + + +class ReviewSummary(CamelModel): + message: str + findings_by_severity: ReviewSeverityCounts + assessed_categories: int + partially_assessed_categories: int + not_assessed_categories: int + insufficient_evidence_categories: int + evidence_backed_finding_count: int + #: Findings whose evidence covers the whole named file because the + #: diagnostic recorded no span. A subset of + #: ``evidence_backed_finding_count``, not an addition to it. + file_scoped_finding_count: int = 0 + #: Diagnostics deliberately not published: no rule, or no evidence in this + #: snapshot that addresses them. + omitted_unsupported_diagnostic_count: int = 0 + vulnerability_scanning: Literal["not_assessed"] = "not_assessed" class EngineeringReviewResponse(CamelModel): + schema_version: ReviewSchemaVersion = "engineering-review.v2" repository_id: str repository_name: str + revision_kind: Literal["git", "upload"] + revision_value: str + snapshot_id: str + snapshot_schema_version: str + canonical_graph_hash: str + manifest_digest: str + provenance: ReviewProvenance generated_at: datetime + assessment_status: AssessmentState + categories: list[ReviewCategoryAssessment] = Field(default_factory=list) + #: This page of findings. Bounded by ``pagination.limit``, not the full + #: matched count -- see ``pagination.total`` for the complete count and + #: ``summary``/``categories`` for whole-snapshot assessment stats, which + #: are never affected by pagination or filtering. + findings: list[ReviewFinding] = Field(default_factory=list) + pagination: ReviewPagination summary: ReviewSummary - scores: list[ReviewScore] - findings: list[ReviewFinding] - roadmap: list[ImprovementStep] diff --git a/apps/backend/app/schemas/waitlist.py b/apps/backend/app/schemas/waitlist.py new file mode 100644 index 00000000..8a35bdf7 --- /dev/null +++ b/apps/backend/app/schemas/waitlist.py @@ -0,0 +1,14 @@ +from typing import Literal + +from pydantic import EmailStr, Field + +from app.schemas.base import CamelModel + + +class WaitlistSignupRequest(CamelModel): + email: EmailStr + name: str | None = Field(default=None, max_length=200) + + +class WaitlistSignupResponse(CamelModel): + status: Literal["ok"] diff --git a/apps/backend/app/services/account_deletion_service.py b/apps/backend/app/services/account_deletion_service.py new file mode 100644 index 00000000..544a205c --- /dev/null +++ b/apps/backend/app/services/account_deletion_service.py @@ -0,0 +1,93 @@ +"""Verified, cascading account deletion (#290). + +Deletion is truthful: the response completes only after the account is +inaccessible and every owner-scoped database row is gone. The database +transaction is the point of no return -- once it commits, the user can no +longer authenticate, refresh, or be found by any owner-scoped query, because +every owner foreign key cascades from the ``users`` row at the database level +(0010_account_deletion). Filesystem cleanup of repository source is +deliberately a *separate*, best-effort step that runs only after that commit +succeeds: the account is already gone from the caller's perspective by then, +so a stray unremovable directory is an operational cleanup concern, not a +reason to report deletion as failed to an account that no longer exists. +""" + +import logging +from datetime import UTC, datetime +from uuid import uuid4 + +from sqlalchemy.orm import Session + +from app.auth.security import verify_password +from app.core.exceptions import UnauthorizedError, ValidationServiceError +from app.models.account_deletion_audit import AccountDeletionAuditRecord +from app.models.user import SEED_USER_ID, User +from app.repositories.repository_repository import RepositoryRepository +from app.storage.local import LocalStorage + +logger = logging.getLogger(__name__) + +# Matches the auth module's existing "don't reveal which part was wrong" +# convention: a deletion attempt is a credential check like any other. +INVALID_PASSWORD = "Invalid password." +CONFIRMATION_MISMATCH = "Confirmation email does not match your account email." +SEED_USER_UNDELETABLE = "The system account cannot be deleted." + + +class AccountDeletionService: + def __init__(self, db: Session, repository: RepositoryRepository, storage: LocalStorage) -> None: + self.db = db + self.repository = repository + self.storage = storage + + def delete_account(self, user: User, password: str, confirm_email: str) -> None: + self._authenticate_for_deletion(user, password, confirm_email) + + audit_id = str(uuid4()) + now = datetime.now(UTC) + self.db.add( + AccountDeletionAuditRecord(id=audit_id, deleted_user_id=user.id, status="in_progress", requested_at=now) + ) + self.db.commit() + + # Storage paths must be collected before the cascading delete below: + # once the user row is gone, the repository rows that named them are + # gone too, and there is nothing left to query. + local_paths = [record.local_path for record in self.repository.list_for_owner(user.id)] + + try: + self.db.delete(user) + audit = self.db.get(AccountDeletionAuditRecord, audit_id) + assert audit is not None + audit.status = "completed" + audit.completed_at = datetime.now(UTC) + self.db.commit() + except Exception as exc: + self.db.rollback() + self._record_failure(audit_id, exc) + raise + + for local_path in local_paths: + try: + self.storage.delete_repository(local_path) + except OSError: + logger.error( + "Account deletion storage cleanup failed for a repository path", + extra={"deleted_user_id": user.id}, + exc_info=True, + ) + + def _authenticate_for_deletion(self, user: User, password: str, confirm_email: str) -> None: + if user.id == SEED_USER_ID: + raise ValidationServiceError(SEED_USER_UNDELETABLE) + if user.password_hash is None or not verify_password(user.password_hash, password): + raise UnauthorizedError(INVALID_PASSWORD) + if confirm_email.strip().lower() != user.email: + raise ValidationServiceError(CONFIRMATION_MISMATCH) + + def _record_failure(self, audit_id: str, exc: Exception) -> None: + audit = self.db.get(AccountDeletionAuditRecord, audit_id) + if audit is not None: + audit.status = "failed" + audit.failure_reason = str(exc)[:255] + self.db.commit() diff --git a/apps/backend/app/services/ai_service.py b/apps/backend/app/services/ai_service.py index 98c74dfe..e912b026 100644 --- a/apps/backend/app/services/ai_service.py +++ b/apps/backend/app/services/ai_service.py @@ -1,6 +1,10 @@ from app.ai.orchestrator import AiOrchestrator +from app.ai.providers.capabilities import all_capabilities from app.ai.types import AiProviderConfig from app.schemas.ai import ( + AiMessage, + AiProviderCapabilitiesResponse, + AiProviderCapability, AiProviderPublicConfig, AiProviderTestRequest, AiProviderTestResponse, @@ -13,6 +17,23 @@ class AiService: def __init__(self, orchestrator: AiOrchestrator) -> None: self.orchestrator = orchestrator + def list_provider_capabilities(self) -> AiProviderCapabilitiesResponse: + return AiProviderCapabilitiesResponse( + providers=[ + AiProviderCapability( + provider=capability.provider, + display_name=capability.display_name, + requires_api_key=capability.requires_api_key, + requires_base_url=capability.requires_base_url, + default_model=capability.default_model, + setup_url=capability.setup_url, + setup_steps=capability.setup_steps(), + support_state=capability.support_state, + ) + for capability in all_capabilities() + ] + ) + def get_config(self) -> AiProviderPublicConfig: return self.orchestrator.get_config() @@ -24,3 +45,6 @@ async def test_connection(self, request: AiProviderTestRequest) -> AiProviderTes async def query(self, request: AiQueryRequest) -> AiQueryResponse: return await self.orchestrator.query(request) + + def list_conversation(self, repository_id: str) -> list[AiMessage]: + return self.orchestrator.list_conversation(repository_id) diff --git a/apps/backend/app/services/analysis_job_service.py b/apps/backend/app/services/analysis_job_service.py new file mode 100644 index 00000000..ea65c9a4 --- /dev/null +++ b/apps/backend/app/services/analysis_job_service.py @@ -0,0 +1,432 @@ +"""Durable analysis-job lifecycle: submit, status, and cancel (#93). + +This service owns the *durable* side of analysis: it turns a +``POST /analysis/{repository_id}/start`` request into an ``analysis_jobs`` row +(fast, off the request path) and answers status/cancel queries against that +row. The heavy work — legacy intelligence plus the evidence-backed extraction +pipeline that seals a Repository Intelligence snapshot — runs in +``app.workers.analysis_worker.AnalysisWorker``, never inside the request. + +A job's semantic identity is ``(repository_id, revision_value, config_hash)``, +mirroring ``SnapshotStore``'s own semantic-identity concept +(RFC-0001 §3.3). ``config_hash`` is a stable content hash of the analysis +configuration; there is no tunable analysis configuration today, so it hashes a +fixed empty-config sentinel via the same helper ``SnapshotStore`` uses. The +field exists so that a future config change naturally produces a new identity. +""" + +from __future__ import annotations + +from datetime import UTC, datetime +from uuid import uuid4 + +from sqlalchemy import select, update +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from app.core.exceptions import ConflictServiceError, NotFoundError, ServiceError +from app.extraction import production_extractors +from app.extraction.pipeline import ExtractionPipeline +from app.intelligence import canonical +from app.intelligence.classification import RoleClassifier +from app.intelligence.resolution import RelationshipResolver +from app.intelligence.snapshot_store import SnapshotStore +from app.models.analysis_job import AnalysisJob +from app.models.repository import RepositoryRecord +from app.models.snapshot import RiSnapshot +from app.repositories.repository_repository import RepositoryRepository + + +def _analysis_producer_version_set() -> tuple[str, ...]: + """The fixed, planned producer set the analysis pipeline declares. + + The semantic identity (RFC §3.3) must fix the *planned* producer set before + any facts are written, so both ``submit`` (looking for a reusable snapshot) + and the worker (opening/sealing one) key on the identical set. Deriving it + from the collaborator classes keeps it in lockstep with the pipeline the + worker actually runs. Declaring a producer that emits nothing for a given + repository is safe: sealing requires declared ⊇ observed, never equality. + """ + + return tuple( + sorted( + { + f"{ExtractionPipeline.inventory_name}@{ExtractionPipeline.inventory_version}", + *(f"{extractor.name}@{extractor.version}" for extractor in production_extractors()), + f"{RelationshipResolver.name}@{RelationshipResolver.version}", + f"{RoleClassifier.name}@{RoleClassifier.version}", + } + ) + ) + + +# Shared identity constants — imported by the worker so submit and execution +# agree on the exact semantic identity. +ANALYSIS_PRODUCER_VERSION_SET = _analysis_producer_version_set() +ANALYSIS_CONFIG_HASH = canonical.compute_config_hash(None) +ANALYSIS_SCHEMA_VERSION = canonical.SCHEMA_VERSION + + +def _utcnow() -> datetime: + return datetime.now(UTC) + + +class AnalysisJobService: + """Owner-scoped durable analysis-job submission, status, and cancellation.""" + + def __init__(self, db: Session, owner_id: str) -> None: + self.db = db + self.owner_id = owner_id + self.repository = RepositoryRepository(db) + self.snapshots = SnapshotStore(db) + + # -- submit -------------------------------------------------------------- + + def submit(self, repository_id: str) -> AnalysisJob: + """Durably enqueue analysis for a repository, idempotently. + + Duplicate submissions never create duplicate work: an already-completed + snapshot short-circuits to a ``completed`` job, and a still-active job + for the same identity is returned unchanged. The effective-identity + index also serializes a concurrent worker completion with job insertion. + """ + + record = self._get_record(repository_id) + revision_value = self._require_revision(record) + + # 1. Already-sealed snapshot for this exact identity ⇒ idempotent-complete. + completed = self._completed_snapshot(record, revision_value) + if completed is not None: + existing = self._job_for_snapshot(record.id, completed.snapshot_id) + if existing is not None: + return self._reconcile_completed_job(record, existing, completed) + return self._insert_completed_job(record, completed) + + # 2. An active (queued/running) job for this identity already exists. + active = self._active_job(record.id, ANALYSIS_CONFIG_HASH) + if active is not None: + return active + + # 3. No completed snapshot and no active job ⇒ enqueue a fresh one. + return self._insert_queued_job(record, ANALYSIS_CONFIG_HASH) + + # -- status -------------------------------------------------------------- + + def status(self, repository_id: str) -> AnalysisJob | None: + """Return the authoritative job for a repository, or ``None`` if none. + + ``None`` means no analysis job has ever been submitted; the route layer + treats that as "not started" (surfaced as ``queued``). + """ + + record = self._get_record(repository_id) + completed = self._completed_snapshot(record, self._require_revision(record)) + if completed is not None: + job = self._job_for_snapshot(record.id, completed.snapshot_id) + if job is not None: + return self._reconcile_completed_job(record, job, completed) + effective = self._effective_job(record.id, ANALYSIS_CONFIG_HASH) + if effective is not None: + return self._reconcile_completed_job(record, effective, completed) + return self._insert_completed_job(record, completed) + return self._latest_job(repository_id) + + # -- cancel -------------------------------------------------------------- + + def cancel(self, repository_id: str) -> AnalysisJob: + """Cancel the current job cooperatively. + + A ``queued`` job (never claimed) transitions straight to ``cancelled``. + A ``running`` job is flagged ``cancel_requested`` and left running; the + worker heartbeat observes the flag and performs the actual transition + once stage work stops. A terminal job — or no job at all — has nothing + to cancel and raises ``ConflictServiceError`` (409). + """ + + self._get_record(repository_id) + job = self._latest_job(repository_id) + if job is None: + raise ConflictServiceError("No analysis job to cancel.", {"repositoryId": repository_id}) + return self._cancel_job(job) + + def _cancel_job(self, job: AnalysisJob) -> AnalysisJob: + """Cancel ``job`` without overwriting a concurrent claim/terminal state.""" + + if job.status == "queued": + now = _utcnow() + result = self.db.execute( + update(AnalysisJob) + .where( + AnalysisJob.id == job.id, + AnalysisJob.owner_id == self.owner_id, + AnalysisJob.status == "queued", + ) + .values( + status="cancelled", + cancel_requested=False, + worker_id=None, + lease_expires_at=None, + next_attempt_at=None, + completed_at=now, + updated_at=now, + ) + .execution_options(synchronize_session=False) + ) + if result.rowcount != 0: + self._cancel_repository(job.repository_id, now) + self.db.commit() + return self._reload_job(job.id) + self.db.commit() + # A worker may have claimed the queued row between the read and the + # guarded update. Re-read and request cooperative cancellation from + # the actual running owner instead of overwriting its claim. + job = self._reload_job(job.id) + if job.status == "running": + now = _utcnow() + result = self.db.execute( + update(AnalysisJob) + .where( + AnalysisJob.id == job.id, + AnalysisJob.owner_id == self.owner_id, + AnalysisJob.status == "running", + ) + .values(cancel_requested=True, updated_at=now) + .execution_options(synchronize_session=False) + ) + self.db.commit() + if result.rowcount != 0: + return self._reload_job(job.id) + # Completion/failure/cancellation may have won the race. The + # terminal row is authoritative and must never be overwritten. + job = self._reload_job(job.id) + raise ConflictServiceError( + "Analysis job is not in a cancellable state.", + {"repositoryId": job.repository_id, "status": job.status}, + ) + + # -- internals ----------------------------------------------------------- + + def _get_record(self, repository_id: str) -> RepositoryRecord: + # Owner-scoped: get_for_owner returns None for both a missing repository + # and one owned by another user, so a cross-user request gets the same + # 404 as a missing one and never learns the resource exists. + record = self.repository.get_for_owner(repository_id, self.owner_id) + if not record: + raise NotFoundError("Repository not found.", {"repositoryId": repository_id}) + return record + + @staticmethod + def _require_revision(record: RepositoryRecord) -> str: + if record.revision_kind is None or record.revision_value is None: + raise ServiceError("Repository has no analyzable revision.", {"repositoryId": record.id}) + return record.revision_value + + def _latest_job(self, repository_id: str) -> AnalysisJob | None: + return self.db.scalars( + select(AnalysisJob) + .where( + AnalysisJob.repository_id == repository_id, + AnalysisJob.owner_id == self.owner_id, + ) + .order_by(AnalysisJob.created_at.desc()) + .limit(1) + ).first() + + def _reload_job(self, job_id: str) -> AnalysisJob: + self.db.expire_all() + job = self.db.get(AnalysisJob, job_id) + if job is None or job.owner_id != self.owner_id: + raise ConflictServiceError("No analysis job to cancel.") + return job + + def _active_job(self, repository_id: str, config_hash: str) -> AnalysisJob | None: + return self.db.scalars( + select(AnalysisJob) + .where( + AnalysisJob.repository_id == repository_id, + AnalysisJob.owner_id == self.owner_id, + AnalysisJob.config_hash == config_hash, + AnalysisJob.status.in_(("queued", "running")), + ) + .order_by(AnalysisJob.created_at.desc()) + .limit(1) + ).first() + + def _effective_job(self, repository_id: str, config_hash: str) -> AnalysisJob | None: + return self.db.scalars( + select(AnalysisJob) + .where( + AnalysisJob.repository_id == repository_id, + AnalysisJob.owner_id == self.owner_id, + AnalysisJob.config_hash == config_hash, + AnalysisJob.status.in_(("queued", "running", "completed")), + ) + .order_by(AnalysisJob.created_at.desc()) + .limit(1) + ).first() + + def _job_for_snapshot(self, repository_id: str, snapshot_id: str) -> AnalysisJob | None: + return self.db.scalars( + select(AnalysisJob) + .where( + AnalysisJob.repository_id == repository_id, + AnalysisJob.owner_id == self.owner_id, + AnalysisJob.snapshot_id == snapshot_id, + ) + .order_by(AnalysisJob.created_at.desc()) + .limit(1) + ).first() + + def _insert_queued_job(self, record: RepositoryRecord, config_hash: str) -> AnalysisJob: + job = AnalysisJob( + id=str(uuid4()), + repository_id=record.id, + owner_id=self.owner_id, + revision_kind=record.revision_kind, + revision_value=record.revision_value, + config_hash=config_hash, + status="queued", + attempt=0, + ) + self.db.add(job) + self._prepare_repository_for_analysis(record) + try: + self.db.commit() + except IntegrityError: + # A concurrent submit or worker completion won the effective + # identity constraint. The failed INSERT cannot return until the + # winning transaction commits, so this post-rollback read is a + # transactionally ordered reconciliation, not a timing check. + self.db.rollback() + completed = self._completed_snapshot(record, self._require_revision(record)) + effective = self._effective_job(record.id, config_hash) + if completed is not None and effective is not None: + return self._reconcile_completed_job(record, effective, completed) + if effective is not None: + return effective + raise + self.db.refresh(job) + return job + + def _reconcile_completed_job(self, record: RepositoryRecord, job: AnalysisJob, snapshot: RiSnapshot) -> AnalysisJob: + """Make a sealed snapshot authoritative over any non-completed job row.""" + + if ( + job.status == "completed" + and job.stage == "completed" + and job.progress == 100 + and job.snapshot_id == snapshot.snapshot_id + and not job.cancel_requested + and job.worker_id is None + and job.lease_expires_at is None + and job.next_attempt_at is None + and job.error_code is None + and job.error_message is None + and job.completed_at is not None + and record.status == "completed" + and record.analysis_stage == "completed" + and record.analysis_progress == 100 + and record.error_message is None + and record.analysed_at is not None + ): + return job + now = _utcnow() + completed_at = snapshot.sealed_at or now + job.status = "completed" + job.stage = "completed" + job.progress = 100 + job.snapshot_id = snapshot.snapshot_id + job.cancel_requested = False + job.worker_id = None + job.lease_expires_at = None + job.next_attempt_at = None + job.error_code = None + job.error_message = None + job.started_at = job.started_at or snapshot.created_at + job.completed_at = completed_at + job.updated_at = now + self._complete_repository(record, completed_at) + self.db.commit() + self.db.refresh(job) + return job + + def _insert_completed_job(self, record: RepositoryRecord, snapshot: RiSnapshot) -> AnalysisJob: + now = _utcnow() + completed_at = snapshot.sealed_at or now + job = AnalysisJob( + id=str(uuid4()), + repository_id=record.id, + owner_id=self.owner_id, + revision_kind=record.revision_kind, + revision_value=record.revision_value, + config_hash=ANALYSIS_CONFIG_HASH, + status="completed", + stage="completed", + progress=100, + attempt=0, + snapshot_id=snapshot.snapshot_id, + started_at=snapshot.created_at, + completed_at=completed_at, + ) + self.db.add(job) + self._complete_repository(record, completed_at) + try: + self.db.commit() + except IntegrityError: + # Another submit associated this sealed snapshot while both callers + # had no job row. The unique snapshot index makes that race safe; + # return the winner just like the active-job insertion path. + self.db.rollback() + existing = self._job_for_snapshot(record.id, snapshot.snapshot_id) + if existing is not None: + return self._reconcile_completed_job(record, existing, snapshot) + effective = self._effective_job(record.id, ANALYSIS_CONFIG_HASH) + if effective is not None: + return self._reconcile_completed_job(record, effective, snapshot) + raise + self.db.refresh(job) + return job + + @staticmethod + def _complete_repository(record: RepositoryRecord, completed_at: datetime) -> None: + record.status = "completed" + record.analysis_stage = "completed" + record.analysis_progress = 100 + record.error_message = None + record.analysed_at = completed_at + + def _completed_snapshot(self, record: RepositoryRecord, revision_value: str) -> RiSnapshot | None: + return self.snapshots.find_completed_for_owner( + owner_id=self.owner_id, + repository_id=record.id, + revision_value=revision_value, + schema_version=ANALYSIS_SCHEMA_VERSION, + producer_version_set=ANALYSIS_PRODUCER_VERSION_SET, + config_hash=ANALYSIS_CONFIG_HASH, + ) + + @staticmethod + def _prepare_repository_for_analysis(record: RepositoryRecord) -> None: + record.status = "analysing" + record.analysis_stage = None + record.analysis_progress = 0 + record.error_message = None + record.analysed_at = None + + def _cancel_repository(self, repository_id: str, cancelled_at: datetime) -> None: + self.db.execute( + update(RepositoryRecord) + .where( + RepositoryRecord.id == repository_id, + RepositoryRecord.owner_id == self.owner_id, + ) + .values( + status="cancelled", + analysis_stage=None, + analysis_progress=0, + error_message=None, + analysed_at=None, + updated_at=cancelled_at, + ) + .execution_options(synchronize_session=False) + ) diff --git a/apps/backend/app/services/analysis_service.py b/apps/backend/app/services/analysis_service.py index 9b1bf2f3..71b8bc96 100644 --- a/apps/backend/app/services/analysis_service.py +++ b/apps/backend/app/services/analysis_service.py @@ -1,78 +1,43 @@ -from datetime import UTC, datetime - from app.analysis.architecture import ArchitectureAnalyzer +from app.analysis.authentication import AuthenticationExplanationService from app.graph.dependency_graph import DependencyGraphBuilder -from app.intelligence.engine import RepositoryIntelligenceEngine +from app.insights.service import RepositoryInsightsBuilder from app.repositories.repository_repository import RepositoryRepository from app.review.review_service import EngineeringReviewBuilder -from app.schemas.analysis import AnalysisStartResponse, AnalysisStatusResponse from app.schemas.architecture import ArchitectureResponse +from app.schemas.authentication import AuthenticationExplanationResponse from app.schemas.dependencies import DependencyGraphResponse -from app.schemas.review import EngineeringReviewResponse -from app.core.exceptions import NotFoundError, ServiceError +from app.schemas.insights import RepositoryInsightsResponse +from app.schemas.review import EngineeringReviewResponse, ReviewCategoryId, ReviewSeverity +from app.core.exceptions import NotFoundError class AnalysisService: + """Read-model builder for a repository's persisted analysis (#93). + + Enqueue/status/cancel of the durable analysis lifecycle now live in + ``AnalysisJobService``; this service only builds the architecture, dependency, + review, and authentication-explanation read models from already-persisted + intelligence, which the export service also reuses. + """ + def __init__( self, repository: RepositoryRepository, architecture: ArchitectureAnalyzer, dependencies: DependencyGraphBuilder, review: EngineeringReviewBuilder, - intelligence: RepositoryIntelligenceEngine, + insights: RepositoryInsightsBuilder, + authentication: AuthenticationExplanationService, + owner_id: str, ) -> None: self.repository = repository self.architecture = architecture self.dependencies = dependencies self.review = review - self.intelligence = intelligence - - def start(self, repository_id: str) -> AnalysisStartResponse: - record = self._get_record(repository_id) - if record.status == "completed": - return AnalysisStartResponse(repository_id=record.id, status="completed") - if record.status == "error": - return AnalysisStartResponse(repository_id=record.id, status="failed") - - record.status = "analysing" - record.analysis_stage = "preparing-architecture" - record.analysis_progress = 80 - self.repository.save(record) - try: - repository_intelligence = self.intelligence.from_record(record) - self.intelligence.persist(record, repository_intelligence) - self.repository.save(record) - self.architecture.build_architecture(record) - self.dependencies.build(record) - self.review.build(record) - except Exception as exc: - record.status = "error" - record.analysis_stage = None - record.analysis_progress = 0 - record.error_message = "Repository analysis failed." - self.repository.save(record) - raise ServiceError("Repository analysis failed.", {"repositoryId": record.id}) from exc - - record.status = "completed" - record.analysis_stage = "completed" - record.analysis_progress = 100 - record.error_message = None - record.analysed_at = datetime.now(UTC) - self.repository.save(record) - return AnalysisStartResponse(repository_id=record.id, status="completed") - - def status(self, repository_id: str) -> AnalysisStatusResponse: - record = self._get_record(repository_id) - status = "failed" if record.status == "error" else "completed" if record.status == "completed" else "processing" - return AnalysisStatusResponse( - repository_id=record.id, - status=status, - stage=record.analysis_stage, - progress=record.analysis_progress, - started_at=record.uploaded_at, - completed_at=record.analysed_at, - error=record.error_message, - ) + self.insights = insights + self.authentication = authentication + self.owner_id = owner_id def architecture_model(self, repository_id: str) -> ArchitectureResponse: return self.architecture.build_architecture(self._get_record(repository_id)) @@ -80,11 +45,36 @@ def architecture_model(self, repository_id: str) -> ArchitectureResponse: def dependency_graph(self, repository_id: str) -> DependencyGraphResponse: return self.dependencies.build(self._get_record(repository_id)) - def engineering_review(self, repository_id: str) -> EngineeringReviewResponse: - return self.review.build(self._get_record(repository_id)) + def engineering_review( + self, + repository_id: str, + *, + category: ReviewCategoryId | None = None, + severity: ReviewSeverity | None = None, + diagnostic_code: str | None = None, + offset: int | None = None, + limit: int | None = None, + ) -> EngineeringReviewResponse: + return self.review.build( + self._get_record(repository_id), + category=category, + severity=severity, + diagnostic_code=diagnostic_code, + offset=offset, + limit=limit, + ) + + def repository_insights(self, repository_id: str) -> RepositoryInsightsResponse: + return self.insights.build(self._get_record(repository_id)) + + def authentication_explanation(self, repository_id: str) -> AuthenticationExplanationResponse: + return self.authentication.explain(self._get_record(repository_id)) def _get_record(self, repository_id: str): - record = self.repository.get(repository_id) + # Owner-scoped: get_for_owner returns None for both a missing repository + # and one owned by another user, so a cross-user request gets the same + # 404 as a missing one and never learns the resource exists. + record = self.repository.get_for_owner(repository_id, self.owner_id) if not record: raise NotFoundError("Repository not found.", {"repositoryId": repository_id}) return record diff --git a/apps/backend/app/services/documentation_service.py b/apps/backend/app/services/documentation_service.py index f1a1f42c..689c1b86 100644 --- a/apps/backend/app/services/documentation_service.py +++ b/apps/backend/app/services/documentation_service.py @@ -1,12 +1,12 @@ +from collections import Counter, defaultdict from datetime import UTC, datetime -from app.analysis.architecture import ArchitectureAnalyzer from app.core.exceptions import NotFoundError -from app.graph.dependency_graph import DependencyGraphBuilder -from app.intelligence.engine import RepositoryIntelligenceEngine +from app.intelligence.classification import LAYER_ORDER, layer_for_role +from app.intelligence.query_service import ProductSnapshotProjection, SnapshotModuleFact, SnapshotQueryService from app.repositories.repository_repository import RepositoryRepository from app.reports.renderers import render_html, render_markdown -from app.reports.report_document import ReportDocument, Section +from app.reports.report_document import ReportDocument, Section, Table from app.schemas.documentation import GenerateDocRequest, GenerateDocResponse DEFAULT_SECTIONS = ["overview", "architecture", "folder-structure", "api", "environment", "deployment", "contribution"] @@ -16,104 +16,235 @@ class DocumentationService: def __init__( self, repository: RepositoryRepository, - architecture: ArchitectureAnalyzer, - dependencies: DependencyGraphBuilder, - intelligence: RepositoryIntelligenceEngine | None = None, + snapshots: SnapshotQueryService, + owner_id: str, ) -> None: self.repository = repository - self.architecture = architecture - self.dependencies = dependencies - self.intelligence = intelligence or RepositoryIntelligenceEngine() + self.snapshots = snapshots + self.owner_id = owner_id def generate(self, request: GenerateDocRequest) -> GenerateDocResponse: - document = self._document(self._get_record(request.repository_id), request.sections) + record = self._get_record(request.repository_id) + projection = self.snapshots.product_projection(record.id) + document = self._document(record, projection, request.sections) content = render_html(document) if request.format == "html" else render_markdown(document) - return GenerateDocResponse(content=content, format=request.format, generated_at=datetime.now(UTC)) + return GenerateDocResponse( + content=content, + format=request.format, + generated_at=datetime.now(UTC), + source="ri.v1", + snapshot_id=projection.snapshot_id, + snapshot_schema_version=projection.schema_version, + revision_kind=projection.revision_kind, + revision_value=projection.revision_value, + revision_ref=projection.revision_ref, + ) def build_document(self, repository_id: str) -> ReportDocument: - return self._document(self._get_record(repository_id), None) + record = self._get_record(repository_id) + return self._document(record, self.snapshots.product_projection(record.id), None) def _get_record(self, repository_id: str): - record = self.repository.get(repository_id) + # Owner-scoped: another user's repository id resolves to None, yielding + # the same 404 as a missing one rather than confirming it exists. + record = self.repository.get_for_owner(repository_id, self.owner_id) if not record: raise NotFoundError("Repository not found.", {"repositoryId": repository_id}) return record - def _document(self, record, sections: list[str] | None) -> ReportDocument: + def _document( + self, + record, + projection: ProductSnapshotProjection, + sections: list[str] | None, + ) -> ReportDocument: selected = set(sections or DEFAULT_SECTIONS) - repository_intelligence = self.intelligence.from_record(record) - discovery = repository_intelligence.discovery - files = [file.path for file in repository_intelligence.files] + files = [file.path for file in projection.files] report_sections: list[Section] = [] if "overview" in selected: + source_files = sum(file.language is not None for file in projection.files) report_sections.append( Section( heading="Overview", fields=[ ("Source", record.source), - ("Primary language", discovery.primary_language), - ("Frameworks", ", ".join(discovery.frameworks) if discovery.frameworks else "Not detected"), - ("Files", str(discovery.statistics.total_files)), - ("Source files", str(discovery.statistics.source_files)), - ("Entry point", ", ".join(discovery.entry_points) if discovery.entry_points else "Not found"), + ("Primary language", projection.primary_language), + ("Frameworks", ", ".join(projection.frameworks) if projection.frameworks else "Not detected"), + ("Observed files", str(len(projection.files))), + ("Observed supported-language files", str(source_files)), + ( + "Entry point", + ", ".join(projection.entry_points) if projection.entry_points else "Not detected", + ), ], ) ) if "architecture" in selected: - architecture = self.architecture.build_architecture(record) - report_sections.append( - Section( - heading="Architecture", - fields=[("Pattern", architecture.architecture_type)], - bullets=[f"{layer.name}: {len(layer.nodes)} module(s)" for layer in architecture.detected_layers], - ) - ) + report_sections.append(self._architecture_section(projection)) if "folder-structure" in selected: report_sections.append(Section(heading="Folder Structure", bullets=files[:80] or ["No files detected."])) if "api" in selected: - api_files = [file.path for file in repository_intelligence.files if file.role in {"route", "controller"} or file.api_routes] - api_routes = [route for file in repository_intelligence.files for route in file.api_routes] + # A benchmark/test fixture that happens to declare a route (e.g. + # apps/backend/tests/benchmark/fixtures/.../service.py) is not + # part of this repository's actual API -- exclude anything whose + # every evidence path is test-classified rather than presenting + # it undistinguished from real routes (#337). + file_role_by_path = {file.path: file.role for file in projection.files} + real_routes = [ + route + for route in projection.routes + if not route.evidence_paths + or any(file_role_by_path.get(path) != "test" for path in route.evidence_paths) + ] + api_files = sorted( + {file.path for file in projection.files if file.role in {"route", "controller"}} + | {path for route in real_routes for path in route.evidence_paths} + ) report_sections.append( Section( heading="API", bullets=[f"File: {path}" for path in api_files[:30]] - + [f"Route: {route}" for route in api_routes[:30]] - or ["No API files detected."], + + [f"Observed route: {route.path}" for route in real_routes[:30]] + or ["No routes detected in the supported snapshot facts."], ) ) if "environment" in selected: + environment_files = sorted( + path + for path in files + if path.rsplit("/", 1)[-1].lower().startswith(".env") + or path.rsplit("/", 1)[-1].lower() in {"settings.py", "config.py", "config.ts", "config.js"} + ) report_sections.append( Section( heading="Environment", - bullets=discovery.environment_files[:30] or ["No environment configuration files detected."], + bullets=environment_files[:30] or ["No environment/configuration paths detected."], ) ) if "deployment" in selected: - deployment_files = sorted(set(discovery.docker_files + discovery.ci_files)) + # Grouped explicitly (not relying on `and` binding tighter than `or`): + # a Dockerfile or workflow path is deployment outright; a plain + # ".yml"/".yaml" path is deployment only alongside a deploy/Compose/CI + # token, so unrelated YAML (e.g. config, lint rules) is not misclassified. + deployment_files = sorted( + path + for path in files + if "dockerfile" in path.lower() + or path.lower().startswith(".github/workflows/") + or ( + path.lower().endswith((".yml", ".yaml")) + and any(token in path.lower() for token in ("deploy", "compose", "ci")) + ) + ) report_sections.append( - Section(heading="Deployment", bullets=deployment_files[:30] or ["No deployment files detected."]) + Section( + heading="Deployment", + bullets=deployment_files[:30] or ["No deployment-related paths detected."], + ) ) if "contribution" in selected: + ecosystems = sorted({item.ecosystem for item in projection.dependencies if item.ecosystem}) + manifests = sorted( + { + declaration.manifest_path + for dependency in projection.dependencies + for declaration in dependency.declarations + if declaration.manifest_path + } + ) report_sections.append( Section( heading="Contribution", fields=[ - ("Package managers", ", ".join(discovery.package_managers) if discovery.package_managers else "Not detected"), - ("Config files", ", ".join(discovery.configuration_files[:20]) if discovery.configuration_files else "Not detected"), + ("Dependency ecosystems", ", ".join(ecosystems) if ecosystems else "Not detected"), + ("Observed manifests", ", ".join(manifests[:20]) if manifests else "Not detected"), ], - paragraphs=["Add setup, test, and review instructions that match this repository before onboarding contributors."], + paragraphs=[ + "Setup, test, and review commands are not inferred from path inventory; verify repository instructions directly." + ], + bullets=[ + ( + f"{dependency.name}: " + f"{declaration.version or 'no version/specifier declared'}" + f" ({declaration.manifest_path or 'manifest path unavailable'})" + ) + for dependency in projection.dependencies + for declaration in dependency.declarations + ][:50] + or ["No supported dependency declarations detected."], ) ) return ReportDocument( title=record.name, - subtitle=f"Generated {datetime.now(UTC):%Y-%m-%d %H:%M UTC}", + subtitle=( + f"Sealed {projection.schema_version} snapshot {projection.snapshot_id}; " + f"revision {projection.revision_kind}:{projection.revision_value}" + ), sections=report_sections, ) + + @staticmethod + def _architecture_section(projection: ProductSnapshotProjection) -> Section: + """Layer modules by heuristic role and cross-layer edges by resolved snapshot facts. + + Layers are a re-labelling of the ``classified_as`` role already on each + module (heuristic, never a snapshot fact); relationships are counted + only from resolved file-to-file edges, so a repository with no + resolvable relationships reports none rather than inventing one. + """ + + layer_groups: dict[str, list[SnapshotModuleFact]] = defaultdict(list) + for module in projection.modules: + layer_groups[module.layer].append(module) + + layer_rows = [ + [ + layer.replace("-", " ").title(), + ", ".join(f"{module.name} ({module.role}, {len(module.paths)} file(s))" for module in modules), + str(sum(len(module.paths) for module in modules)), + ] + for layer, modules in sorted(layer_groups.items(), key=lambda item: LAYER_ORDER.get(item[0], 99)) + ] + + role_by_path = {file.path: file.role for file in projection.files} + cross_layer_counts: Counter[tuple[str, str]] = Counter() + for relationship in projection.file_relationships: + source_layer = layer_for_role(role_by_path.get(relationship.subject_path)) + target_layer = layer_for_role(role_by_path.get(relationship.object_path)) + if source_layer != target_layer: + cross_layer_counts[(source_layer, target_layer)] += 1 + + relationship_bullets = [ + f"{source.replace('-', ' ').title()} → {target.replace('-', ' ').title()}: {count} observed relationship(s)" + for (source, target), count in sorted( + cross_layer_counts.items(), + key=lambda item: (LAYER_ORDER.get(item[0][0], 99), LAYER_ORDER.get(item[0][1], 99)), + ) + ] + if not relationship_bullets: + relationship_bullets = ( + ["No cross-layer relationships observed in the supported snapshot facts."] + if layer_rows + else ["No modules detected."] + ) + + return Section( + heading="Architecture", + fields=[ + ( + "Basis", + "Modules are deterministic groupings of observed paths; layers and roles are heuristic " + "classifications; relationships are counted only from resolved snapshot edges.", + ) + ], + table=Table(headers=["Layer", "Modules", "Observed files"], rows=layer_rows) if layer_rows else None, + bullets=relationship_bullets, + ) diff --git a/apps/backend/app/services/evidence_service.py b/apps/backend/app/services/evidence_service.py new file mode 100644 index 00000000..b4a8e487 --- /dev/null +++ b/apps/backend/app/services/evidence_service.py @@ -0,0 +1,135 @@ +from __future__ import annotations + +from app.core.exceptions import NotFoundError, ValidationServiceError +from app.extraction.base import logical_line_count +from app.intelligence import canonical +from app.intelligence.query_service import SnapshotQueryService +from app.repositories.repository_repository import RepositoryRepository +from app.schemas.evidence import EvidenceSourceResponse +from app.services.repository_service import RepositoryService + + +class EvidenceSourceService: + """Serve evidence-citation source text bound to its exact cited snapshot (#95). + + A citation names a snapshot, fact, path, and line span. This service proves + both that the citation is stored and that the available file bytes match + the content hash sealed on that snapshot's file fact. It never substitutes + changed repository content, and it never silently narrows or widens the + requested span -- an unprovable citation returns an explicit + ``status="unavailable"`` instead. + """ + + def __init__( + self, + repository: RepositoryRepository, + snapshots: SnapshotQueryService, + files: RepositoryService, + owner_id: str, + ) -> None: + self.repository = repository + self.snapshots = snapshots + self.files = files + self.owner_id = owner_id + + def read( + self, + repository_id: str, + snapshot_id: str, + fact_id: str, + path: str, + start_line: int, + end_line: int, + ) -> EvidenceSourceResponse: + if start_line < 1 or end_line < start_line: + raise ValidationServiceError( + "Requested line range is invalid.", + {"startLine": start_line, "endLine": end_line}, + ) + + record = self._get_record(repository_id) + # metadata() is owner-scoped, requires a sealed ("completed") snapshot, + # and rejects an unsupported schema version -- the same guarantees + # every other snapshot-query consumer already relies on. + snapshot = self.snapshots.metadata(snapshot_id) + if snapshot.repository_id != repository_id: + # A real snapshot, but for a *different* repository: the same 404 + # as a missing one, so a cross-repository citation never + # discloses that the snapshot exists. + raise NotFoundError("Snapshot not found.", {"snapshotId": snapshot_id}) + + def unavailable(reason: str) -> EvidenceSourceResponse: + return EvidenceSourceResponse( + schema_version="evidence-source.v1", + repository_id=repository_id, + snapshot_id=snapshot_id, + fact_id=fact_id, + revision_kind=snapshot.revision_kind, + revision_value=snapshot.revision_value, + path=path, + start_line=start_line, + end_line=end_line, + status="unavailable", + reason=reason, + ) + + # `fk_ri_snapshots_repository_revision` (a composite foreign key from + # ri_snapshots(repository_id, revision_kind, revision_value) to + # repositories(id, revision_kind, revision_value)) already makes this + # divergence impossible at the database level for any snapshot still + # linked to its repository. This check stays as explicit + # defense-in-depth -- e.g. against a future schema change -- rather + # than relying solely on a constraint this code doesn't own. + if record.revision_kind != snapshot.revision_kind or record.revision_value != snapshot.revision_value: + return unavailable( + "The repository's current stored revision no longer matches the exact " + "revision this citation's snapshot was sealed against." + ) + + if not self.snapshots.has_evidence_reference(snapshot, fact_id, path, start_line, end_line): + return unavailable("This exact fact and source span are not part of the cited snapshot's evidence.") + + try: + # Reuses the existing owner-scoped path-traversal, binary, image, + # and size-truncation protections unchanged; a genuine path-escape + # attempt still raises ValidationServiceError (422) from here. + file_response = self.files.read_file(repository_id, path) + except NotFoundError: + return unavailable("The exact source for this revision is no longer available.") + + if file_response.is_binary or file_response.is_image: + return unavailable("The cited path is not a text source file.") + + line_count = logical_line_count(file_response.content) + if file_response.truncated or end_line > line_count: + return unavailable("The cited line span is outside the available source content.") + + sealed_content_hash = self.snapshots.source_content_hash(snapshot, path) + current_content_hash = canonical.sha256_prefixed(file_response.content.encode("utf-8")) + if sealed_content_hash is None or current_content_hash != sealed_content_hash: + return unavailable("The available source bytes do not match the content sealed into this snapshot.") + + return EvidenceSourceResponse( + schema_version="evidence-source.v1", + repository_id=repository_id, + snapshot_id=snapshot_id, + fact_id=fact_id, + revision_kind=snapshot.revision_kind, + revision_value=snapshot.revision_value, + path=path, + start_line=start_line, + end_line=end_line, + status="ready", + content=file_response.content, + truncated=file_response.truncated, + size=file_response.size, + ) + + def _get_record(self, repository_id: str): + # get_for_owner returns None both when the repository does not exist + # and when it belongs to another user, so a cross-user request gets + # the same 404 as a missing one and never learns the resource exists. + record = self.repository.get_for_owner(repository_id, self.owner_id) + if not record: + raise NotFoundError("Repository not found.", {"repositoryId": repository_id}) + return record diff --git a/apps/backend/app/services/oauth_service.py b/apps/backend/app/services/oauth_service.py new file mode 100644 index 00000000..3fd696e3 --- /dev/null +++ b/apps/backend/app/services/oauth_service.py @@ -0,0 +1,338 @@ +"""OAuth sign-in and account-linking business logic (#288). + +Credentials-deferred build: fully implemented and covered by tests using +clearly-fake mocked provider clients (app/auth/oauth_providers.py). See the +comment on issue #288 for exactly what still needs the owner's real client +id/secret and a decided public callback domain before this can go live. + +Linking rule enforced throughout: a matching email is never sufficient on +its own to sign into or link an existing account. A newly-discovered +identity whose verified email matches an existing account produces an +OAuthPendingLink and requires that account's password before the two are +connected (confirm_pending_link) -- there is no silent-merge path. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +from typing import Literal +from uuid import uuid4 + +from sqlalchemy import select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from app.auth.oauth_providers import ( + OAuthIdentityInfo, + OAuthProviderClient, + OAuthProviderError, + generate_nonce, + generate_pkce_pair, + generate_state, +) +from app.auth.security import burn_password_check, hash_refresh_token, verify_password +from app.auth.service import AuthService +from app.core.config import Settings +from app.core.exceptions import ConflictServiceError, NotFoundError, UnauthorizedError, ValidationServiceError +from app.models.oauth_flow_state import OAuthFlowState +from app.models.oauth_identity import OAuthIdentity +from app.models.oauth_pending_link import OAuthPendingLink +from app.models.user import SEED_USER_ID, User + +logger = logging.getLogger(__name__) + +# A real callback happens within seconds of the redirect; these bound how +# long an abandoned flow/pending-link lingers before it's simply unusable. +FLOW_TTL_SECONDS = 600 +PENDING_LINK_TTL_SECONDS = 600 + +INVALID_OAUTH_STATE = "This sign-in link has expired or was already used. Please try again." +PROVIDER_UNAVAILABLE = "This sign-in method isn't available right now." + + +def _as_utc(value: datetime) -> datetime: + # Same normalization as app.auth.service: SQLite hands back naive + # datetimes for DateTime(timezone=True) columns, Postgres hands back + # aware ones. + return value if value.tzinfo is not None else value.replace(tzinfo=UTC) + + +def _hash_state(raw: str) -> str: + # Same sha256-hex construction as refresh tokens: `state` is a bearer + # secret (the CSRF protection), so only its hash is stored. + return hash_refresh_token(raw) + + +@dataclass(frozen=True) +class OAuthLoginResult: + kind: Literal["session", "linked", "pending_link", "error"] + user: User | None = None + access_token: str | None = None + refresh_token: str | None = None + pending_link_id: str | None = None + error_code: str | None = None + + +class OAuthService: + def __init__( + self, + db: Session, + settings: Settings, + auth_service: AuthService, + providers: dict[str, OAuthProviderClient], + ) -> None: + self.db = db + self.settings = settings + self.auth_service = auth_service + self.providers = providers + + def configured_providers(self) -> list[str]: + return [name for name, client in self.providers.items() if client.is_configured()] + + def redirect_uri(self, provider: str) -> str: + # Fixed per (deployment, provider) -- exactly what must be registered + # with the provider's console -- so it is derived, never stored per + # flow; the /start and callback routes always agree by construction. + return f"{self.settings.oauth_public_base_url.rstrip('/')}/auth/oauth/{provider}/callback" + + def _require_client(self, provider: str) -> OAuthProviderClient: + client = self.providers.get(provider) + if client is None or not client.is_configured(): + raise ValidationServiceError(PROVIDER_UNAVAILABLE) + return client + + def start( + self, + provider: str, + *, + intent: Literal["login", "link"], + frontend_redirect_base: str, + link_user_id: str | None = None, + ) -> str: + client = self._require_client(provider) + state = generate_state() + code_verifier, code_challenge = generate_pkce_pair() + nonce = generate_nonce() + now = datetime.now(UTC) + self.db.add( + OAuthFlowState( + id=str(uuid4()), + state_hash=_hash_state(state), + provider=provider, + code_verifier=code_verifier, + nonce=nonce, + intent=intent, + link_user_id=link_user_id, + frontend_redirect_base=frontend_redirect_base, + created_at=now, + expires_at=now + timedelta(seconds=FLOW_TTL_SECONDS), + ) + ) + self.db.commit() + return client.authorize_url( + redirect_uri=self.redirect_uri(provider), state=state, code_challenge=code_challenge, nonce=nonce + ) + + async def complete_callback( + self, provider: str, *, state: str, code: str | None, provider_error: str | None + ) -> tuple[str, OAuthLoginResult]: + """Consume a single-use flow state and resolve it to an outcome. + + The flow row is deleted the moment it's read, success or failure, so + a replayed callback can never reuse a state value. Raises only for a + state this server never issued (or already consumed) -- every other + failure (provider denial, exchange failure, unverified email, an + email collision) comes back as an ``OAuthLoginResult(kind="error")`` + so the caller can still redirect the browser back to the frontend + that started the flow. + """ + flow = self.db.scalars(select(OAuthFlowState).where(OAuthFlowState.state_hash == _hash_state(state))).first() + if flow is None or _as_utc(flow.expires_at) <= datetime.now(UTC): + raise ValidationServiceError(INVALID_OAUTH_STATE) + + frontend_redirect_base = flow.frontend_redirect_base + flow_intent = flow.intent + flow_link_user_id = flow.link_user_id + code_verifier = flow.code_verifier + nonce = flow.nonce + self.db.delete(flow) + self.db.commit() + + if provider_error is not None: + logger.info("OAuth flow denied or cancelled", extra={"provider": provider, "reason": provider_error}) + return frontend_redirect_base, OAuthLoginResult(kind="error", error_code=provider_error) + if not code: + return frontend_redirect_base, OAuthLoginResult(kind="error", error_code="missing_code") + + try: + client = self._require_client(provider) + except ValidationServiceError: + return frontend_redirect_base, OAuthLoginResult(kind="error", error_code="provider_unavailable") + + try: + identity = await client.resolve_identity( + code=code, redirect_uri=self.redirect_uri(provider), code_verifier=code_verifier, nonce=nonce + ) + except OAuthProviderError: + logger.warning("OAuth provider exchange failed", exc_info=True, extra={"provider": provider}) + return frontend_redirect_base, OAuthLoginResult(kind="error", error_code="exchange_failed") + + if flow_intent == "link": + result = self._complete_link(provider, identity, flow_link_user_id) + else: + result = self._complete_login(provider, identity) + return frontend_redirect_base, result + + def _complete_login(self, provider: str, identity: OAuthIdentityInfo) -> OAuthLoginResult: + existing = self.db.scalars( + select(OAuthIdentity).where( + OAuthIdentity.provider == provider, OAuthIdentity.provider_subject == identity.subject + ) + ).first() + if existing is not None: + user = self.db.get(User, existing.user_id) + if user is None or not user.is_active or user.id == SEED_USER_ID: + return OAuthLoginResult(kind="error", error_code="account_unavailable") + db_user, access_token, refresh_token = self.auth_service.open_session(user) + return OAuthLoginResult( + kind="session", user=db_user, access_token=access_token, refresh_token=refresh_token + ) + + # No linked identity yet. An email match against an existing account + # is never sufficient on its own to sign into it -- only to offer a + # password-confirmed link, and only when the provider itself has + # verified that email (an unverified email is not proof of anything). + if identity.email and identity.email_verified: + matched_user = self.db.scalars(select(User).where(User.email == identity.email.strip().lower())).first() + if matched_user is not None and matched_user.id != SEED_USER_ID: + now = datetime.now(UTC) + pending = OAuthPendingLink( + id=str(uuid4()), + provider=provider, + provider_subject=identity.subject, + email=matched_user.email, + display_name=identity.display_name, + created_at=now, + expires_at=now + timedelta(seconds=PENDING_LINK_TTL_SECONDS), + ) + self.db.add(pending) + self.db.commit() + return OAuthLoginResult(kind="pending_link", pending_link_id=pending.id) + + # No existing account to sign into or link. A brand-new account is + # only ever created here if the verified provider email is itself on + # the same admin-managed allowlist that gates password registration + # (#374) -- AuthService.register_oauth_user() is the exact same + # approval-gate-and-audit-trail code path as AuthService.register(), + # just without a password. Anything else (unapproved, or no + # verified email at all) is refused; OAuth is never a second, looser + # door into the product than password registration is. + if not identity.email or not identity.email_verified: + return OAuthLoginResult(kind="error", error_code="email_not_approved") + try: + db_user, access_token, refresh_token = self.auth_service.register_oauth_user(identity.email) + except ValidationServiceError: + return OAuthLoginResult(kind="error", error_code="email_not_approved") + except ConflictServiceError: + # Lost a concurrent-registration race for this exact email + # (vanishingly rare, same class of race AuthService.register() + # itself guards against) -- nothing left to do but report it. + return OAuthLoginResult(kind="error", error_code="email_already_registered") + + # The account now exists; record the identity that created it so a + # later sign-in with this same provider account reuses it instead of + # re-running the approval check (the "existing identity" branch at + # the top of this method). + self.db.add( + OAuthIdentity( + id=str(uuid4()), + user_id=db_user.id, + provider=provider, + provider_subject=identity.subject, + email=identity.email, + created_at=datetime.now(UTC), + ) + ) + self.db.commit() + return OAuthLoginResult(kind="session", user=db_user, access_token=access_token, refresh_token=refresh_token) + + def _complete_link(self, provider: str, identity: OAuthIdentityInfo, link_user_id: str | None) -> OAuthLoginResult: + if not link_user_id: + return OAuthLoginResult(kind="error", error_code="missing_link_target") + user = self.db.get(User, link_user_id) + if user is None or not user.is_active: + return OAuthLoginResult(kind="error", error_code="account_unavailable") + self.db.add( + OAuthIdentity( + id=str(uuid4()), + user_id=user.id, + provider=provider, + provider_subject=identity.subject, + email=identity.email, + created_at=datetime.now(UTC), + ) + ) + try: + self.db.commit() + except IntegrityError: + # Either this external identity is already linked to a different + # PARTHA account, or this account already has a provider + # identity linked -- both are the unique constraints on + # OAuthIdentity, not distinguished further here. + self.db.rollback() + return OAuthLoginResult(kind="error", error_code="already_linked") + return OAuthLoginResult(kind="linked", user=user) + + def confirm_pending_link(self, pending_link_id: str, password: str) -> tuple[User, str, str]: + now = datetime.now(UTC) + pending = self.db.get(OAuthPendingLink, pending_link_id) + if pending is None or _as_utc(pending.expires_at) <= now: + burn_password_check(password) + raise UnauthorizedError(INVALID_OAUTH_STATE) + + user = self.db.scalars(select(User).where(User.email == pending.email)).first() + if user is None or user.password_hash is None: + burn_password_check(password) + raise UnauthorizedError(INVALID_OAUTH_STATE) + if not verify_password(user.password_hash, password) or not user.is_active: + raise UnauthorizedError(INVALID_OAUTH_STATE) + + self.db.add( + OAuthIdentity( + id=str(uuid4()), + user_id=user.id, + provider=pending.provider, + provider_subject=pending.provider_subject, + email=pending.email, + created_at=now, + ) + ) + self.db.delete(pending) + try: + self.db.commit() + except IntegrityError: + self.db.rollback() + raise ConflictServiceError("This provider account is already linked to a different account.") from None + return self.auth_service.open_session(user) + + def linked_identities(self, user_id: str) -> list[OAuthIdentity]: + return list(self.db.scalars(select(OAuthIdentity).where(OAuthIdentity.user_id == user_id)).all()) + + def unlink(self, user: User, provider: str) -> None: + identity = self.db.scalars( + select(OAuthIdentity).where(OAuthIdentity.user_id == user.id, OAuthIdentity.provider == provider) + ).first() + if identity is None: + raise NotFoundError(f"No linked {provider} account.", {"provider": provider}) + if user.password_hash is None: + remaining = self.db.scalars( + select(OAuthIdentity).where(OAuthIdentity.user_id == user.id, OAuthIdentity.provider != provider) + ).first() + if remaining is None: + raise ValidationServiceError( + "This is your only way to sign in to this account. Link another provider before removing it." + ) + self.db.delete(identity) + self.db.commit() diff --git a/apps/backend/app/services/repository_service.py b/apps/backend/app/services/repository_service.py index f6257f76..89c8c911 100644 --- a/apps/backend/app/services/repository_service.py +++ b/apps/backend/app/services/repository_service.py @@ -1,4 +1,6 @@ import base64 +import hashlib +import re from datetime import UTC, datetime from pathlib import Path from typing import NoReturn @@ -9,20 +11,28 @@ from app.core.config import Settings from app.core.exceptions import ConflictServiceError, NotFoundError, ServiceError, ValidationServiceError from app.github.client import GitHubClient -from app.intelligence.engine import RepositoryIntelligenceEngine from app.models.repository import RepositoryRecord -from app.parsers.repository_parser import RepositoryParser -from app.repositories.repository_repository import RepositoryRepository +from app.parsers.repository_parser import RepositoryFileLimitExceeded, RepositoryParser, UnsafeRepositoryPath +from app.repositories.repository_repository import LineageDuplicateRevision, RepositoryRepository from app.schemas.repository import ( + FileTreeNode, GitHubImportRequest, RepositoryFileResponse, + RepositoryLineageEntry, + RepositoryLineageResponse, RepositoryListResponse, + RepositoryMeta, RepositoryResponse, + RepositoryRevision, ) from app.storage.local import LocalStorage MAX_FILE_PREVIEW_BYTES = 512 * 1024 +# A git object name is a 40-character lowercase hex SHA-1 (RFC §3.2). ri.v1 +# targets the SHA-1 default git produces today. +GIT_SHA_RE = re.compile(r"^[0-9a-f]{40}$") + IMAGE_MEDIA_TYPES = { ".png": "image/png", ".jpg": "image/jpeg", @@ -42,88 +52,185 @@ def __init__( storage: LocalStorage, github: GitHubClient, parser: RepositoryParser, - intelligence: RepositoryIntelligenceEngine, settings: Settings, + owner_id: str, ) -> None: self.repository = repository self.storage = storage self.github = github self.parser = parser - self.intelligence = intelligence self.settings = settings + self.owner_id = owner_id def list_repositories(self) -> RepositoryListResponse: - records = self.repository.list() + records = self.repository.list_for_owner(self.owner_id) return RepositoryListResponse(data=[self.to_response(record) for record in records], total=len(records)) def get_repository(self, repository_id: str) -> RepositoryResponse: return self.to_response(self._get_record(repository_id)) + def get_lineage(self, repository_id: str) -> RepositoryLineageResponse: + """The history behind `repository_id` (#299, RFC-0002; #400). + + A standalone import (an upload, or a GitHub import whose ref never + resolved -- RFC §4.3/§6) has no lineage row to read: this returns + `is_lineaged=False` and a one-entry history containing only the + requested repository, rather than fabricating a lineage or a 404. + """ + + record = self._get_record(repository_id) + if record.lineage_id is None: + return RepositoryLineageResponse( + is_lineaged=False, + entries=[self._lineage_entry(record, current_id=repository_id)], + ) + lineage = self.repository.get_lineage_for_owner(record.lineage_id, self.owner_id) + # Defensive only: a repository's lineage_id always names a lineage row + # owned by that same repository's owner (the DB-level composite FK + # ties them together), so this is never actually None in production. + assert lineage is not None + members = self.repository.list_lineage_members(record.lineage_id, self.owner_id) + return RepositoryLineageResponse( + is_lineaged=True, + lineage_id=lineage.id, + canonical_source_key=lineage.canonical_source_key, + canonical_branch=lineage.canonical_branch, + entries=[self._lineage_entry(member, current_id=repository_id) for member in members], + ) + + def _lineage_entry(self, record: RepositoryRecord, *, current_id: str) -> RepositoryLineageEntry: + revision = None + if record.revision_kind and record.revision_value: + revision = RepositoryRevision( + kind=record.revision_kind, value=record.revision_value, ref=record.revision_ref + ) + return RepositoryLineageEntry( + repository_id=record.id, + sequence=record.sequence, + name=record.name, + status=record.status, + revision=revision, + uploaded_at=record.uploaded_at, + is_current=record.id == current_id, + ) + def delete_repository(self, repository_id: str) -> None: record = self._get_record(repository_id) - self.storage.delete_repository(record.local_path) - self.repository.delete(record) + local_path = record.local_path + # DB transaction (including any lineage latest-pointer rollback, #299 + # §8.3) commits before the filesystem path is removed, so a DB + # failure can never leave a database row whose source directory has + # already vanished. + self.repository.delete_with_lineage_update(record) + self.storage.delete_repository(local_path) def import_github_repository(self, request: GitHubImportRequest) -> RepositoryResponse: repository_id = str(uuid4()) url = self.github.validate_public_url(str(request.url)) branch = self.github.validate_branch(request.branch) - existing = self.repository.find_by_source(url, branch) - if existing: - raise ConflictServiceError( - "Repository has already been imported.", - {"repositoryId": existing.id, "name": existing.name}, - ) + # A new commit is a new revision, so duplicate detection is keyed on the + # resolved commit SHA rather than URL+branch (#87). That requires cloning + # first: URL+branch is only a fallback when git identity is unavailable, + # and it must never block importing a genuinely new revision. Duplicate + # detection itself happens later, transactionally, scoped to the + # resolved ref's lineage (#299 §5.2) -- not here, and deliberately not + # by (source_url, revision_value, owner) alone, since that would also + # reject the same commit legitimately re-imported under a different + # branch (#299 §8.1), which must succeed. destination = self.storage.reset_repository_path(repository_id) try: self.github.clone_public_repository(url, destination, branch) root = self._resolve_repository_root(destination) - tree, meta, total_size = self.parser.parse(root) + commit_sha = self.github.read_head_commit(destination) + revision_kind, revision_value, revision_ref = self._git_revision(destination, commit_sha, branch) + tree, meta, total_size = self._parse_repository(root) self._validate_parsed_repository(meta.total_files) - repository_intelligence = self.intelligence.build(repository_id, self.github.repository_name(url), root, tree, meta, total_size) except Exception: self.storage.delete_repository_id(repository_id) raise now = datetime.now(UTC) + name = self.github.repository_name(url) record = RepositoryRecord( id=repository_id, - name=self.github.repository_name(url), + owner_id=self.owner_id, + name=name, description=None, source="github", source_url=url, branch=branch, + revision_kind=revision_kind, + revision_value=revision_value, + revision_ref=revision_ref, local_path=str(root), size=total_size, file_count=meta.total_files, status="analysing", - data_source="real", analysis_stage="building-file-tree", analysis_progress=70, uploaded_at=now, analysed_at=None, - repo_metadata=self._metadata_with_intelligence(meta, repository_intelligence), + repo_metadata=meta.model_dump(mode="json", by_alias=True), file_tree=[node.model_dump(mode="json", by_alias=True, exclude_none=True) for node in tree], ) - return self.to_response(self.repository.add(record)) + # A resolved ref is guaranteed here (`_git_revision` above raises + # otherwise), so every live GitHub import always gets a canonical + # pair and therefore a lineage (#299 §8.1) -- unlineaged standalone + # GitHub rows are only a backfill-time legacy case, never live. + try: + persisted = self.repository.add_with_lineage( + record, + owner_id=self.owner_id, + canonical_source_key=self._canonical_github_source(url), + canonical_branch=revision_ref, + display_name=name, + ) + except LineageDuplicateRevision as exc: + self.storage.delete_repository_id(repository_id) + raise ConflictServiceError( + "Repository has already been imported.", + {"repositoryId": exc.existing.id, "name": exc.existing.name}, + ) from exc + except Exception: + self.storage.delete_repository_id(repository_id) + raise + return self.to_response(persisted) + + def _canonical_github_source(self, url: str) -> str: + """Owner-scoped lineage grouping key for an already-validated live URL. + + `url` is already normalized by `GitHubClient.validate_public_url` to + exactly ``https://github.com//`` (no trailing slash or + ``.git``); only case-folding the owner/repo remains (#299 §8.1). This + is deliberately simpler than the migration's own backfill parser, + which must additionally accept looser historical forms -- the two are + intentionally not shared code, so a future change to this live parser + can never silently change what the frozen backfill migration does. + """ + owner, repo = url.removeprefix("https://github.com/").split("/", 1) + return f"github.com/{owner.lower()}/{repo.lower()}" async def import_uploaded_repository(self, file: UploadFile) -> RepositoryResponse: repository_id = str(uuid4()) repository_name = self._repository_name_from_archive(file.filename or repository_id) - existing = self.repository.find_by_name(repository_name) - if existing: - raise ConflictServiceError( - "Repository has already been imported.", - {"repositoryId": existing.id, "name": existing.name}, - ) archive_path = await self.storage.save_upload(repository_id, file, self.settings.max_upload_size_bytes) try: + # Uploads have no git history, so the immutable content hash is the + # revision. Duplicate detection is keyed on that content hash (#87), + # not the filename: a genuinely new archive is a new revision even + # if it is uploaded under a previously-used name. + content_hash = self._content_hash_for_upload(archive_path) + existing = self.repository.find_by_revision_for_owner(content_hash, self.owner_id) + if existing: + raise ConflictServiceError( + "Repository has already been imported.", + {"repositoryId": existing.id, "name": existing.name}, + ) root = self.storage.extract_archive(archive_path, repository_id) - tree, meta, total_size = self.parser.parse(root) + tree, meta, total_size = self._parse_repository(root) self._validate_parsed_repository(meta.total_files) - repository_intelligence = self.intelligence.build(repository_id, repository_name, root, tree, meta, total_size) except Exception: self.storage.delete_upload(archive_path) self.storage.delete_repository_id(repository_id) @@ -131,23 +238,30 @@ async def import_uploaded_repository(self, file: UploadFile) -> RepositoryRespon self.storage.delete_upload(archive_path) now = datetime.now(UTC) + # No lineage_id/sequence set (both stay null): uploads are always + # unlineaged standalone imports (#299 §8.2/§4.3) -- nothing here + # proves two archives are revisions of the same logical repository, + # so this never creates or searches a lineage. record = RepositoryRecord( id=repository_id, + owner_id=self.owner_id, name=repository_name, description=None, source="upload", source_url=None, branch=None, + revision_kind="upload", + revision_value=content_hash, + revision_ref=None, local_path=str(root), size=total_size, file_count=meta.total_files, status="analysing", - data_source="real", analysis_stage="building-file-tree", analysis_progress=70, uploaded_at=now, analysed_at=None, - repo_metadata=self._metadata_with_intelligence(meta, repository_intelligence), + repo_metadata=meta.model_dump(mode="json", by_alias=True), file_tree=[node.model_dump(mode="json", by_alias=True, exclude_none=True) for node in tree], ) return self.to_response(self.repository.add(record)) @@ -219,6 +333,13 @@ def _raise_file_read_error(self, path: str, exc: OSError) -> NoReturn: raise ServiceError("Unable to read file preview.", {"path": path}) from exc def to_response(self, record: RepositoryRecord) -> RepositoryResponse: + revision = None + if record.revision_kind and record.revision_value: + revision = RepositoryRevision( + kind=record.revision_kind, + value=record.revision_value, + ref=record.revision_ref, + ) return RepositoryResponse( id=record.id, name=record.name, @@ -229,18 +350,24 @@ def to_response(self, record: RepositoryRecord) -> RepositoryResponse: size=record.size, file_count=record.file_count, status=record.status, - data_source=record.data_source, analysis_stage=record.analysis_stage, analysis_progress=record.analysis_progress, uploaded_at=record.uploaded_at, analysed_at=record.analysed_at, error_message=record.error_message, + revision=revision, + # Revision identity now comes from the first-class column, not the + # mutable metadata blob (#87). ``commit_sha`` is a compatibility alias. + commit_sha=record.revision_value, meta=record.repo_metadata, file_tree=record.file_tree, ) def _get_record(self, repository_id: str) -> RepositoryRecord: - record = self.repository.get(repository_id) + # get_for_owner returns None both when the repository does not exist and + # when it belongs to another user, so a cross-user request gets the same + # 404 as a missing one and never learns the resource exists. + record = self.repository.get_for_owner(repository_id, self.owner_id) if not record: raise NotFoundError("Repository not found.", {"repositoryId": repository_id}) return record @@ -255,13 +382,54 @@ def _validate_parsed_repository(self, total_files: int) -> None: if total_files == 0: raise ValidationServiceError("Repository archive does not contain any readable files.") + def _parse_repository(self, root: Path) -> tuple[list[FileTreeNode], RepositoryMeta, int]: + try: + return self.parser.parse(root, max_file_count=self.settings.max_file_count) + except RepositoryFileLimitExceeded as exc: + raise ValidationServiceError( + "Repository exceeds the configured maximum file count.", + {"maxFileCount": exc.max_file_count, "fileCount": exc.file_count}, + ) from exc + except UnsafeRepositoryPath as exc: + # Matches the archive-upload posture (storage/local.py rejects any + # symlink/link/device member in a TAR before extraction): a + # GitHub-cloned checkout containing a symlink is rejected outright + # rather than partially imported, since a symlink here can point + # outside the checkout entirely (issue: unguarded symlink follow + # in the file-tree walk). + raise ValidationServiceError( + "Repository contains a symlink, which is not supported.", + {"path": exc.relative_path}, + ) from exc + def _repository_name_from_archive(self, filename: str) -> str: for suffix in (".tar.gz", ".tgz", ".zip", ".tar", ".gz"): if filename.lower().endswith(suffix): return filename[: -len(suffix)] return Path(filename).stem - def _metadata_with_intelligence(self, meta, intelligence) -> dict: - metadata = meta.model_dump(mode="json", by_alias=True) - metadata["intelligence"] = intelligence.model_dump(mode="json", by_alias=True) - return metadata + def _git_revision( + self, + destination: Path, + commit_sha: str | None, + requested_ref: str | None, + ) -> tuple[str, str, str]: + """Return ``(kind, value, ref)`` for a GitHub import (RFC §3.2). + + New imports must always have both the immutable commit and the resolved + ref. Missing legacy identity is handled only by the migration; silently + creating a new repository without identity would violate RFC §3.2. + """ + if not commit_sha or not GIT_SHA_RE.fullmatch(commit_sha): + raise ServiceError("Unable to determine an immutable Git commit for the imported repository.") + resolved_ref = self.github.read_head_ref(destination, requested_ref) + if not resolved_ref or not resolved_ref.startswith("refs/"): + raise ServiceError("Unable to determine the resolved Git ref for the imported repository.") + return "git", commit_sha, resolved_ref + + def _content_hash_for_upload(self, archive_path: Path) -> str: + digest = hashlib.sha256() + with archive_path.open("rb") as handle: + while chunk := handle.read(1024 * 1024): + digest.update(chunk) + return f"sha256:{digest.hexdigest()}" diff --git a/apps/backend/app/storage/local.py b/apps/backend/app/storage/local.py index fb6ed7e9..b8e571fa 100644 --- a/apps/backend/app/storage/local.py +++ b/apps/backend/app/storage/local.py @@ -7,6 +7,7 @@ from app.core.config import Settings from app.core.exceptions import ValidationServiceError +from app.parsers.repository_parser import is_macos_artifact class LocalStorage: @@ -16,6 +17,11 @@ def __init__(self, settings: Settings) -> None: self.uploads_root = self.root / "uploads" self.repositories_root.mkdir(parents=True, exist_ok=True) self.uploads_root.mkdir(parents=True, exist_ok=True) + # Bound decompressed size and member count while iterating archive + # members during extraction (see _safe_extract_zip/_safe_extract_tar), + # so a zip/tar-bomb is rejected before it is ever written to disk. + self.max_extracted_size_bytes = settings.max_extracted_size_bytes + self.max_extracted_entries = settings.max_extracted_entries def repository_path(self, repository_id: str) -> Path: return self.repositories_root / repository_id @@ -41,7 +47,9 @@ def delete_upload(self, archive_path: Path) -> None: archive_path.unlink(missing_ok=True) async def save_upload(self, repository_id: str, file: UploadFile, max_size_bytes: int) -> Path: - upload_path = self.uploads_root / f"{repository_id}-{file.filename or 'repository'}" + # Never build the stored path from the client-supplied filename: derive it + # solely from the server-generated UUID plus a validated archive suffix. + upload_path = self.uploads_root / f"{repository_id}{self._safe_archive_suffix(file.filename)}" total = 0 with upload_path.open("wb") as destination: while chunk := await file.read(1024 * 1024): @@ -52,17 +60,36 @@ async def save_upload(self, repository_id: str, file: UploadFile, max_size_bytes destination.write(chunk) return upload_path + SAFE_ARCHIVE_SUFFIXES = (".tar.gz", ".tgz", ".tar", ".zip", ".gz") + + def _safe_archive_suffix(self, filename: str | None) -> str: + """Return an allowlisted archive suffix from a client filename, or "". + + The suffix is used only as a cosmetic hint on the stored file; archive + type detection is content-based (see extract_archive), so an empty + suffix is safe. No path separators or traversal can survive this. + """ + if not filename: + return "" + lowered = filename.lower() + for suffix in self.SAFE_ARCHIVE_SUFFIXES: + if lowered.endswith(suffix): + return suffix + return "" + def extract_archive(self, archive_path: Path, repository_id: str) -> Path: destination = self.reset_repository_path(repository_id) try: if zipfile.is_zipfile(archive_path): with zipfile.ZipFile(archive_path) as archive: self._safe_extract_zip(archive, destination) + self._strip_macos_artifacts(destination) return self._normalise_single_root(destination) if tarfile.is_tarfile(archive_path): with tarfile.open(archive_path) as archive: self._safe_extract_tar(archive, destination) + self._strip_macos_artifacts(destination) return self._normalise_single_root(destination) except (zipfile.BadZipFile, tarfile.TarError, OSError) as exc: raise ValidationServiceError("Archive is corrupted or cannot be extracted.") from exc @@ -70,23 +97,83 @@ def extract_archive(self, archive_path: Path, repository_id: str) -> Path: raise ValidationServiceError("Unsupported archive format. Upload a ZIP or TAR archive.") def _safe_extract_zip(self, archive: zipfile.ZipFile, destination: Path) -> None: - for member in archive.infolist(): + total_size = 0 + for entry_index, member in enumerate(archive.infolist(), start=1): target = destination / member.filename if not self._is_safe_child(destination, target): raise ValidationServiceError("Archive contains unsafe paths.") + if entry_index > self.max_extracted_entries: + raise ValidationServiceError( + "Archive contains more entries than the configured maximum.", + {"maxExtractedEntries": self.max_extracted_entries}, + ) + # ZipInfo.file_size is the member's declared decompressed size, so + # this rejects an oversized member using its own metadata, before + # any of its bytes are written to disk. + total_size += member.file_size + if total_size > self.max_extracted_size_bytes: + raise ValidationServiceError( + "Archive would decompress to more than the configured maximum size.", + {"maxExtractedSizeBytes": self.max_extracted_size_bytes}, + ) archive.extractall(destination) def _safe_extract_tar(self, archive: tarfile.TarFile, destination: Path) -> None: - for member in archive.getmembers(): + total_size = 0 + for entry_index, member in enumerate(archive.getmembers(), start=1): if member.issym() or member.islnk() or member.isdev(): raise ValidationServiceError("Archive contains unsupported link or device entries.") target = destination / member.name if not self._is_safe_child(destination, target): raise ValidationServiceError("Archive contains unsafe paths.") - archive.extractall(destination) + if entry_index > self.max_extracted_entries: + raise ValidationServiceError( + "Archive contains more entries than the configured maximum.", + {"maxExtractedEntries": self.max_extracted_entries}, + ) + # TarInfo.size is the member's declared decompressed size, checked + # the same way as the zip path above: reject before extraction. + total_size += member.size + if total_size > self.max_extracted_size_bytes: + raise ValidationServiceError( + "Archive would decompress to more than the configured maximum size.", + {"maxExtractedSizeBytes": self.max_extracted_size_bytes}, + ) + # `filter="data"` applies CPython's own extraction hardening: it strips + # absolute paths and `..` traversal, and rejects links, devices, setuid + # bits and other unsafe metadata as the members are written. + # + # The loop above already rejects those cases, so this is defence in + # depth on untrusted uploads rather than the primary control — the two + # have to disagree for it to matter, which is exactly when a check is + # worth having. It also settles the DeprecationWarning: tar extraction + # is unfiltered by default until Python 3.14, which would switch this + # behaviour on silently. Being explicit keeps it a decision. + archive.extractall(destination, filter="data") + + def _strip_macos_artifacts(self, destination: Path) -> None: + """Delete Finder/Archive Utility artifacts an archive may carry (#397). + + RepositoryParser already excludes these from the persisted file tree + (so they never reach the extraction pipeline or the UI), but that + leaves them sitting on disk in the repository's own storage tree. + Removing them here too keeps that tree honest, not just what's + derived from it. + """ + + if destination.name == "__MACOSX" or is_macos_artifact(destination.name): + if destination.is_dir(): + shutil.rmtree(destination) + else: + destination.unlink() + return + if not destination.is_dir(): + return + for child in list(destination.iterdir()): + self._strip_macos_artifacts(child) def _normalise_single_root(self, destination: Path) -> Path: - children = [child for child in destination.iterdir() if child.name != "__MACOSX"] + children = list(destination.iterdir()) if len(children) == 1 and children[0].is_dir(): return children[0] return destination diff --git a/apps/backend/app/workers/analysis_worker.py b/apps/backend/app/workers/analysis_worker.py new file mode 100644 index 00000000..a3a455c2 --- /dev/null +++ b/apps/backend/app/workers/analysis_worker.py @@ -0,0 +1,1003 @@ +"""Durable analysis-job execution (#93). + +``AnalysisWorker`` claims one queued ``analysis_jobs`` row at a time and runs the +full analysis off the request path through the evidence-backed extraction +pipeline that seals the repository's authoritative ``ri.v1`` snapshot. + +``run_once`` is the primary unit both ``AnalysisWorkerRunner`` and the test suite +drive; it is synchronous and deterministic. It claims at most one job *through +the control plane* (``app.workers.control_plane``), runs the stages with +cooperative cancellation checks within and between them, and applies a bounded +exponential backoff on failure before finally marking the job ``failed``. + +Boundary note (#324): this class no longer decides **who owns a job**. Claiming, +lease renewal, expiry, reclaim and every ownership guard are +``AnalysisControlPlane`` calls, and the loop that drives ``run_once`` lives in +``app.workers.runner``. What remains here is execution: turning a job this +worker *already owns* into a sealed ``ri.v1`` snapshot, and deciding its +terminal transition. Keep it that way -- queue policy belongs on the other side +of the boundary, and repository analysis belongs on this side of it. + +Transactional note (deliberate design, not a gap to "fix"): ``SnapshotStore.seal`` +owns its own commit boundary (``snapshot_store.py`` ``_commit_transition``) and +there is no way to merge that commit with the job row's ``status='completed'`` +commit without modifying the sealed #88 store (out of scope). Sealing and +job-completion are therefore two separate commits, not one atomic transaction. +This is made safe by *idempotent reconciliation*, not single-phase atomicity: if +the process dies between the two commits, the job is left ``running`` with a +lease that will expire, and the stale-job sweep (Task 4) reconciles by checking +``SnapshotStore.find_completed_for_owner`` for a matching identity — finding the +already-sealed snapshot, it marks the job ``completed`` pointing at it rather +than redoing the work. Do not collapse these two commits into one. +""" + +from __future__ import annotations + +import logging +import threading +from collections.abc import Callable, Iterator +from contextlib import contextmanager +from dataclasses import dataclass, field +from datetime import UTC, datetime, timedelta +from pathlib import Path + +from sqlalchemy.orm import Session + +from app.analysis.resource_budget import ( + DEFAULT_MAX_ANALYSIS_SECONDS, + DEFAULT_MAX_PROCESS_RSS_BYTES, + DEFAULT_MAX_REPOSITORY_SOURCE_BYTES, + RESOURCE_EXCEEDED_CODE, + AnalysisResourceBudget, + AnalysisResourceExceeded, +) +from app.analysis.source_stream import RepositorySourceStream +from app.extraction.base import ExtractedEvidence +from app.extraction.dependencies import ( + DEPENDENCY_SET_ARRAY_KEYS, + merge_dependency_facts, +) +from app.extraction import production_extractors +from app.extraction.pipeline import ( + DEFAULT_MAX_SOURCE_BYTES, + ExtractionPipeline, + ProducedExtraction, +) +from app.intelligence.classification import RoleClassifier +from app.intelligence.resolution import RelationshipResolver +from app.intelligence.snapshot_store import Evidence, Revision, SnapshotStore +from app.models.analysis_job import AnalysisJob +from app.models.repository import RepositoryRecord +from app.models.snapshot import RiSnapshot +from app.services.analysis_job_service import ( + ANALYSIS_CONFIG_HASH, + ANALYSIS_PRODUCER_VERSION_SET, + ANALYSIS_SCHEMA_VERSION, +) +from app.workers.control_plane import ( + AnalysisControlPlane, + DatabaseAnalysisControlPlane, + JobLease, + lease_expired, +) + +_MAX_ERROR_MESSAGE = 1024 +_ERROR_CODE = "RI-JOB-FAILED" +_CANCELLED_CODE = "RI-JOB-CANCELLED" +_STALE_CODE = "RI-JOB-STALE" +_STALE_MESSAGE = "Analysis worker lease expired before the job completed." + +logger = logging.getLogger(__name__) + + +class _LeaseLostError(RuntimeError): + """Internal control flow for a job reclaimed by another worker.""" + + +class _CancellationObserved(RuntimeError): + """Internal control flow for cancellation observed by a heartbeat.""" + + +@dataclass +class _HeartbeatState: + """Thread-safe signals returned by one stage heartbeat.""" + + stop: threading.Event = field(default_factory=threading.Event) + ownership_lost: threading.Event = field(default_factory=threading.Event) + cancel_requested: threading.Event = field(default_factory=threading.Event) + failure: Exception | None = None + thread: threading.Thread | None = None + + +@dataclass +class _StageContext: + """Mutable state threaded through one job's stage pipeline.""" + + session: Session + job: AnalysisJob + record: RepositoryRecord | None + lease: JobLease | None = None + store: SnapshotStore | None = None + snapshot: RiSnapshot | None = None + reused: bool = False + resource_budget: AnalysisResourceBudget | None = None + heartbeat: _HeartbeatState | None = None + + +class AnalysisWorker: + """Claim and execute one durable analysis job at a time.""" + + def __init__( + self, + session_factory: Callable[[], Session], + *, + worker_id: str, + lease_seconds: int, + max_source_bytes: int = DEFAULT_MAX_SOURCE_BYTES, + max_repository_source_bytes: int = DEFAULT_MAX_REPOSITORY_SOURCE_BYTES, + max_process_rss_bytes: int = DEFAULT_MAX_PROCESS_RSS_BYTES, + max_analysis_seconds: float = DEFAULT_MAX_ANALYSIS_SECONDS, + rss_reader: Callable[[], int] | None = None, + monotonic: Callable[[], float] | None = None, + clock: Callable[[], datetime] = lambda: datetime.now(UTC), + heartbeat_interval_seconds: float | None = None, + control_plane: AnalysisControlPlane | None = None, + ) -> None: + self.session_factory = session_factory + self.worker_id = worker_id + self.lease_seconds = lease_seconds + # The queue this worker draws from. Defaulting to the durable + # ``analysis_jobs`` table keeps every existing caller unchanged while + # making the boundary an argument rather than an assumption. + self.control_plane: AnalysisControlPlane = control_plane or DatabaseAnalysisControlPlane( + lease_seconds=lease_seconds, + clock=clock, + ) + self.max_source_bytes = max_source_bytes + self.max_repository_source_bytes = max_repository_source_bytes + self.max_process_rss_bytes = max_process_rss_bytes + self.max_analysis_seconds = max_analysis_seconds + self._rss_reader = rss_reader + self._monotonic = monotonic + self._clock = clock + self._heartbeat_interval_seconds = heartbeat_interval_seconds or min(max(lease_seconds / 3, 0.1), 5.0) + self._shutdown = threading.Event() + + # -- public API ---------------------------------------------------------- + + def run_once(self) -> bool: + """Claim and fully execute at most one queued job. + + Returns ``True`` if a job was claimed (regardless of its outcome), + ``False`` if the queue was empty. Synchronous and deterministic. + """ + + session = self.session_factory() + try: + lease = self.control_plane.claim(session, worker_id=self.worker_id) + if lease is None: + return False + job = session.get(AnalysisJob, lease.job_id) + if job is None: + return False + # Draining the stage generator runs the whole job to a terminal + # state; tests can instead step the generator to interleave a cancel. + for _ in self._execute_stages(session, job, lease=lease): + pass + return True + finally: + session.close() + + def shutdown(self) -> None: + """Stop stage heartbeat loops during application shutdown.""" + + self._shutdown.set() + + def sweep_stale(self) -> int: + """Reconcile running jobs whose worker lease has expired. + + A completed snapshot means the old worker died after the snapshot seal + commit but before the job-completion commit, so the job is healed to + ``completed`` without repeating analysis. Otherwise any orphaned + building snapshot is failed and the job is either requeued with bounded + backoff or terminally failed when its attempt budget is exhausted. + """ + + session = self.session_factory() + try: + now = self._clock() + stale_ids = self.control_plane.expired_job_ids(session, now=now) + reclaimed = 0 + for job_id in stale_ids: + # Re-read each row so another sweeper that already reconciled it + # turns this pass into a harmless no-op. + session.expire_all() + job = session.get(AnalysisJob, job_id) + if ( + job is None + or job.status != "running" + or job.lease_expires_at is None + or not lease_expired(job.lease_expires_at, now) + ): + continue + if self._reconcile_stale(session, job, now): + reclaimed += 1 + return reclaimed + finally: + session.close() + + # -- claim --------------------------------------------------------------- + + def _claim(self, session: Session) -> AnalysisJob | None: + """Claim one job through the control plane, as a row. + + The claim/lease contract itself lives in + ``app.workers.control_plane``; this is the row-shaped convenience the + execution paths and tests use. + """ + + lease = self.control_plane.claim(session, worker_id=self.worker_id) + if lease is None: + return None + return session.get(AnalysisJob, lease.job_id) + + # -- stage pipeline ------------------------------------------------------ + + def _execute_stages( + self, + session: Session, + job: AnalysisJob, + *, + lease: JobLease | None = None, + ) -> Iterator[_StageContext]: + """Run the job's stages, yielding at each boundary. + + Yielding after every stage gives the runner loop a plain drain and gives + tests a seam to set ``cancel_requested`` between two stages. The cancel + flag is re-read from the row through the control plane before each stage + so a concurrent ``AnalysisJobService.cancel`` is observed cooperatively. + """ + + ctx = _StageContext( + session=session, + job=job, + record=session.get(RepositoryRecord, job.repository_id), + lease=lease, + ) + stages = ( + ("extracting-modules", 35, self._stage_open_snapshot), + ("building-dependency-graph", 75, self._stage_extract), + ("preparing-architecture", 90, self._stage_seal), + ) + try: + for stage_name, progress, stage in stages: + if self._cancel_requested(ctx): + self._cancel(ctx) + yield ctx + return + with self._heartbeat(ctx): + self._check_heartbeat(ctx) + stage(ctx) + self._check_heartbeat(ctx) + self._checkpoint(ctx, stage_name, progress) + yield ctx + if self._cancel_requested(ctx): + self._cancel(ctx) + yield ctx + return + self._complete(ctx) + yield ctx + except _CancellationObserved: + session.rollback() + current = session.get(AnalysisJob, job.id) + if current is None: + self._log_lease_lost(job.id) + return + ctx.job = current + ctx.record = session.get(RepositoryRecord, current.repository_id) + try: + self._cancel(ctx) + except _LeaseLostError: + self._log_lease_lost(job.id) + return + yield ctx + return + except _LeaseLostError: + session.rollback() + self._log_lease_lost(job.id) + return + except AnalysisResourceExceeded as exc: + try: + self._fail_resource_exceeded(ctx, exc) + except _LeaseLostError: + self._log_lease_lost(job.id) + return + yield ctx + return + except Exception as exc: # noqa: BLE001 - bounded retry is the contract + try: + self._retry_or_fail(ctx, exc) + except _LeaseLostError: + self._log_lease_lost(job.id) + return + yield ctx + + def _stage_open_snapshot(self, ctx: _StageContext) -> None: + """Open (or reuse) the snapshot for this job's exact semantic identity. + + Submit and execution must key on the identical identity constants or the + idempotency guarantee breaks, so the shared ``ANALYSIS_*`` constants are + used here. ``job.snapshot_id`` is recorded as soon as the id exists — + before any facts or sealing — so a later crash-recovery sweep can find and + fail an orphaned ``building`` snapshot. + """ + + record = self._require_record(ctx) + record.status = "analysing" + record.analysis_stage = "extracting-modules" + record.analysis_progress = 35 + revision = self._require_revision(record) + ctx.store = SnapshotStore(ctx.session) + snapshot, reused = ctx.store.get_or_reuse( + repository_id=record.id, + revision=revision, + producer_version_set=ANALYSIS_PRODUCER_VERSION_SET, + schema_version=ANALYSIS_SCHEMA_VERSION, + config_hash=ANALYSIS_CONFIG_HASH, + ) + ctx.snapshot = snapshot + ctx.reused = reused + ctx.job.snapshot_id = snapshot.snapshot_id + if not reused: + ctx.resource_budget = self._new_resource_budget() + + def _stage_extract(self, ctx: _StageContext) -> None: + """Run the evidence pipeline and persist every extracted fact. + + A reused (already-sealed) snapshot needs no work: extraction and + resolution are skipped and sealing becomes a no-op. + """ + + if ctx.reused: + return + record = self._require_record(ctx) + store = ctx.store + snapshot = ctx.snapshot + assert store is not None and snapshot is not None + budget = ctx.resource_budget + assert budget is not None + sources = RepositorySourceStream( + root=Path(record.local_path), + file_tree=record.file_tree or (), + max_file_bytes=self.max_source_bytes, + budget=budget, + check_cancelled=lambda: self._check_heartbeat(ctx), + ) + pipeline = ExtractionPipeline( + production_extractors(), + max_source_bytes=self.max_source_bytes, + ) + deferred_dependencies: list[ProducedExtraction] = [] + for produced in pipeline.iter_run( + sources, + check_cancelled=lambda: self._check_heartbeat(ctx), + ): + if any(node.node_kind == "dependency" for node in produced.result.nodes): + # Dependency observations reference these nodes, so the complete + # manifest and lockfile results must wait for the cross-file + # reducer that folds them onto one identity. + deferred_dependencies.append(produced) + continue + self._persist_produced(store, snapshot, produced, ctx) + for produced in self._merge_dependency_declarations(tuple(deferred_dependencies)): + self._persist_produced(store, snapshot, produced, ctx) + self._check_heartbeat(ctx) + RelationshipResolver(store).resolve( + snapshot, + check_cancelled=lambda: self._check_heartbeat(ctx), + ) + self._check_heartbeat(ctx) + RoleClassifier(store).classify( + snapshot, + check_cancelled=lambda: self._check_heartbeat(ctx), + ) + self._check_heartbeat(ctx) + + def _stage_seal(self, ctx: _StageContext) -> None: + """Seal the building snapshot (commits internally, see the module note).""" + + if ctx.reused: + return + assert ctx.store is not None and ctx.snapshot is not None + self._check_heartbeat(ctx) + # ``seal`` commits internally, so acquire the guarded job row in the + # same transaction before allowing the snapshot transition to commit. + self._lock_owned_job(ctx, require_cancel_not_requested=True) + ctx.store.seal( + ctx.snapshot, + check_cancelled=lambda: self._check_heartbeat(ctx), + ) + + # -- terminal transitions ------------------------------------------------ + + def _complete(self, ctx: _StageContext) -> None: + """Mark the job (and its repository record) completed. + + This commit is deliberately separate from ``seal``'s commit (see the + module note); do not merge them. + """ + + now = self._clock() + job_id = ctx.job.id + try: + self._update_owned_job( + ctx, + require_cancel_not_requested=True, + status="completed", + stage="completed", + progress=100, + cancel_requested=False, + worker_id=None, + lease_expires_at=None, + error_code=None, + error_message=None, + completed_at=now, + updated_at=now, + ) + except _LeaseLostError: + # Cancellation can be accepted after the final cooperative read but + # before this completion CAS. If this worker still owns that running + # row, honour the accepted request instead of abandoning/completing. + job = ctx.session.get(AnalysisJob, job_id) + if job is not None and job.status == "running" and job.worker_id == self.worker_id and job.cancel_requested: + ctx.job = job + ctx.record = ctx.session.get(RepositoryRecord, job.repository_id) + self._cancel(ctx) + return + raise + if ctx.record is not None: + ctx.record.status = "completed" + ctx.record.analysis_stage = "completed" + ctx.record.analysis_progress = 100 + ctx.record.error_message = None + ctx.record.analysed_at = now + ctx.session.commit() + + def _cancel(self, ctx: _StageContext) -> None: + """Honour a cooperative cancel: fail any open snapshot, cancel the job.""" + + self._lock_owned_job(ctx) + snapshot = ctx.session.get(RiSnapshot, ctx.job.snapshot_id) if ctx.job.snapshot_id is not None else None + if snapshot is not None and snapshot.state == "completed": + self._complete_sealed_snapshot(ctx, snapshot) + return + self._fail_open_snapshot(ctx, code=_CANCELLED_CODE) + now = self._clock() + self._update_owned_job( + ctx, + status="cancelled", + worker_id=None, + lease_expires_at=None, + next_attempt_at=None, + completed_at=now, + updated_at=now, + ) + if ctx.record is not None: + ctx.record.status = "cancelled" + ctx.record.analysis_stage = None + ctx.record.analysis_progress = 0 + ctx.record.error_message = None + ctx.record.analysed_at = None + ctx.session.commit() + + def _retry_or_fail(self, ctx: _StageContext, exc: Exception) -> None: + """Bounded retry: re-queue with backoff, or fail after ``max_attempts``.""" + + session = ctx.session + session.rollback() + # Rollback expired the in-memory rows; reload from the row that the claim + # already committed so ``attempt``/``max_attempts`` reflect the database. + job = session.get(AnalysisJob, ctx.job.id) + if job is None: + raise _LeaseLostError(ctx.job.id) + ctx.job = job + ctx.record = session.get(RepositoryRecord, job.repository_id) + now = self._clock() + try: + self._lock_owned_job(ctx, require_cancel_not_requested=True) + except _CancellationObserved: + self._cancel(ctx) + return + snapshot = session.get(RiSnapshot, job.snapshot_id) if job.snapshot_id else None + if snapshot is not None and snapshot.state == "completed": + self._update_owned_job( + ctx, + status="completed", + stage="completed", + progress=100, + cancel_requested=False, + worker_id=None, + lease_expires_at=None, + next_attempt_at=None, + error_code=None, + error_message=None, + completed_at=snapshot.sealed_at or now, + updated_at=now, + ) + if ctx.record is not None: + ctx.record.status = "completed" + ctx.record.analysis_stage = "completed" + ctx.record.analysis_progress = 100 + ctx.record.error_message = None + ctx.record.analysed_at = snapshot.sealed_at or now + session.commit() + return + self._fail_open_snapshot(ctx, code=_ERROR_CODE) + if job.attempt >= job.max_attempts: + self._update_owned_job( + ctx, + status="failed", + worker_id=None, + lease_expires_at=None, + next_attempt_at=None, + error_code=_ERROR_CODE, + error_message=self._error_message(exc), + completed_at=now, + updated_at=now, + ) + if ctx.record is not None: + ctx.record.status = "error" + ctx.record.analysis_stage = None + ctx.record.analysis_progress = 0 + ctx.record.error_message = "Repository analysis failed." + session.commit() + return + self._update_owned_job( + ctx, + status="queued", + worker_id=None, + lease_expires_at=None, + next_attempt_at=now + timedelta(seconds=self._backoff(job.attempt)), + error_code=_ERROR_CODE, + error_message=self._error_message(exc), + updated_at=now, + ) + session.commit() + + def _fail_resource_exceeded(self, ctx: _StageContext, exc: AnalysisResourceExceeded) -> None: + """Fail a deterministic resource breach terminally instead of retrying it.""" + + session = ctx.session + session.rollback() + job = session.get(AnalysisJob, ctx.job.id) + if job is None: + raise _LeaseLostError(ctx.job.id) + ctx.job = job + ctx.record = session.get(RepositoryRecord, job.repository_id) + now = self._clock() + try: + self._lock_owned_job(ctx, require_cancel_not_requested=True) + except _CancellationObserved: + self._cancel(ctx) + return + snapshot = session.get(RiSnapshot, job.snapshot_id) if job.snapshot_id else None + if snapshot is not None and snapshot.state == "completed": + self._complete_sealed_snapshot(ctx, snapshot) + return + self._fail_open_snapshot(ctx, code=RESOURCE_EXCEEDED_CODE) + self._update_owned_job( + ctx, + status="failed", + worker_id=None, + lease_expires_at=None, + next_attempt_at=None, + error_code=RESOURCE_EXCEEDED_CODE, + error_message=self._error_message(exc), + completed_at=now, + updated_at=now, + ) + if ctx.record is not None: + ctx.record.status = "error" + ctx.record.analysis_stage = None + ctx.record.analysis_progress = 0 + ctx.record.error_message = "Repository analysis exceeded its resource budget." + session.commit() + + def _reconcile_stale(self, session: Session, job: AnalysisJob, now: datetime) -> bool: + """Atomically claim and reconcile one expired running job.""" + + lease = self.control_plane.reclaim(session, job, worker_id=self.worker_id) + if lease is None: + return False + + record = session.get(RepositoryRecord, job.repository_id) + snapshot = session.get(RiSnapshot, job.snapshot_id) if job.snapshot_id else None + ctx = _StageContext(session=session, job=job, record=record, lease=lease) + + if job.cancel_requested: + self._cancel(ctx) + return True + + if snapshot is not None and snapshot.state == "completed": + self._update_owned_job( + ctx, + status="completed", + stage="completed", + progress=100, + cancel_requested=False, + worker_id=None, + lease_expires_at=None, + next_attempt_at=None, + error_code=None, + error_message=None, + completed_at=snapshot.sealed_at or now, + updated_at=now, + ) + if record is not None: + record.status = "completed" + record.analysis_stage = "completed" + record.analysis_progress = 100 + record.error_message = None + record.analysed_at = snapshot.sealed_at or now + session.commit() + return True + + if snapshot is not None and snapshot.state == "building": + # mark_failed owns a commit boundary. Do this before changing the job + # so its transition cannot accidentally commit a half-reconciled row. + SnapshotStore(session).mark_failed(snapshot, code=_STALE_CODE) + + if job.attempt < job.max_attempts: + self._update_owned_job( + ctx, + status="queued", + worker_id=None, + lease_expires_at=None, + next_attempt_at=now + timedelta(seconds=self._backoff(job.attempt)), + error_code=_STALE_CODE, + error_message=_STALE_MESSAGE, + updated_at=now, + ) + if record is not None: + record.status = "analysing" + record.analysis_stage = None + record.analysis_progress = 0 + record.error_message = None + else: + self._update_owned_job( + ctx, + status="failed", + worker_id=None, + lease_expires_at=None, + next_attempt_at=None, + error_code=_STALE_CODE, + error_message=_STALE_MESSAGE, + completed_at=now, + updated_at=now, + ) + if record is not None: + record.status = "error" + record.analysis_stage = None + record.analysis_progress = 0 + record.error_message = "Repository analysis failed after its worker lease expired." + session.commit() + return True + + # -- helpers ------------------------------------------------------------- + + def _checkpoint(self, ctx: _StageContext, stage: str, progress: int) -> None: + """Persist stage/progress and renew the lease at a stage boundary.""" + + now = self._clock() + self._update_owned_job( + ctx, + stage=stage, + progress=progress, + lease_expires_at=now + timedelta(seconds=self.lease_seconds), + updated_at=now, + ) + ctx.session.commit() + + def _update_owned_job( + self, + ctx: _StageContext, + *, + require_cancel_not_requested: bool = False, + **values: object, + ) -> None: + """Update a running job only while this worker still owns it.""" + + job_id = ctx.job.id + held = self.control_plane.update_owned( + ctx.session, + job_id=job_id, + worker_id=self.worker_id, + values=values, + require_cancel_not_requested=require_cancel_not_requested, + ) + if not held: + ctx.session.rollback() + raise _LeaseLostError(job_id) + + def _lock_owned_job(self, ctx: _StageContext, *, require_cancel_not_requested: bool = False) -> None: + """Lock the running row after atomically confirming this worker owns it.""" + + try: + self._update_owned_job( + ctx, + require_cancel_not_requested=require_cancel_not_requested, + updated_at=AnalysisJob.updated_at, + ) + except _LeaseLostError: + if require_cancel_not_requested: + current = ctx.session.get(AnalysisJob, ctx.job.id) + if ( + current is not None + and current.status == "running" + and current.worker_id == self.worker_id + and current.cancel_requested + ): + ctx.job = current + raise _CancellationObserved(current.id) from None + raise + + def _cancel_requested(self, ctx: _StageContext) -> bool: + return self.control_plane.cancel_requested(ctx.session, ctx.job.id) + + def _fail_open_snapshot(self, ctx: _StageContext, *, code: str) -> None: + """Mark an opened-but-unsealed snapshot ``failed`` (no-op if reused/sealed).""" + + if ctx.reused or ctx.job.snapshot_id is None: + return + snapshot = ctx.session.get(RiSnapshot, ctx.job.snapshot_id) + if snapshot is not None and snapshot.state == "building": + SnapshotStore(ctx.session).mark_failed(snapshot, code=code) + + def _log_lease_lost(self, job_id: str) -> None: + logger.warning( + "Analysis worker lost job ownership; abandoning execution", + extra={"job_id": job_id, "worker_id": self.worker_id}, + ) + + def _lease_for(self, ctx: _StageContext) -> JobLease: + """This worker's ownership token for ``ctx``'s job. + + Paths that did not claim the job themselves — a reconciling sweep, or a + test driving a single stage — still own the row by ``worker_id``, which + is exactly what every control-plane guard tests. Rebuilding the token + from the row therefore asserts the same ownership a claim would have, + and never more than it. + """ + + if ctx.lease is not None: + return ctx.lease + return JobLease( + job_id=ctx.job.id, + worker_id=self.worker_id, + expires_at=ctx.job.lease_expires_at or self._clock(), + attempt=ctx.job.attempt, + ) + + @contextmanager + def _heartbeat(self, ctx: _StageContext) -> Iterator[_HeartbeatState]: + """Renew one running job from an independent session during a stage.""" + + state = _HeartbeatState() + lease = self._lease_for(ctx) + self._heartbeat_once(lease, state) + + def _run() -> None: + while not state.stop.wait(self._heartbeat_interval_seconds): + if self._shutdown.is_set(): + return + self._heartbeat_once(lease, state) + if state.ownership_lost.is_set() or state.cancel_requested.is_set() or state.failure: + return + + if not state.ownership_lost.is_set() and not state.cancel_requested.is_set() and not state.failure: + state.thread = threading.Thread( + target=_run, + name=f"analysis-heartbeat-{ctx.job.id}", + daemon=True, + ) + state.thread.start() + ctx.heartbeat = state + try: + yield state + finally: + ctx.heartbeat = None + state.stop.set() + if state.thread is not None: + state.thread.join(timeout=max(self._heartbeat_interval_seconds * 2, 1.0)) + if state.thread.is_alive(): + logger.warning( + "Analysis heartbeat did not stop promptly", + extra={"job_id": ctx.job.id, "worker_id": self.worker_id}, + ) + + def _heartbeat_once(self, lease: JobLease, state: _HeartbeatState) -> None: + """Pulse one lease renewal on an independent session. + + The renewal itself — including the atomic cancellation read-back — is a + control-plane call; this method only owns the session and translates the + outcome into the thread-safe signals the stage loop watches. A + ``deferred`` outcome leaves every signal clear on purpose: ownership is + unchanged, so the stage must neither abandon its work nor treat the + skipped pulse as a failure. + """ + + session = self.session_factory() + try: + renewal = self.control_plane.renew(session, lease) + if renewal.lost: + state.ownership_lost.set() + elif renewal.cancel_requested: + state.cancel_requested.set() + except Exception as exc: # noqa: BLE001 - surfaced to the owning worker + state.failure = exc + finally: + session.close() + + @staticmethod + def _check_heartbeat(ctx: _StageContext) -> None: + state = ctx.heartbeat + if state is not None: + if state.ownership_lost.is_set(): + raise _LeaseLostError(ctx.job.id) + if state.cancel_requested.is_set(): + raise _CancellationObserved(ctx.job.id) + if state.failure is not None: + raise state.failure + if ctx.resource_budget is not None: + ctx.resource_budget.check() + + def _new_resource_budget(self) -> AnalysisResourceBudget: + kwargs: dict[str, object] = { + "max_source_bytes": self.max_repository_source_bytes, + "max_rss_bytes": self.max_process_rss_bytes, + "max_seconds": self.max_analysis_seconds, + } + if self._rss_reader is not None: + kwargs["rss_reader"] = self._rss_reader + if self._monotonic is not None: + kwargs["monotonic"] = self._monotonic + return AnalysisResourceBudget(**kwargs) # type: ignore[arg-type] + + def _complete_sealed_snapshot(self, ctx: _StageContext, snapshot: RiSnapshot) -> None: + """Make an already-sealed snapshot authoritative over cancellation.""" + + now = self._clock() + completed_at = snapshot.sealed_at or now + self._update_owned_job( + ctx, + status="completed", + stage="completed", + progress=100, + cancel_requested=False, + worker_id=None, + lease_expires_at=None, + next_attempt_at=None, + error_code=None, + error_message=None, + completed_at=completed_at, + updated_at=now, + ) + if ctx.record is not None: + ctx.record.status = "completed" + ctx.record.analysis_stage = "completed" + ctx.record.analysis_progress = 100 + ctx.record.error_message = None + ctx.record.analysed_at = completed_at + ctx.session.commit() + + @staticmethod + def _merge_dependency_declarations( + produced: tuple[ProducedExtraction, ...], + ) -> tuple[ProducedExtraction, ...]: + """Fold per-file dependency facts onto one node before persistence. + + Scoped to this worker's single repository-wide extraction stream, where + every manifest and lockfile in the repository has been seen; + ``ExtractionPipeline`` itself is unchanged. The reducer lives in + :mod:`app.extraction.dependencies` so the golden benchmark seals the + identical merged shape this worker persists. + """ + + return merge_dependency_facts(produced) + + def _persist_produced( + self, + store: SnapshotStore, + snapshot: RiSnapshot, + produced: ProducedExtraction, + ctx: _StageContext | None = None, + ) -> None: + """Convert one ``ExtractionResult`` into snapshot writes. + + This mirrors the proven benchmark adapter conversion, but writes to the + production ``SnapshotStore`` instead of building comparison facts. Evidence + provenance carries the emitting producer, which is a declared member of the + analysis producer set, so sealing's ``declared ⊇ observed`` check holds. + """ + + result = produced.result + for node in result.nodes: + if ctx is not None: + self._check_heartbeat(ctx) + set_array_keys = frozenset( + key + for key in ("decorators", *sorted(DEPENDENCY_SET_ARRAY_KEYS)) + if node.properties and key in node.properties + ) + store.add_node( + snapshot, + node_kind=node.node_kind, + stable_key=node.stable_key, + name=node.name, + language=node.language, + properties=node.properties, + set_array_keys=set_array_keys, + evidence=[self._evidence(item, produced) for item in node.evidence], + ) + for observation in result.observations: + if ctx is not None: + self._check_heartbeat(ctx) + store.add_observation( + snapshot, + observed_kind=observation.observed_kind, + subject_kind=observation.subject_kind, + subject_key=observation.subject_key, + referent_text=observation.referent_text, + ordinal=observation.ordinal, + evidence=self._evidence(observation.evidence, produced), + ) + for diagnostic in result.diagnostics: + if ctx is not None: + self._check_heartbeat(ctx) + store.add_diagnostic( + snapshot, + code=diagnostic.code, + category=diagnostic.category, + severity=diagnostic.severity, + message=diagnostic.message, + producer=produced.producer, + path=diagnostic.path, + span=diagnostic.span, + subject=diagnostic.subject, + details=diagnostic.details, + ) + + @staticmethod + def _evidence(item: ExtractedEvidence, produced: ProducedExtraction) -> Evidence: + return Evidence( + path=item.path, + start_line=item.start_line, + end_line=item.end_line, + extractor=produced.producer_name, + extractor_version=produced.producer_version, + logical_line_count=item.logical_line_count, + granularity=item.granularity, + ) + + @staticmethod + def _require_record(ctx: _StageContext) -> RepositoryRecord: + if ctx.record is None: + raise RuntimeError("analysis job references a repository that no longer exists") + return ctx.record + + @staticmethod + def _require_revision(record: RepositoryRecord) -> Revision: + if record.revision_kind is None or record.revision_value is None: + raise RuntimeError("repository has no analyzable revision") + return Revision(record.revision_kind, record.revision_value, record.revision_ref) + + @staticmethod + def _backoff(attempt: int) -> int: + """Bounded exponential backoff in seconds (capped at 60).""" + + return min(2**attempt, 60) + + @staticmethod + def _error_message(exc: Exception) -> str: + message = str(exc) or exc.__class__.__name__ + return message[:_MAX_ERROR_MESSAGE] diff --git a/apps/backend/app/workers/control_plane.py b/apps/backend/app/workers/control_plane.py new file mode 100644 index 00000000..62674e07 --- /dev/null +++ b/apps/backend/app/workers/control_plane.py @@ -0,0 +1,428 @@ +"""The analysis queue and control-plane boundary (#324). + +``analysis_jobs`` *is* the queue. This module is the explicit boundary around +that fact: everything that decides **which work is eligible** and **who owns +it** lives here, and nothing here knows how a repository is analysed. + +The split this module introduces +-------------------------------- + +Before #324 the claim compare-and-swap, the lease-renewal compare-and-swap, the +expired-lease scan and the ownership predicate were private methods of +``AnalysisWorker``, and the polling/sweep policy that drove them lived in +``app.main``. Ownership was therefore expressible only as "whatever the executor +happens to do", and a second worker process would have had to import the +executor to participate in the queue at all. + +``AnalysisControlPlane`` is now the seam: + +* **control plane** -- eligibility, claiming, lease renewal, expiry, reclaim, + ownership-guarded mutation, and observing a cancellation request; +* **executor** (``AnalysisWorker``) -- running a *claimed* job through the + Repository Intelligence pipeline and deciding its terminal transition. + +Why the database and not Redis/Celery +------------------------------------- + +The durable ``analysis_jobs`` row is already the authority for job identity, +attempt budget, cancellation and lease expiry, and it is already crash-safe by +idempotent reconciliation (see the ``analysis_worker`` module docstring). A +broker would add a second, weaker source of truth that still could not be +trusted over the row, plus a runtime dependency the deployment does not +otherwise need. The protocol below is the abstraction that makes a different +backing store possible later without another redesign of the claim/lease +contract; it is deliberately not that store. + +Portability +----------- + +Every mutation is a portable compare-and-swap (``UPDATE ... WHERE ``) +rather than ``SELECT ... FOR UPDATE SKIP LOCKED``, so SQLite development and +PostgreSQL deployment share one code path and one contract. PostgreSQL is the +deployment authority; the one dialect-specific concession is the SQLite +busy-timeout handling in :meth:`DatabaseAnalysisControlPlane.renew`, which is +documented at its site. +""" + +from __future__ import annotations + +import logging +from collections.abc import Callable +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +from typing import Any, Literal, Protocol, cast + +from sqlalchemy import CursorResult, func, or_, select, update +from sqlalchemy.orm import Session +from sqlalchemy.sql.base import Executable + +from app.models.analysis_job import AnalysisJob + +logger = logging.getLogger(__name__) + +RenewalOutcome = Literal["renewed", "lost", "deferred"] + + +def lease_expired(lease_expires_at: datetime, now: datetime) -> bool: + """Compare SQLite-naive and timezone-aware persisted timestamps safely.""" + + if lease_expires_at.tzinfo is None and now.tzinfo is not None: + lease_expires_at = lease_expires_at.replace(tzinfo=UTC) + elif lease_expires_at.tzinfo is not None and now.tzinfo is None: + now = now.replace(tzinfo=UTC) + return lease_expires_at < now + + +@dataclass(frozen=True, slots=True) +class JobLease: + """Proof that ``worker_id`` owns ``job_id`` until ``expires_at``. + + A lease is a *value*, not a handle: holding one asserts nothing on its own. + Ownership is re-proved by the guard on every mutation + (:meth:`AnalysisControlPlane.update_owned`), so a stale lease object can + never be used to write to a job another worker has since reclaimed. + """ + + job_id: str + worker_id: str + expires_at: datetime + attempt: int + + +@dataclass(frozen=True, slots=True) +class LeaseRenewal: + """The outcome of one renewal attempt. + + ``deferred`` is neither a failure nor a loss: SQLite serialises writers, so + a renewal can be impossible *while this same worker's stage holds the write + lock*. Ownership is unchanged in that case -- and a sweeper is equally + locked out -- so the pulse is skipped rather than treated as a lost lease. + """ + + outcome: RenewalOutcome + cancel_requested: bool = False + expires_at: datetime | None = None + + @property + def held(self) -> bool: + return self.outcome == "renewed" + + @property + def lost(self) -> bool: + return self.outcome == "lost" + + +class AnalysisControlPlane(Protocol): + """Queue ownership for durable analysis jobs. + + Implementations must make every method safe against concurrent workers + without holding a lock across a call: each is a single compare-and-swap or + a read, so a worker process can crash between any two calls and leave only + an expired lease behind. + """ + + def next_eligible_job_id(self, session: Session, *, now: datetime | None = None) -> str | None: + """The oldest job the queue would hand out next, without claiming it.""" + ... + + def claim(self, session: Session, *, worker_id: str) -> JobLease | None: + """Take exclusive ownership of the oldest eligible queued job.""" + ... + + def renew(self, session: Session, lease: JobLease) -> LeaseRenewal: + """Extend ``lease`` and report any cancellation request.""" + ... + + def expired_job_ids(self, session: Session, *, now: datetime | None = None) -> tuple[str, ...]: + """Ids of running jobs whose lease has lapsed, oldest lapse first.""" + ... + + def reclaim(self, session: Session, job: AnalysisJob, *, worker_id: str) -> JobLease | None: + """Take ownership of one expired job, or ``None`` if it moved on.""" + ... + + def update_owned( + self, + session: Session, + *, + job_id: str, + worker_id: str, + values: dict[str, object], + require_cancel_not_requested: bool = False, + ) -> bool: + """Apply ``values`` only while ``worker_id`` still owns the running job.""" + ... + + def cancel_requested(self, session: Session, job_id: str) -> bool: + """Read the durable cancellation flag for ``job_id``.""" + ... + + +class DatabaseAnalysisControlPlane: + """The v1 control plane: the durable ``analysis_jobs`` table itself.""" + + def __init__( + self, + *, + lease_seconds: int, + clock: Callable[[], datetime] = lambda: datetime.now(UTC), + ) -> None: + self.lease_seconds = lease_seconds + self._clock = clock + + def _lease_until(self, now: datetime) -> datetime: + return now + timedelta(seconds=self.lease_seconds) + + @staticmethod + def _execute_dml(session: Session, statement: Executable) -> CursorResult[Any]: + """Execute a guarded UPDATE and expose its ``rowcount``. + + ``Session.execute`` is declared as returning ``Result``, which carries + no ``rowcount``; DML always produces a ``CursorResult``, and every + compare-and-swap here decides ownership from exactly that value. + """ + + return cast(CursorResult[Any], session.execute(statement)) + + # -- claim --------------------------------------------------------------- + + def next_eligible_job_id(self, session: Session, *, now: datetime | None = None) -> str | None: + """The oldest job the queue would hand out next, or ``None``. + + Deliberately separate from :meth:`claim`: this read is the half of a + claim that carries no ownership at all, and naming it makes the race + window between reading a candidate and winning it explicit -- both to a + reader and to a test that needs to hold a candidate across another + worker's claim. + """ + + moment = now if now is not None else self._clock() + return session.scalar( + select(AnalysisJob.id) + .where( + AnalysisJob.status == "queued", + or_(AnalysisJob.next_attempt_at.is_(None), AnalysisJob.next_attempt_at <= moment), + ) + .order_by(AnalysisJob.created_at) + .limit(1) + ) + + def claim(self, session: Session, *, worker_id: str) -> JobLease | None: + """Atomically claim the oldest eligible queued job, or return ``None``. + + A portable compare-and-swap rather than ``SELECT ... FOR UPDATE SKIP + LOCKED``: pick the oldest eligible id, then ``UPDATE ... WHERE id = :id + AND status='queued'``. The ``status='queued'`` predicate is the atomic + guard -- two workers racing for the same row see exactly one non-zero + ``rowcount``; the loser gets ``None`` and polls again. Eligibility also + honours ``next_attempt_at``, so a job serving retry backoff is invisible + to the queue until its delay elapses. + """ + + now = self._clock() + candidate_id = self.next_eligible_job_id(session, now=now) + if candidate_id is None: + return None + expires_at = self._lease_until(now) + result = self._execute_dml( + session, + update(AnalysisJob) + .where(AnalysisJob.id == candidate_id, AnalysisJob.status == "queued") + .values( + status="running", + worker_id=worker_id, + lease_expires_at=expires_at, + started_at=func.coalesce(AnalysisJob.started_at, now), + attempt=AnalysisJob.attempt + 1, + next_attempt_at=None, + updated_at=now, + ), + ) + session.commit() + if result.rowcount == 0: + # Another worker won the compare-and-swap for this row. + return None + claimed = session.get(AnalysisJob, candidate_id) + if claimed is None: + return None + return JobLease( + job_id=candidate_id, + worker_id=worker_id, + expires_at=expires_at, + attempt=claimed.attempt, + ) + + # -- renew --------------------------------------------------------------- + + def renew(self, session: Session, lease: JobLease) -> LeaseRenewal: + """Atomically extend ownership and read back the cancellation flag. + + The guard is ``status='running' AND worker_id = ``: a worker that + has been reclaimed, or whose job reached a terminal state, matches no + row and is told its lease is ``lost`` instead of writing to a job it no + longer owns. Renewal and cancellation observation are one statement, so + a cancellation request accepted between the two can never be missed. + """ + + sqlite_connection: Any = None + sqlite_busy_timeout: int | None = None + + def _restore_sqlite_timeout() -> None: + nonlocal sqlite_connection + connection = sqlite_connection + if connection is None or sqlite_busy_timeout is None: + return + try: + cursor = connection.cursor() + cursor.execute(f"PRAGMA busy_timeout = {sqlite_busy_timeout}") + cursor.close() + except Exception: # noqa: BLE001 - discard a modified connection + session.invalidate() + finally: + sqlite_connection = None + + try: + if session.bind is not None and session.bind.dialect.name == "sqlite": + driver_connection = session.connection().connection.driver_connection + # A pooled connection with no live DBAPI connection has no + # busy timeout to tune; the renewal is still correct, it just + # waits the configured default like any other statement. + if driver_connection is not None: + sqlite_connection = driver_connection + cursor = driver_connection.cursor() + sqlite_busy_timeout = int(cursor.execute("PRAGMA busy_timeout").fetchone()[0]) + # SQLite serializes all writers. If the stage already owns + # the database write lock, a sweeper cannot reclaim the job + # either, so the renewal must not block stage cleanup + # behind that lock. + cursor.execute("PRAGMA busy_timeout = 0") + cursor.close() + now = self._clock() + expires_at = self._lease_until(now) + row = session.execute( + update(AnalysisJob) + .where( + AnalysisJob.id == lease.job_id, + AnalysisJob.status == "running", + AnalysisJob.worker_id == lease.worker_id, + ) + .values(lease_expires_at=expires_at, updated_at=now) + .returning(AnalysisJob.cancel_requested) + .execution_options(synchronize_session=False) + ).first() + if row is None: + _restore_sqlite_timeout() + session.rollback() + return LeaseRenewal(outcome="lost") + _restore_sqlite_timeout() + session.commit() + return LeaseRenewal(outcome="renewed", cancel_requested=bool(row[0]), expires_at=expires_at) + except Exception as exc: # noqa: BLE001 - classified here, re-raised to the caller + _restore_sqlite_timeout() + session.rollback() + # A SQLite writer prevents every other SQLite writer, including a + # stale sweeper. Skipping that transient pulse avoids a false retry; + # PostgreSQL renewals remain fully independent and guarded. + if session.bind is not None and session.bind.dialect.name == "sqlite" and "locked" in str(exc).lower(): + logger.debug("SQLite analysis lease renewal skipped while the stage held the write lock") + return LeaseRenewal(outcome="deferred") + raise + finally: + _restore_sqlite_timeout() + + # -- expiry and reclaim -------------------------------------------------- + + def expired_job_ids(self, session: Session, *, now: datetime | None = None) -> tuple[str, ...]: + """Ids of running jobs whose lease has lapsed, oldest lapse first. + + Read-only, and not itself a claim: a caller must still win + :meth:`reclaim` for each id before acting on it, because another sweeper + may reconcile the same row between this scan and that call. + """ + + moment = now if now is not None else self._clock() + return tuple( + session.scalars( + select(AnalysisJob.id) + .where( + AnalysisJob.status == "running", + AnalysisJob.lease_expires_at.is_not(None), + AnalysisJob.lease_expires_at < moment, + ) + .order_by(AnalysisJob.lease_expires_at, AnalysisJob.created_at) + ) + ) + + def reclaim(self, session: Session, job: AnalysisJob, *, worker_id: str) -> JobLease | None: + """Take ownership of one expired job, or ``None`` if it moved on. + + The guard pins the *exact* prior owner and lease instant as well as + requiring the lease to still be lapsed, so an active lease can never be + stolen: a renewal landing between the scan and this statement moves + ``lease_expires_at`` and the compare-and-swap matches nothing. + """ + + now = self._clock() + expires_at = self._lease_until(now) + result = self._execute_dml( + session, + update(AnalysisJob) + .where( + AnalysisJob.id == job.id, + AnalysisJob.status == "running", + AnalysisJob.worker_id == job.worker_id, + AnalysisJob.lease_expires_at == job.lease_expires_at, + AnalysisJob.lease_expires_at < now, + ) + .values(worker_id=worker_id, lease_expires_at=expires_at, updated_at=now) + .execution_options(synchronize_session="fetch"), + ) + if result.rowcount == 0: + session.rollback() + return None + return JobLease(job_id=job.id, worker_id=worker_id, expires_at=expires_at, attempt=job.attempt) + + # -- ownership-guarded mutation ------------------------------------------ + + def update_owned( + self, + session: Session, + *, + job_id: str, + worker_id: str, + values: dict[str, object], + require_cancel_not_requested: bool = False, + ) -> bool: + """Apply ``values`` only while ``worker_id`` owns the running job. + + Returns ``True`` when the guard matched and ``False`` when ownership was + lost. Transactional recovery from a lost guard belongs to the caller: + this method neither commits nor rolls back, so the caller can decide + whether a miss means abandon, cancel, or reconcile. + """ + + ownership = [ + AnalysisJob.id == job_id, + AnalysisJob.worker_id == worker_id, + AnalysisJob.status == "running", + ] + if require_cancel_not_requested: + ownership.append(AnalysisJob.cancel_requested.is_(False)) + with session.no_autoflush: + result = self._execute_dml( + session, + update(AnalysisJob).where(*ownership).values(**values).execution_options(synchronize_session="fetch"), + ) + return bool(result.rowcount) + + # -- cancellation -------------------------------------------------------- + + def cancel_requested(self, session: Session, job_id: str) -> bool: + """Read the durable cancellation flag for ``job_id``. + + Cancellation is observed from the row rather than carried on the lease, + so a request accepted by the API after this worker claimed the job is + still seen at the next cooperative check. + """ + + return bool(session.scalar(select(AnalysisJob.cancel_requested).where(AnalysisJob.id == job_id))) diff --git a/apps/backend/app/workers/runner.py b/apps/backend/app/workers/runner.py new file mode 100644 index 00000000..b7df402a --- /dev/null +++ b/apps/backend/app/workers/runner.py @@ -0,0 +1,197 @@ +"""The in-process compatibility runner for the analysis control plane (#324). + +#324 requires the current single-worker path to stay available *behind* the new +boundary during migration. This module is that path. + +Before this module, ``app.main`` constructed the worker, minted its ownership +token, owned the poll loop, decided the stale-sweep cadence, and joined the +thread on shutdown -- so the API process did not merely *host* a worker, it +*was* the control loop. Nothing outside a FastAPI lifespan could run a worker +without copying that policy. + +``AnalysisWorkerRunner`` owns that policy instead. ``app.main`` now only starts +and stops it, and :meth:`AnalysisWorkerRunner.run_forever` is a plain blocking +call, so the same loop a future standalone worker process needs is already +here -- a ``__main__`` that builds a runner and calls ``run_forever`` adds no +new queue policy. Building that deployment is #210's remaining work and is +deliberately not done here. + +Threading is an implementation detail of *this* runner, not of the boundary: the +claim/lease contract in ``app.workers.control_plane`` is process-agnostic, and a +separate process would use the identical contract without a daemon thread. +""" + +from __future__ import annotations + +import logging +import threading +from collections.abc import Callable +from os import getpid +from uuid import uuid4 + +from sqlalchemy.orm import Session + +from app.core.config import Settings, get_settings +from app.workers.analysis_worker import AnalysisWorker + +logger = logging.getLogger(__name__) + +#: Empty-queue polls between stale-lease sweeps. Reconciling an expired lease is +#: a scan plus a compare-and-swap per stale row, so it is far cheaper than an +#: analysis but not free; once per N polls keeps recovery prompt without turning +#: an idle worker into a busy sweeper. +DEFAULT_STALE_SWEEP_INTERVAL_POLLS = 10 + +#: How long ``stop`` waits for the loop thread to leave its current iteration. +DEFAULT_SHUTDOWN_TIMEOUT_SECONDS = 10.0 + + +def new_worker_id(pid: int | None = None) -> str: + """Mint a process-observable, globally unique worker ownership token. + + The pid makes an owner traceable to a process in logs; the uuid makes two + workers in the *same* process (or in two containers that happen to share a + pid namespace) distinct owners. Uniqueness is what the control plane's + ownership guards rest on, so it must not depend on the pid alone. The result + fits ``analysis_jobs.worker_id`` (64 characters). + """ + + return f"analysis-worker-{pid if pid is not None else getpid()}-{uuid4().hex}" + + +class AnalysisWorkerRunner: + """Drive one :class:`AnalysisWorker` against the queue until stopped. + + The loop claims and runs one job per iteration and sleeps *only* when the + queue is empty, so a backlog drains without an artificial poll delay between + jobs. A failed iteration is logged and the loop continues: one bad job must + never stop the worker, and the job's own bounded-retry and stale-lease paths + already decide what happens to it. + """ + + def __init__( + self, + worker: AnalysisWorker, + *, + poll_interval_seconds: float, + stale_sweep_interval_polls: int = DEFAULT_STALE_SWEEP_INTERVAL_POLLS, + ) -> None: + self.worker = worker + self.poll_interval_seconds = poll_interval_seconds + self.stale_sweep_interval_polls = stale_sweep_interval_polls + self._stop = threading.Event() + self._thread: threading.Thread | None = None + + @property + def worker_id(self) -> str: + return self.worker.worker_id + + def sweep_on_start(self) -> None: + """Reclaim jobs orphaned by a previous hard process exit. + + A crash leaves a running row with a lease nobody will renew. Sweeping + once at startup recovers those immediately instead of waiting out the + first periodic sweep. A failure here must not prevent the process from + starting, so it is logged rather than raised. + """ + + try: + self.worker.sweep_stale() + except Exception: # noqa: BLE001 - stale cleanup must not block startup + logger.exception("Initial stale analysis-job sweep failed") + + def run_forever(self) -> None: + """Run the claim/sweep loop on the calling thread until :meth:`stop`. + + This is the whole control loop. :meth:`start` runs it on a daemon thread + for the in-process path; a standalone worker process would call it + directly. + """ + + polls_since_sweep = 0 + while not self._stop.is_set(): + try: + claimed = self.worker.run_once() + polls_since_sweep += 1 + if polls_since_sweep >= self.stale_sweep_interval_polls: + self.worker.sweep_stale() + polls_since_sweep = 0 + except Exception: # noqa: BLE001 - a single bad job must not kill the loop + logger.exception("Analysis worker iteration failed") + claimed = False + if not claimed: + self._stop.wait(self.poll_interval_seconds) + + def start(self) -> None: + """Sweep once, then run the loop on a daemon thread.""" + + if self._thread is not None: + raise RuntimeError("analysis worker runner is already started") + self.sweep_on_start() + self._stop.clear() + self._thread = threading.Thread(target=self.run_forever, name="analysis-worker", daemon=True) + self._thread.start() + + def stop(self, timeout: float = DEFAULT_SHUTDOWN_TIMEOUT_SECONDS) -> None: + """Signal the loop and the worker's heartbeats, then join the thread. + + Both signals are needed and ordered: ``_stop`` ends the loop after the + current iteration, and ``worker.shutdown()`` releases a stage heartbeat + that would otherwise keep renewing a lease while the process exits. A + thread still alive after ``timeout`` is a daemon, so it cannot block + interpreter exit; it is reported rather than killed. + """ + + self._stop.set() + self.worker.shutdown() + thread = self._thread + self._thread = None + if thread is None: + return + thread.join(timeout=timeout) + if thread.is_alive(): + logger.warning( + "Analysis worker loop did not stop within the shutdown timeout", + extra={"worker_id": self.worker_id, "timeout_seconds": timeout}, + ) + + +def build_analysis_worker( + settings: Settings, + session_factory: Callable[[], Session], + *, + worker_id: str | None = None, +) -> AnalysisWorker: + """Construct the configured executor for the durable analysis queue.""" + + return AnalysisWorker( + session_factory, + worker_id=worker_id or new_worker_id(), + lease_seconds=settings.analysis_job_lease_seconds, + max_repository_source_bytes=settings.analysis_max_repository_source_bytes, + max_process_rss_bytes=settings.analysis_max_process_rss_bytes, + max_analysis_seconds=settings.analysis_max_duration_seconds, + ) + + +def build_analysis_worker_runner( + settings: Settings | None = None, + session_factory: Callable[[], Session] | None = None, +) -> AnalysisWorkerRunner: + """Assemble the configured in-process runner. + + Imported lazily by callers that only need it when the worker actually + autostarts, which keeps the database session factory out of import order for + processes that never run a worker. + """ + + resolved_settings = settings if settings is not None else get_settings() + if session_factory is None: + from app.core.database import SessionLocal + + session_factory = SessionLocal + worker = build_analysis_worker(resolved_settings, session_factory) + return AnalysisWorkerRunner( + worker, + poll_interval_seconds=resolved_settings.analysis_job_poll_interval_seconds, + ) diff --git a/apps/backend/pyproject.toml b/apps/backend/pyproject.toml index 9390c1f6..e2cc1b98 100644 --- a/apps/backend/pyproject.toml +++ b/apps/backend/pyproject.toml @@ -10,19 +10,36 @@ requires-python = ">=3.12,<3.14" dependencies = [ "fastapi>=0.115.0", "uvicorn[standard]>=0.30.0", - "pydantic>=2.8.0", + "pydantic[email]>=2.8.0", "pydantic-settings>=2.4.0", + "PyJWT>=2.9.0", + "argon2-cffi>=23.1.0", + "cryptography>=42.0.0", "SQLAlchemy>=2.0.0", "alembic>=1.13.0", "psycopg[binary]>=3.2.0", "redis>=5.0.0", - "GitPython>=3.1.43", "tree-sitter>=0.22.0", - "networkx>=3.3", + "tree-sitter-typescript>=0.23.0", "python-multipart>=0.0.9", - "httpx>=0.27.0", - "xhtml2pdf>=0.2.16", - "pytest>=8.3.0" + "anyio>=4.0.0", + # The sender relies directly on httpcore's documented sni_hostname request + # extension while connecting to an IP-pinned URL. + "httpcore>=1.0.9,<2.0.0", + "httpx>=0.27.0,<1.0.0", + # The IaC extractor uses yaml.compose(), whose node marks are what make an + # exact one-based declaration span possible for a Compose manifest. + "PyYAML>=6.0", + "xhtml2pdf>=0.2.16" +] + +[project.optional-dependencies] +test = [ + "pytest>=8.3.0", +] +dev = [ + "mypy==2.3.1", + "ruff==0.16.4", ] [tool.setuptools.packages.find] @@ -32,3 +49,69 @@ include = ["app*"] testpaths = ["tests"] pythonpath = ["."] addopts = "-q" + +[tool.ruff] +target-version = "py312" +line-length = 120 +src = ["app", "scripts", "tests"] +# Deliberately malformed/adversarial Python source used as golden-benchmark +# fixture *input* (Issue #94), not our own code -- e.g. adv-py-malformed's +# broken.py exists to prove the extractor reports a parse failure instead of +# crashing. Linting it as if it were a real module is a category error. +extend-exclude = ["tests/benchmark/fixtures"] + +[tool.ruff.lint] +select = ["E4", "E7", "E9", "F"] +# AiProviderConfig is a backwards-compatible re-export from app.ai.types. +per-file-ignores = { "app/ai/types.py" = ["F401"] } + +[tool.mypy] +files = ["app", "scripts"] +python_version = "3.12" +show_error_codes = true +warn_unused_configs = true +warn_unused_ignores = true + +# Existing type debt is isolated to the listed modules. The rest of the +# production package remains checked, so each entry can be removed as its +# annotations are repaired without weakening the global baseline. +# PyYAML ships no inline types and the stub package is not a runtime dependency. +# Only the IaC extractor imports it, and it wraps every yaml call in its own +# typed helpers, so the untyped boundary stops at that module. +[[tool.mypy.overrides]] +module = ["yaml"] +ignore_missing_imports = true + +[[tool.mypy.overrides]] +module = [ + "app.ai.providers.config_store", + "app.ai.providers.http", + "app.analysis.architecture", + "app.analysis.authentication", + "app.api.routes.ai", + "app.api.routes.analysis", + "app.api.routes.auth", + "app.api.routes.documentation", + "app.api.routes.intelligence", + "app.api.routes.repositories", + "app.api.routes.reports", + "app.auth.service", + "app.core.ai_egress", + "app.core.observability", + "app.extraction.manifests", + "app.extraction.python", + "app.extraction.typescript", + "app.graph.dependency_graph", + "app.insights.service", + "app.intelligence.query_service", + "app.intelligence.resolution", + "app.intelligence.snapshot_store", + "app.main", + "app.reports.renderers", + "app.review.review_service", + "app.services.analysis_job_service", + "app.services.documentation_service", + "app.services.repository_service", + "app.workers.analysis_worker", +] +ignore_errors = true diff --git a/apps/backend/requirements-dev.txt b/apps/backend/requirements-dev.txt new file mode 100644 index 00000000..bb1e3743 --- /dev/null +++ b/apps/backend/requirements-dev.txt @@ -0,0 +1,16 @@ +# Development/test dependencies for PARTHA backend. +# Extends the pinned runtime lockfile (requirements.txt) with the test +# toolchain so CI and local dev can run pytest. The runtime image installs +# only requirements.txt; pytest must never ship in runtime dependencies (issue #185). +-r requirements.txt +ast-serialize==0.8.0 +coverage==7.15.4 +iniconfig==2.3.0 +librt==0.15.0 +mypy==2.3.1 +mypy_extensions==1.1.0 +pathspec==1.1.1 +pluggy==1.6.0 +pytest==9.1.1 +pytest-cov==7.1.0 +ruff==0.16.4 diff --git a/apps/backend/requirements.txt b/apps/backend/requirements.txt new file mode 100644 index 00000000..d9b77d90 --- /dev/null +++ b/apps/backend/requirements.txt @@ -0,0 +1,74 @@ +# Pinned backend dependency lockfile for reproducible installs and Dependabot +# pip tracking. Abstract version ranges live in pyproject.toml; this file pins the +# concrete resolved set so CI and Dependabot agree. +# +# Regenerate after changing pyproject.toml dependencies: +# python -m venv /tmp/lock && /tmp/lock/bin/pip install ./apps/backend +# /tmp/lock/bin/pip freeze | grep -v partha-backend > apps/backend/requirements.txt +alembic==1.19.1 +annotated-doc==0.0.5 +annotated-types==0.8.0 +anyio==4.14.2 +arabic-reshaper==3.0.1 +argon2-cffi==25.1.0 +argon2-cffi-bindings==26.1.0 +asn1crypto==1.5.1 +certifi==2026.7.22 +cffi==2.1.1 +charset-normalizer==3.5.1 +click==8.4.2 +colorama==0.4.6 +cryptography==50.0.0 +cssselect2==0.9.0 +dnspython==2.8.0 +email-validator==2.3.0 +fastapi==0.141.1 +greenlet==3.5.5 +h11==0.16.0 +html5lib==1.1 +httpcore==1.0.9 +httptools==0.8.0 +httpx==0.28.1 +idna==3.19 +lxml==6.1.2 +Mako==1.4.1 +MarkupSafe==3.0.3 +oscrypto==1.3.0 +packaging==26.3 +pillow==12.3.0 +psycopg-binary==3.3.4 +psycopg==3.3.4 +pycparser==3.0 +pydantic-settings==2.15.0 +pydantic==2.13.4 +pydantic_core==2.46.4 +Pygments==2.21.0 +pyhanko-certvalidator==0.31.4 +pyHanko==0.36.2 +PyJWT==2.13.0 +pypdf==6.16.1 +python-bidi==0.6.11 +python-dotenv==1.2.3 +python-multipart==0.0.32 +PyYAML==6.0.3 +redis==8.1.0 +reportlab==4.5.1 +requests==2.34.2 +six==1.17.0 +SQLAlchemy==2.0.52 +starlette==1.6.0 +svglib==2.2.0 +tinycss2==1.5.1 +tree-sitter==0.26.0 +tree-sitter-typescript==0.23.2 +typing-inspection==0.4.4 +typing_extensions==4.16.0 +tzdata==2026.3 +tzlocal==5.4.4 +uritools==6.1.3 +urllib3==2.7.0 +uvicorn==0.52.4 +watchfiles==1.2.0 +webencodings==0.6.1 +websockets==17.0.1 +xhtml2pdf==0.2.17 diff --git a/apps/backend/scripts/approve_email.py b/apps/backend/scripts/approve_email.py new file mode 100644 index 00000000..0a9a2d83 --- /dev/null +++ b/apps/backend/scripts/approve_email.py @@ -0,0 +1,69 @@ +"""Approve an email address for registration (#374). + +Replaces the retired single-use invite codes (#341): access during the +invite-only beta is now gated by an admin-managed allowlist instead, and +this script is the v1 mechanism for adding to it -- there is no admin UI or +API route for this, matching the same scope the retired invite-issuing +script had. + +An approved email is not a scarce secret and is not consumed by use: it +stays approved indefinitely (re-registering the same email a second time is +already rejected by the normal email-uniqueness check, regardless of this +table), so approving the same address twice is a harmless no-op, not an +error. + + python scripts/approve_email.py --email jane@example.com --note "waitlist: 2026-08-29" + python scripts/approve_email.py --email jane@example.com --added-by parth +""" + +from __future__ import annotations + +import argparse +import getpass +import sys +from pathlib import Path +from uuid import uuid4 + +BACKEND_ROOT = Path(__file__).resolve().parents[1] +if str(BACKEND_ROOT) not in sys.path: + sys.path.insert(0, str(BACKEND_ROOT)) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--email", required=True, help="The email address to approve for registration.") + parser.add_argument( + "--note", + default=None, + help="Optional operator-only label (e.g. which waitlist entry this is for). Never shown to the registrant.", + ) + parser.add_argument( + "--added-by", + default=None, + help="Optional operator-only label for who ran this (defaults to the current OS user).", + ) + args = parser.parse_args() + + from sqlalchemy import select + + from app.core.database import SessionLocal + from app.models.approved_email import ApprovedEmail + + normalized = args.email.strip().lower() + added_by = args.added_by or getpass.getuser() + + with SessionLocal() as session: + existing = session.scalars(select(ApprovedEmail).where(ApprovedEmail.email == normalized)).first() + if existing is not None: + print(f"{normalized} is already approved (added {existing.created_at.isoformat()}).") + return 0 + + session.add(ApprovedEmail(id=str(uuid4()), email=normalized, note=args.note, added_by=added_by)) + session.commit() + + print(f"Approved {normalized}.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/apps/backend/scripts/list_waitlist.py b/apps/backend/scripts/list_waitlist.py new file mode 100644 index 00000000..c88bde51 --- /dev/null +++ b/apps/backend/scripts/list_waitlist.py @@ -0,0 +1,65 @@ +"""List waitlist signups, newest first (#334). + +No admin UI for v1, matching approve_email.py's own scope -- this is the +owner's review step before deciding who to approve next. + + python scripts/list_waitlist.py + python scripts/list_waitlist.py --csv > waitlist.csv +""" + +from __future__ import annotations + +import argparse +import csv +import sys +from pathlib import Path + +BACKEND_ROOT = Path(__file__).resolve().parents[1] +if str(BACKEND_ROOT) not in sys.path: + sys.path.insert(0, str(BACKEND_ROOT)) + +_FORMULA_TRIGGER_CHARS = ("=", "+", "-", "@") + + +def _csv_safe(value: str) -> str: + """Neutralize CSV/formula injection (a leading '=', '+', '-' or '@' is + interpreted as a formula by Excel/Sheets when this file is opened + there). A leading apostrophe forces the cell to be read as text.""" + + if value.startswith(_FORMULA_TRIGGER_CHARS): + return f"'{value}" + return value + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--csv", action="store_true", help="Write CSV to stdout instead of a human-readable table.") + args = parser.parse_args() + + from sqlalchemy import select + + from app.core.database import SessionLocal + from app.models.waitlist_entry import WaitlistEntry + + with SessionLocal() as session: + entries = session.scalars(select(WaitlistEntry).order_by(WaitlistEntry.created_at.desc())).all() + + if args.csv: + writer = csv.writer(sys.stdout) + writer.writerow(["created_at", "email", "name"]) + for entry in entries: + writer.writerow([entry.created_at.isoformat(), _csv_safe(entry.email), _csv_safe(entry.name or "")]) + return 0 + + if not entries: + print("No waitlist signups yet.") + return 0 + + for entry in entries: + print(f"{entry.created_at.isoformat()} {entry.email} {entry.name or ''}".rstrip()) + print(f"\n{len(entries)} total.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/apps/backend/scripts/rehearse_backup_restore.py b/apps/backend/scripts/rehearse_backup_restore.py new file mode 100644 index 00000000..bf81cd7e --- /dev/null +++ b/apps/backend/scripts/rehearse_backup_restore.py @@ -0,0 +1,427 @@ +"""Rehearse a full PARTHA backup and restore on disposable targets. + +Proves the two things a production incident actually needs proven (#323): +that a backup of the *database* and the *repository storage directory* can +be taken, restored into a clean environment, and come back byte-for-byte +and row-for-row identical -- ownership, hashes, and the sealed ri.v1 +snapshot's canonical graph hash included. + +Like ``rehearse_migrations.py``, this command accepts no database URL and +cannot be pointed at an existing application database or storage directory: +every target it touches is one it created itself, named with a random +suffix, and removed again before exit (even on failure). SQLite is the +default and always available (backup is a file copy, matching how SQLite +itself defines a consistent backup); PostgreSQL is opt-in and reuses the +exact same disposable-server confirmation gate as the migration rehearsal +(``PARTHA_MIGRATION_REHEARSAL_CONFIRM=disposable`` and +``PARTHA_MIGRATION_REHEARSAL_PG_URL``), and additionally requires the +``pg_dump``/``pg_restore`` client binaries, which are not universally +present (see the module docstring's "Known gaps" note below and the +companion doc, docs/operations/backup-restore.md). + +Known gaps this rehearsal does NOT prove, recorded here rather than implied: + +- Encryption at rest and backup retention windows are the hosting + provider's responsibility (Render-managed PostgreSQL), not this + application's code, so they cannot be rehearsed by a local script -- + docs/operations/backup-restore.md records the expected policy instead. +- This rehearses a clean, quiescent restore. It does not rehearse restoring + under concurrent production write load, nor point-in-time recovery to a + timestamp between backups. +- Filesystem "backup" here is a directory copy, matching the local storage + backend used in this rehearsal. A different storage backend (e.g. object + storage) would need its own rehearsal, not this one. +""" + +from __future__ import annotations + +import argparse +import hashlib +import os +import shutil +import subprocess +import sys +import tempfile +import time +import uuid +from collections.abc import Callable, Iterator +from contextlib import contextmanager +from datetime import UTC, datetime +from pathlib import Path + +from sqlalchemy import MetaData, Table, create_engine, inspect, select +from sqlalchemy.engine import URL, Engine, make_url +from sqlalchemy.exc import SQLAlchemyError + +BACKEND_ROOT = Path(__file__).resolve().parents[1] +if str(BACKEND_ROOT) not in sys.path: + sys.path.insert(0, str(BACKEND_ROOT)) + +OWNER_ID = "10000000-0000-0000-0000-000000000323" +REPOSITORY_ID = "20000000-0000-0000-0000-000000000323" +SNAPSHOT_TABLES = ("users", "repositories", "ri_snapshots", "ri_nodes") + + +class RehearsalError(RuntimeError): + """A safe-to-print rehearsal failure.""" + + +def _alembic_config(database_url: str): + from alembic.config import Config + + os.environ["DATABASE_URL"] = database_url + os.environ.setdefault("CORS_ORIGINS", "http://backup-rehearsal.invalid") + from app.core import config as app_config + + app_config.get_settings.cache_clear() + cfg = Config(str(BACKEND_ROOT / "alembic.ini")) + cfg.set_main_option("script_location", str(BACKEND_ROOT / "alembic")) + return cfg + + +def _seed(database_url: str, storage_root: Path) -> dict[str, object]: + """Bring a disposable database to head and seed representative, + non-partner, non-private data: one user, one owner-scoped repository, + and one sealed ri.v1 snapshot with a real evidence-backed node -- plus + matching content on disk under ``storage_root``, the same pairing a real + repository import produces.""" + + from alembic import command + + # Deliberately NOT `from app.core.database import SessionLocal`: that + # module binds its engine to get_settings().database_url once, at first + # import, as process-wide module state -- a later os.environ change or + # get_settings.cache_clear() (see _alembic_config below) cannot rebind + # it. Using it here would silently target whatever database URL was + # active the first time ANYTHING imported app.core.database in this + # process (e.g. a real local Postgres from .env), not the disposable + # target this function was given. Every session below is bound to an + # engine this function creates itself, for exactly `database_url`. + from app.auth.security import hash_password + from app.intelligence.snapshot_store import Evidence, Revision, SnapshotStore + from app.models.repository import RepositoryRecord + from app.models.user import User + from sqlalchemy.orm import Session + + cfg = _alembic_config(database_url) + target_engine = create_engine(database_url) + if inspect(target_engine).get_table_names(): + target_engine.dispose() + raise RehearsalError("A rehearsal target was unexpectedly non-empty; refusing to continue.") + target_engine.dispose() + command.upgrade(cfg, "head") + + repo_dir = storage_root / "repositories" / REPOSITORY_ID + repo_dir.mkdir(parents=True) + readme = repo_dir / "README.md" + readme.write_text("# backup-restore rehearsal fixture\n", encoding="utf-8") + + seed_engine = create_engine(database_url) + session = Session(bind=seed_engine) + try: + # Committed separately, in FK order: there's no ORM relationship() + # between User and RepositoryRecord for SQLAlchemy's unit-of-work to + # infer insert order from, so a single flush can (and on real + # Postgres, did, while SQLite's default FK enforcement stayed silent + # about it) emit the repositories insert before the users insert. + session.add( + User( + id=OWNER_ID, + email="backup-rehearsal@example.invalid", + password_hash=hash_password("not-a-real-password-rehearsal-only"), + created_at=datetime.now(UTC), + ) + ) + session.commit() + session.add( + RepositoryRecord( + id=REPOSITORY_ID, + owner_id=OWNER_ID, + name="backup-restore-rehearsal", + source="upload", + local_path=str(repo_dir), + status="completed", + revision_kind="upload", + revision_value=f"sha256:{'0' * 64}", + file_count=1, + size=readme.stat().st_size, + ) + ) + session.commit() + + store = SnapshotStore(session) + snapshot = store.begin( + repository_id=REPOSITORY_ID, + revision=Revision("upload", f"sha256:{'0' * 64}", None), + schema_version="ri.v1", + producer_version_set=["repository-inventory@1.1.0"], + ) + store.add_node( + snapshot, + node_kind="repository", + stable_key="repo:root", + name="backup-restore-rehearsal", + language=None, + evidence=[ + Evidence( + path="README.md", + start_line=1, + end_line=1, + logical_line_count=1, + extractor="repository-inventory", + extractor_version="1.1.0", + ) + ], + ) + store.seal(snapshot) + canonical_graph_hash = snapshot.canonical_graph_hash + finally: + session.close() + seed_engine.dispose() + + return { + "canonical_graph_hash": canonical_graph_hash, + "readme_sha256": hashlib.sha256(readme.read_bytes()).hexdigest(), + } + + +def _verify(database_url: str, storage_root: Path, expected: dict[str, object]) -> None: + """Read the restored target back and assert it matches the seed exactly.""" + + engine = create_engine(database_url) + try: + actual_tables = set(inspect(engine).get_table_names()) + missing = set(SNAPSHOT_TABLES) - actual_tables + if missing: + raise RehearsalError(f"Restored database is missing table(s): {', '.join(sorted(missing))}.") + + users = Table("users", MetaData(), autoload_with=engine) + repositories = Table("repositories", MetaData(), autoload_with=engine) + snapshots = Table("ri_snapshots", MetaData(), autoload_with=engine) + + with engine.connect() as connection: + # Every schema also carries the migration-seeded system user + # (app.models.user.SEED_USER_ID), so the total count is 2, not 1; + # check for the specific rehearsal user by id instead. + user_row = connection.execute(select(users.c.id).where(users.c.id == OWNER_ID)).one_or_none() + if user_row is None: + raise RehearsalError("The seeded rehearsal user did not survive restore.") + + repo_row = connection.execute( + select(repositories.c.owner_id, repositories.c.local_path).where(repositories.c.id == REPOSITORY_ID) + ).one_or_none() + if repo_row is None: + raise RehearsalError("The seeded repository row did not survive restore.") + if repo_row.owner_id != OWNER_ID: + raise RehearsalError("Restored repository lost its owner scoping.") + + snapshot_row = connection.execute( + select(snapshots.c.canonical_graph_hash).where(snapshots.c.repository_id == REPOSITORY_ID) + ).one_or_none() + if snapshot_row is None: + raise RehearsalError("The sealed snapshot did not survive restore.") + if snapshot_row.canonical_graph_hash != expected["canonical_graph_hash"]: + raise RehearsalError( + "Restored snapshot's canonical_graph_hash does not match the pre-backup value -- " + "the sealed graph identity did not survive restore intact." + ) + finally: + engine.dispose() + + restored_readme = storage_root / "repositories" / REPOSITORY_ID / "README.md" + if not restored_readme.is_file(): + raise RehearsalError("Restored repository storage is missing README.md.") + actual_hash = hashlib.sha256(restored_readme.read_bytes()).hexdigest() + if actual_hash != expected["readme_sha256"]: + raise RehearsalError("Restored repository file content hash does not match the pre-backup value.") + + +# --- SQLite: backup is a file copy, restore is a file copy ----------------- + + +def _sqlite_backup_restore(_database_url_unused: str) -> None: + with tempfile.TemporaryDirectory(prefix="partha-backup-rehearsal-") as workdir: + root = Path(workdir) + primary_db = root / "primary.db" + primary_storage = root / "primary-storage" + primary_storage.mkdir() + + started = time.monotonic() + expected = _seed(f"sqlite:///{primary_db.as_posix()}", primary_storage) + seed_seconds = time.monotonic() - started + + # "Backup": copy the consistent SQLite file and the storage tree. + # "Restore": copy them again into a clean target -- proving the + # backup artifact alone is sufficient, independent of the primary. + backup_db = root / "backup.db" + backup_storage = root / "backup-storage" + shutil.copy2(primary_db, backup_db) + shutil.copytree(primary_storage, backup_storage) + + restored_db = root / "restored.db" + restored_storage = root / "restored-storage" + restore_started = time.monotonic() + shutil.copy2(backup_db, restored_db) + shutil.copytree(backup_storage, restored_storage) + restore_seconds = time.monotonic() - restore_started + + _verify(f"sqlite:///{restored_db.as_posix()}", restored_storage, expected) + print( + f" seed: {seed_seconds:.2f}s, restore: {restore_seconds:.2f}s " + "(local file copy -- not representative of production network/disk throughput)" + ) + + +# --- PostgreSQL: opt-in, mirrors rehearse_migrations.py's disposable-server gate --- + + +def _require_pg_tools() -> None: + missing = [tool for tool in ("pg_dump", "pg_restore") if shutil.which(tool) is None] + if missing: + raise RehearsalError( + f"PostgreSQL rehearsal requires {' and '.join(missing)} on PATH " + "(the client tools, not just a Postgres driver)." + ) + + +def _postgres_rehearsal_admin_url() -> URL: + if os.environ.get("PARTHA_MIGRATION_REHEARSAL_CONFIRM") != "disposable": + raise RehearsalError( + "PostgreSQL rehearsal requires PARTHA_MIGRATION_REHEARSAL_CONFIRM=disposable. " + "Use only a dedicated rehearsal server." + ) + configured_url = os.environ.get("PARTHA_MIGRATION_REHEARSAL_PG_URL") + if not configured_url: + raise RehearsalError( + "PostgreSQL rehearsal requires PARTHA_MIGRATION_REHEARSAL_PG_URL; its value is never printed." + ) + admin_url = make_url(configured_url) + if admin_url.get_backend_name() != "postgresql": + raise RehearsalError("PARTHA_MIGRATION_REHEARSAL_PG_URL must be a PostgreSQL URL.") + return admin_url + + +@contextmanager +def _postgres_database(admin_url: URL, engine: Engine, name: str) -> Iterator[str]: + quoted_name = engine.dialect.identifier_preparer.quote(name) + with engine.connect() as connection: + connection.exec_driver_sql(f"CREATE DATABASE {quoted_name}") + try: + yield admin_url.set(database=name).render_as_string(hide_password=False) + finally: + try: + with engine.connect() as connection: + connection.exec_driver_sql(f"DROP DATABASE IF EXISTS {quoted_name} WITH (FORCE)") + except Exception: + print( + f"WARNING: failed to drop disposable rehearsal database {name}; it may require manual cleanup.", + file=sys.stderr, + ) + + +def _postgres_backup_restore(_database_url_unused: str) -> None: + _require_pg_tools() + admin_url = _postgres_rehearsal_admin_url() + engine = create_engine(admin_url, isolation_level="AUTOCOMMIT") + suffix = uuid.uuid4().hex + try: + with tempfile.TemporaryDirectory(prefix="partha-backup-rehearsal-") as workdir: + root = Path(workdir) + primary_storage = root / "primary-storage" + primary_storage.mkdir() + dump_path = root / "backup.dump" + + with _postgres_database(admin_url, engine, f"partha_backup_rehearsal_primary_{suffix}") as primary_url: + started = time.monotonic() + expected = _seed(primary_url, primary_storage) + seed_seconds = time.monotonic() - started + + primary_conn = make_url(primary_url) + dump_env = {**os.environ, "PGPASSWORD": primary_conn.password or ""} + subprocess.run( + [ + "pg_dump", + "--format=custom", + f"--host={primary_conn.host}", + f"--port={primary_conn.port or 5432}", + f"--username={primary_conn.username}", + f"--dbname={primary_conn.database}", + f"--file={dump_path}", + ], + check=True, + env=dump_env, + capture_output=True, + text=True, + ) + + restored_storage = root / "restored-storage" + shutil.copytree(primary_storage, restored_storage) + + with _postgres_database(admin_url, engine, f"partha_backup_rehearsal_restore_{suffix}") as restore_url: + restore_conn = make_url(restore_url) + restore_env = {**os.environ, "PGPASSWORD": restore_conn.password or ""} + restore_started = time.monotonic() + subprocess.run( + [ + "pg_restore", + f"--host={restore_conn.host}", + f"--port={restore_conn.port or 5432}", + f"--username={restore_conn.username}", + f"--dbname={restore_conn.database}", + str(dump_path), + ], + check=True, + env=restore_env, + capture_output=True, + text=True, + ) + restore_seconds = time.monotonic() - restore_started + + _verify(restore_url, restored_storage, expected) + print(f" seed: {seed_seconds:.2f}s, restore: {restore_seconds:.2f}s (pg_dump/pg_restore)") + finally: + engine.dispose() + + +def _run(name: str, operation: Callable[[str], None]) -> None: + try: + operation("") + except RehearsalError: + raise + except subprocess.CalledProcessError as error: + # pg_dump/pg_restore error output can echo the connection string + # they were given; never print stdout/stderr, only the exit code. + raise RehearsalError( + f"{name} failed: {Path(error.cmd[0]).name} exited {error.returncode}. " + "The target was disposable and has been removed." + ) from error + except SQLAlchemyError as error: + raise RehearsalError( + f"{name} failed with a database error ({type(error).__name__}). The target was disposable " + "and has been removed." + ) from error + print(f"PASS {name}") + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument( + "--postgres", + action="store_true", + help="use pg_dump/pg_restore against the explicitly confirmed disposable rehearsal PostgreSQL server", + ) + args = parser.parse_args() + operation = _postgres_backup_restore if args.postgres else _sqlite_backup_restore + label = "PostgreSQL (pg_dump/pg_restore)" if args.postgres else "SQLite (file copy)" + print(f"Starting {label} backup/restore rehearsal on self-created disposable targets.") + try: + _run("seed -> backup -> restore into a clean target -> verify integrity and ownership", operation) + except RehearsalError as error: + print(f"FAIL backup/restore rehearsal: {error}") + return 1 + print("PASS backup/restore rehearsal completed; disposable targets were removed.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/apps/backend/scripts/rehearse_migrations.py b/apps/backend/scripts/rehearse_migrations.py new file mode 100644 index 00000000..5a84e705 --- /dev/null +++ b/apps/backend/scripts/rehearse_migrations.py @@ -0,0 +1,311 @@ +"""Rehearse PARTHA's Alembic chain on databases this command creates itself. + +The command intentionally accepts no database URL. Its default target is a +temporary SQLite file. ``--postgres`` is opt-in and creates a uniquely named +database on a dedicated rehearsal server configured through the environment. +Neither mode can be pointed at an existing application database. +""" + +from __future__ import annotations + +import argparse +import os +import sys +import tempfile +import uuid +from collections.abc import Callable, Iterator +from contextlib import AbstractContextManager, contextmanager +from datetime import UTC, datetime +from pathlib import Path + +from alembic import command +from alembic.config import Config +from alembic.script import ScriptDirectory +from sqlalchemy import MetaData, Table, create_engine, inspect, select +from sqlalchemy.engine import URL, Engine, make_url +from sqlalchemy.exc import SQLAlchemyError + + +BACKEND_ROOT = Path(__file__).resolve().parents[1] +if str(BACKEND_ROOT) not in sys.path: + sys.path.insert(0, str(BACKEND_ROOT)) + +HEAD_REVISION = "0016_approved_emails" +REPRESENTATIVE_BASELINE = "0004_ai_provider_configs" +REQUIRED_HEAD_TABLES = { + "users", + "repositories", + "ri_snapshots", + "analysis_jobs", + "ai_conversation_messages", + "account_deletion_audits", + "invite_tokens", + "waitlist_entries", + "repository_lineages", + "oauth_identities", + "oauth_flow_states", + "oauth_pending_links", + "approved_emails", +} + + +class RehearsalError(RuntimeError): + """A safe-to-print rehearsal failure.""" + + +def _alembic_config(database_url: str) -> Config: + """Build Alembic config after selecting an isolated target.""" + + os.environ["DATABASE_URL"] = database_url + os.environ.setdefault("CORS_ORIGINS", "http://migration-rehearsal.invalid") + from app.core import config as app_config + + app_config.get_settings.cache_clear() + cfg = Config(str(BACKEND_ROOT / "alembic.ini")) + cfg.set_main_option("script_location", str(BACKEND_ROOT / "alembic")) + return cfg + + +def _revision(engine: Engine) -> str: + version = Table("alembic_version", MetaData(), autoload_with=engine) + with engine.connect() as connection: + value = connection.scalar(select(version.c.version_num)) + if not isinstance(value, str): + raise RehearsalError("Alembic did not record a revision.") + return value + + +def _assert_head(engine: Engine) -> None: + missing_tables = REQUIRED_HEAD_TABLES - set(inspect(engine).get_table_names()) + if missing_tables: + raise RehearsalError(f"Head is missing required table(s): {', '.join(sorted(missing_tables))}.") + actual_revision = _revision(engine) + if actual_revision != HEAD_REVISION: + raise RehearsalError( + f"Alembic reached revision {actual_revision!r} but this script expects {HEAD_REVISION!r}. " + "If a new migration was added, update HEAD_REVISION (and REQUIRED_HEAD_TABLES if the " + "schema changed) in rehearse_migrations.py." + ) + + +def _assert_chain(cfg: Config) -> None: + heads = ScriptDirectory.from_config(cfg).get_heads() + if len(heads) != 1: + raise RehearsalError( + f"Expected exactly one Alembic head, found {len(heads)}: {', '.join(sorted(heads))}. " + "Inspect the migration graph for an unintended branch before rehearsing." + ) + if heads[0] != HEAD_REVISION: + raise RehearsalError( + f"Alembic head is {heads[0]!r} but this script expects {HEAD_REVISION!r}. " + "If a new migration was added, update HEAD_REVISION (and REQUIRED_HEAD_TABLES if the " + "schema changed) in rehearse_migrations.py." + ) + + +def _exercise_clean_chain(database_url: str) -> None: + cfg = _alembic_config(database_url) + _assert_chain(cfg) + engine = create_engine(database_url) + try: + if inspect(engine).get_table_names(): + raise RehearsalError("A rehearsal target was unexpectedly non-empty; refusing to continue.") + command.upgrade(cfg, "head") + _assert_head(engine) + + # This proves revision mechanics only. It is safe because the target + # is empty; it does not claim production downgrades preserve data. + command.downgrade(cfg, "base") + if "repositories" in inspect(engine).get_table_names(): + raise RehearsalError("Clean downgrade did not return the target to base.") + command.upgrade(cfg, "head") + _assert_head(engine) + finally: + engine.dispose() + + +def _insert_representative_0004_row(engine: Engine) -> str: + """Insert an old-format repository row before the 0005 backfill.""" + + repositories = Table("repositories", MetaData(), autoload_with=engine) + repository_id = "10000000-0000-0000-0000-000000000322" + now = datetime.now(UTC) + with engine.begin() as connection: + connection.execute( + repositories.insert().values( + id=repository_id, + owner_id="00000000-0000-0000-0000-000000000000", + name="representative-legacy-github-import", + description=None, + source="github", + source_url="https://github.com/example/representative", + branch="main", + local_path="/rehearsal/legacy", + size=0, + file_count=0, + status="completed", + data_source="real", + analysis_stage=None, + analysis_progress=100, + uploaded_at=now, + analysed_at=now, + error_message=None, + file_tree=[], + repo_metadata={"commitSha": "a" * 40, "intelligence": {"legacy": True}}, + created_at=now, + updated_at=now, + ) + ) + return repository_id + + +def _exercise_representative_baseline(database_url: str) -> None: + cfg = _alembic_config(database_url) + _assert_chain(cfg) + engine = create_engine(database_url) + try: + if inspect(engine).get_table_names(): + raise RehearsalError("A rehearsal target was unexpectedly non-empty; refusing to continue.") + command.upgrade(cfg, REPRESENTATIVE_BASELINE) + if _revision(engine) != REPRESENTATIVE_BASELINE: + raise RehearsalError("Could not prepare the representative supported baseline.") + repository_id = _insert_representative_0004_row(engine) + + command.upgrade(cfg, "head") + _assert_head(engine) + repositories = Table("repositories", MetaData(), autoload_with=engine) + with engine.connect() as connection: + row = connection.execute( + select( + repositories.c.revision_kind, + repositories.c.revision_value, + repositories.c.revision_ref, + repositories.c.repo_metadata, + ).where(repositories.c.id == repository_id) + ).one() + if tuple(row[:3]) != ("git", "a" * 40, "refs/heads/main"): + raise RehearsalError("The representative 0004 repository was not backfilled as expected.") + if row.repo_metadata != {"commitSha": "a" * 40, "intelligence": {"legacy": True}}: + raise RehearsalError("The representative legacy metadata was unexpectedly changed.") + finally: + engine.dispose() + + +@contextmanager +def _sqlite_target() -> Iterator[str]: + with tempfile.TemporaryDirectory(prefix="partha-migration-rehearsal-") as temporary_directory: + yield f"sqlite:///{(Path(temporary_directory) / 'rehearsal.db').as_posix()}" + + +def _postgres_rehearsal_url() -> URL: + if os.environ.get("PARTHA_MIGRATION_REHEARSAL_CONFIRM") != "disposable": + raise RehearsalError( + "PostgreSQL rehearsal requires PARTHA_MIGRATION_REHEARSAL_CONFIRM=disposable. " + "Use only a dedicated rehearsal server." + ) + configured_url = os.environ.get("PARTHA_MIGRATION_REHEARSAL_PG_URL") + if not configured_url: + raise RehearsalError( + "PostgreSQL rehearsal requires PARTHA_MIGRATION_REHEARSAL_PG_URL; its value is never printed." + ) + admin_url = make_url(configured_url) + if admin_url.get_backend_name() != "postgresql": + raise RehearsalError("PARTHA_MIGRATION_REHEARSAL_PG_URL must be a PostgreSQL URL.") + return admin_url + + +@contextmanager +def _postgres_target() -> Iterator[str]: + admin_url = _postgres_rehearsal_url() + database_name = f"partha_migration_rehearsal_{uuid.uuid4().hex}" + engine = create_engine(admin_url, isolation_level="AUTOCOMMIT") + created = False + operation_failed = False + quoted_name = engine.dialect.identifier_preparer.quote(database_name) + try: + with engine.connect() as connection: + connection.exec_driver_sql(f"CREATE DATABASE {quoted_name}") + created = True + try: + yield admin_url.set(database=database_name).render_as_string(hide_password=False) + except BaseException: + operation_failed = True + raise + finally: + cleanup_error: Exception | None = None + try: + if created: + with engine.connect() as connection: + connection.exec_driver_sql(f"DROP DATABASE IF EXISTS {quoted_name} WITH (FORCE)") + except Exception as error: + cleanup_error = error + # Never let a teardown failure replace or hide the original + # exception being propagated through this `finally`, and always + # still reach `engine.dispose()` below. Print the disposable + # database's name (a random UUID, safe to print) so an operator + # can find and drop it manually if this warning is seen. + print( + f"WARNING: failed to drop disposable rehearsal database {database_name} " + f"({type(cleanup_error).__name__}); it may require manual cleanup.", + file=sys.stderr, + ) + finally: + engine.dispose() + if cleanup_error is not None and not operation_failed: + raise RehearsalError( + f"Cleanup failed for disposable rehearsal database {database_name}; remove it manually before retrying." + ) from cleanup_error + + +def _run_phase( + name: str, + operation: Callable[[str], None], + target_factory: Callable[[], AbstractContextManager[str]], +) -> None: + try: + with target_factory() as database_url: + operation(database_url) + except RehearsalError: + raise + except SQLAlchemyError as error: + # Database driver errors can embed the connection URL (and its + # credentials) in their message, so only the exception type is + # surfaced here. The target was disposable and has been removed. + raise RehearsalError( + f"{name} failed with a database error ({type(error).__name__}). The target was disposable; " + "review the Alembic output above and the migration runbook before retrying." + ) from error + except Exception as error: + # Future migration code can raise arbitrary exceptions whose messages + # include environment-derived values. Surface the type, never the + # untrusted message, so a URL or credential cannot leak into logs. + raise RehearsalError( + f"{name} failed with {type(error).__name__}. The target was disposable; " + "review the Alembic output above and the migration runbook before retrying." + ) from error + print(f"PASS {name}") + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--postgres", + action="store_true", + help="use a uniquely named database on the explicitly confirmed rehearsal PostgreSQL server", + ) + args = parser.parse_args() + target_factory = _postgres_target if args.postgres else _sqlite_target + target_label = "PostgreSQL" if args.postgres else "SQLite" + print(f"Starting {target_label} migration rehearsal on self-created disposable databases.") + try: + _run_phase("clean upgrade -> clean downgrade -> re-upgrade", _exercise_clean_chain, target_factory) + _run_phase("representative 0004 baseline -> head", _exercise_representative_baseline, target_factory) + except RehearsalError as error: + print(f"FAIL migration rehearsal: {error}") + return 1 + print("PASS migration rehearsal completed; disposable targets were removed.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/apps/backend/tests/analysis_helpers.py b/apps/backend/tests/analysis_helpers.py new file mode 100644 index 00000000..3b0fac15 --- /dev/null +++ b/apps/backend/tests/analysis_helpers.py @@ -0,0 +1,26 @@ +"""Test helpers for driving the durable analysis worker (#93). + +The background worker thread is disabled in tests (``ANALYSIS_WORKER_AUTOSTART`` +is false in the ``client`` fixture), so tests that trigger ``POST +/analysis/{id}/start`` call :func:`run_analysis_jobs` right afterwards to execute +the enqueued work synchronously and deterministically instead of polling. +""" + +from __future__ import annotations + + +def run_analysis_jobs(worker_id: str = "test-worker") -> int: + """Claim and run every currently-queued analysis job to completion. + + Returns the number of jobs claimed. Uses the process-wide ``SessionLocal``, + which the ``client`` fixture has already bound to the test database. + """ + + from app.core.database import SessionLocal + from app.workers.analysis_worker import AnalysisWorker + + worker = AnalysisWorker(SessionLocal, worker_id=worker_id, lease_seconds=60) + claimed = 0 + while worker.run_once(): + claimed += 1 + return claimed diff --git a/apps/backend/tests/api_assertions.py b/apps/backend/tests/api_assertions.py new file mode 100644 index 00000000..4b50b9d7 --- /dev/null +++ b/apps/backend/tests/api_assertions.py @@ -0,0 +1,24 @@ +"""Small assertions shared by HTTP integration tests.""" + +from httpx import Response + +from app.core.exceptions import ErrorResponse + +ERROR_RESPONSE_FIELDS = frozenset({"code", "message", "details", "request_id"}) + + +def assert_error_response(response: Response, status_code: int, code: str) -> ErrorResponse: + """Assert the exact public error envelope, including request correlation.""" + assert response.status_code == status_code, response.text + payload = response.json() + assert isinstance(payload, dict), f"error response must be a JSON object: {payload!r}" + assert set(payload) == ERROR_RESPONSE_FIELDS, ( + f"error response fields must be exactly {sorted(ERROR_RESPONSE_FIELDS)}; got {sorted(payload)}" + ) + + error = ErrorResponse.model_validate(payload) + assert error.code == code + assert error.request_id == response.headers.get("X-Request-ID"), ( + "error response request_id must match the X-Request-ID header" + ) + return error diff --git a/apps/backend/tests/benchmark/README.md b/apps/backend/tests/benchmark/README.md new file mode 100644 index 00000000..f068257e --- /dev/null +++ b/apps/backend/tests/benchmark/README.md @@ -0,0 +1,151 @@ +# Repository Intelligence golden benchmark (Issue #94) + +A deterministic, auditable benchmark that measures the Repository Intelligence +extractors against **independently authored** golden facts: extraction precision +and recall, citation/provenance validity, and snapshot-hash determinism. It is +the regression guard that answers "is the repository model actually correct?", +which a green test suite alone cannot. + +The default runner sends every applicable fixture's stored bytes through the +production source-policy pipeline and the exact extractor set the durable worker +runs — `production_extractors()` in `app/extraction/__init__.py`, covering +Python, TypeScript, dependency manifests, lockfiles, and IaC — then applies the +same cross-file dependency reducer the worker applies before persistence. It +compares only real emitted nodes, observations, and diagnostics with the +independently authored golden facts; it never copies expected output into the +actual side. Measuring a narrower pipeline than the product runs would let a +fixture covering a lockfile or IaC construct silently produce nothing. + +## Where things live + +| Path | What it is | +| --- | --- | +| [`fixtures/{minimal,realistic,adversarial}/`](fixtures) | The versioned golden corpus: synthetic source + a `manifest.json` of expected facts per fixture. | +| [`config/benchmark_support_matrix.json`](config/benchmark_support_matrix.json) | Benchmark-only construct taxonomy mapped to every production capability id; status and limitations come from the production registry. | +| [`config/thresholds.json`](config/thresholds.json) | The versioned, exact-fraction acceptance thresholds. | +| [`facts.py`](facts.py) / [`scorer.py`](scorer.py) | The fact model and precision/recall scorer. | +| [`loader.py`](loader.py) / [`schema.py`](schema.py) | Strict manifest loading and validation. | +| [`provenance.py`](provenance.py) | Citation validity against the stored revision (RFC-0001 §6.2). | +| [`determinism.py`](determinism.py) | Repeated real extraction persisted through `SnapshotStore`, with canonical-hash determinism. | +| [`adapter.py`](adapter.py) | Exact conversion from real extractor results to comparable benchmark facts. | +| [`runner.py`](runner.py) / [`report.py`](report.py) / [`run.py`](run.py) | Orchestration, gating, and Markdown/JSON reports. | + +## Running it locally + +From `apps/backend` (with the backend venv active): + +```bash +# Fast unit + integration guards (scorer, loader, provenance, determinism, failure paths): +python -m pytest tests/benchmark + +# The full benchmark command with human/machine reports (writes to a throwaway dir): +python tests/benchmark/run.py --report-dir /tmp/ri-benchmark +cat /tmp/ri-benchmark/benchmark.md +``` + +The runner exits non-zero when any enforced gate fails. This includes an exact +golden regression comparison: a real-extractor fact/evidence change cannot pass +only because aggregate precision and recall remain above threshold. An +intentional baseline change requires a reviewed hand-authored fixture or +expectation diff. **Reports are generated, +never committed** (`.gitignore` excludes them; CI writes them to `$RUNNER_TEMP`). + +## Fixture schema and versioning + +Every fixture is a directory containing synthetic source files and a +`manifest.json` with `schemaVersion: "ri-benchmark.v1"`. Bumping that version is a +deliberate, reviewed migration (constants live in [`schema.py`](schema.py)). Each +manifest declares its `fixtureId`, `fixtureClass` (`minimal` / `realistic` / +`adversarial`), `language`, `revisionIdentity` (`upload-sha256`, a content hash of +the fixture bytes — never a fabricated Git SHA), `producerVersionSet`, +`constructsCovered`, and an `expected` block of nodes / edges / observations / +assertions / diagnostics. Each expected fact carries its normalized evidence +(path + one-based inclusive line span + granularity + extractor). + +`syntheticFiles` is an optional manifest map of raw path to UTF-8 content. It is +part of the deterministic stored-revision byte map, but does not require the raw +path to exist on the host filesystem. This is reserved for source-policy cases +such as a Windows-invalid escaping path; it is not a golden-output generator. + +The loader (`loader.py`) fails clearly on unsupported schema versions, duplicate +fixture ids, duplicate expected identities, missing source files, absolute or +`..`-escaping paths, invalid line ranges, undeclared construct ids, malformed +facts, unsupported languages, inconsistent producer versions, facts missing +mandatory evidence, and machine-blessed output. + +## Metrics and thresholds + +Comparison is by each fact's exact semantic identity (fact type, kind, +subject/object/predicate, node name/language, and the full normalized evidence +span set) — a node with the correct stable key but the wrong name or language +does **not** match, nor does a fact with the wrong line span. Matching is also +scoped by fixture id, so fixture-relative paths can safely repeat across the +corpus. + +``` +precision = TP / (TP + FP) (= 1 when TP + FP = 0, i.e. nothing emitted) +recall = TP / (TP + FN) (= 1 when TP + FN = 0, i.e. nothing expected) +``` + +Counting is multiset-aware, so a duplicate emission is one TP and one FP. The +thresholds (`config/thresholds.json`) are the enforced acceptance bar from Issue +#94: + +| Metric | Threshold | Enforced today? | +| --- | --- | --- | +| precision | ≥ 0.95 | **yes** | +| recall | ≥ 0.95 | **yes** | +| provenance validity | = 1.00 | **yes** | +| determinism | = 1.00 | **yes** | + +Do **not** lower a threshold without documenting the reason on Issue #94 and +getting maintainer agreement. + +## How unsupported constructs are scored + +A construct outside the production capability registry must produce **no fact** plus a specific +diagnostic (e.g. `RI-EXT-UNSUPPORTED`, `RI-SRC-MALFORMED`). In scoring: + +- an invented fact where none is expected is a **false positive**; +- a missing required diagnostic is a **benchmark failure** (the runner checks that + every unsupported construct has a fixture emitting its required diagnostic); +- unsupported constructs are never quietly dropped from the corpus. + +## Adding or reviewing a fixture + +1. Write small, original synthetic source (no third-party/copyrighted code). +2. Derive the expected facts **by hand** from the source and the production capability registry — + count the one-based line spans yourself. Do **not** run any extractor and copy + its output. +3. Add the manifest; tag each fact with the benchmark construct id(s) it covers. +4. `python -m pytest tests/benchmark` — the loader validates structure and + provenance; the runner validates parity, diagnostics, and determinism. + +**There is deliberately no "bless/update snapshots" command.** Golden truth is +reviewed by a human, and a manifest carrying a `blessed`/`generated` marker is +rejected on load. A regeneration helper may only *format or validate* facts a +reviewer has already written. + +## CI + +The `Backend` job runs `python -m pytest` (which includes every benchmark +invariant and failure-path test) and then a dedicated step runs +`tests/benchmark/run.py`, uploads `benchmark.md` / `benchmark.json` as the +`ri-golden-benchmark` artifact (even on failure), writes a summary to the job +page, and fails the job when the benchmark is below threshold. + +## What #94 proves + +- the golden corpus is internally valid and every golden citation resolves to + the stored revision; +- the real extractor output meets the configured precision and recall gates; +- every citation emitted by the real extractors is valid; +- repeated real extraction, including reversed insertion order, seals to the + same product canonical graph hash; +- capability-registry drift, duplicate/cross-fixture facts, invalid citations, bad + precision/recall, and nondeterminism fail the build. + +Durable product analysis uses the same extractors, support matrices, resolver, +and normalized snapshot contract exercised here. Product behavior and current +limitations are documented in +[`docs/architecture/REPOSITORY_INTELLIGENCE.md`](../../../../docs/architecture/REPOSITORY_INTELLIGENCE.md). diff --git a/apps/backend/tests/benchmark/__init__.py b/apps/backend/tests/benchmark/__init__.py new file mode 100644 index 00000000..06df6096 --- /dev/null +++ b/apps/backend/tests/benchmark/__init__.py @@ -0,0 +1,20 @@ +"""Repository Intelligence golden benchmark harness (Issue #94). + +A deterministic, auditable benchmark that measures the Repository Intelligence +extractors against *independently authored* golden facts: extraction precision +and recall, citation/provenance validity, and snapshot-hash determinism. + +This package is **test-only** — it is intentionally outside the ``app`` runtime +package (``pyproject.toml`` ships only ``app*``) so no benchmark abstraction +leaks into the product. It exercises the *real* merged contracts: + +- ``app.intelligence.canonical`` — the pure canonical graph hash and identities + (RFC-0001 §12, §4, §5, §6), and +- ``app.intelligence.snapshot_store`` / ``app.models.snapshot`` — the immutable + ``ri.v1`` ``SnapshotStore`` (RFC-0001 §11). + +The default :mod:`benchmark.adapter` runs the merged Python and TypeScript +extractors through the production source-policy pipeline. Every run therefore +enforces measured precision, recall, golden and real-emission citation validity, +support-matrix parity, and repeated-real-extraction snapshot determinism. +""" diff --git a/apps/backend/tests/benchmark/adapter.py b/apps/backend/tests/benchmark/adapter.py new file mode 100644 index 00000000..47a886fb --- /dev/null +++ b/apps/backend/tests/benchmark/adapter.py @@ -0,0 +1,179 @@ +"""Adapt real Repository Intelligence extraction output into benchmark facts. + +The adapter contains no parsing logic. It runs the production extraction +pipeline over the fixture revision's stored bytes and maps the real immutable +``ExtractionResult`` contract into the benchmark's comparison model. +""" + +from __future__ import annotations + +from collections import OrderedDict +from typing import Protocol, runtime_checkable + +from app.extraction import production_extractors +from app.extraction.dependencies import merge_dependency_facts +from app.extraction.pipeline import ExtractionPipeline, ProducedExtraction + +from benchmark.facts import EvidenceSpan, Fact, canonical_value +from benchmark.loader import LoadedFixture + + +@runtime_checkable +class ExtractionAdapter(Protocol): + """Produces the actual facts the product pipeline emits for a fixture.""" + + name: str + available: bool + + def extract(self, fixture: LoadedFixture) -> list[Fact]: ... + + +class RealExtractionAdapter: + """Run the real Python/TypeScript extractors through production source policy.""" + + name = "repository-intelligence-real-extractors" + available = True + scored_fact_types = frozenset({"node", "observation", "diagnostic"}) + + def extract(self, fixture: LoadedFixture) -> list[Fact]: + pipeline = ExtractionPipeline( + production_extractors(), + max_source_bytes=fixture.max_source_bytes, + ) + facts: list[Fact] = [] + # The same cross-file dependency reducer the worker applies, so a + # fixture's golden dependency node is the merged record the product + # actually persists rather than an unmerged per-file intermediate. + for produced in merge_dependency_facts(pipeline.run(fixture.source_files())): + facts.extend(self._convert(produced)) + return self._merge_compatible_nodes(facts) + + def _convert(self, produced: ProducedExtraction) -> list[Fact]: + result = produced.result + facts: list[Fact] = [] + for node in result.nodes: + facts.append( + Fact( + fact_type="node", + kind=node.node_kind, + subject=node.stable_key, + name=node.name or "", + language=node.language or "", + truth_class="observed", + value=(canonical_value(dict(node.properties)) if node.properties is not None else ""), + evidence=self._evidence(node.evidence, produced), + details={"properties": dict(node.properties or {})}, + ) + ) + for observation in result.observations: + facts.append( + Fact( + fact_type="observation", + kind=observation.observed_kind, + subject=observation.subject_key, + predicate=observation.observed_kind, + referent=observation.referent_text or "", + ordinal=observation.ordinal, + evidence=self._evidence((observation.evidence,), produced), + details={"subjectKind": observation.subject_kind}, + ) + ) + for diagnostic in result.diagnostics: + location = canonical_value( + { + "details": dict(diagnostic.details) if diagnostic.details is not None else None, + "path": diagnostic.path, + "span": ( + { + "startLine": diagnostic.span[0], + "endLine": diagnostic.span[1], + } + if diagnostic.span is not None + else None + ), + } + ) + facts.append( + Fact( + fact_type="diagnostic", + kind=diagnostic.code, + subject=diagnostic.subject or "", + severity=diagnostic.severity, + category=diagnostic.category, + message=diagnostic.message, + producer=produced.producer, + value=location, + details={ + "path": diagnostic.path, + "span": diagnostic.span, + "diagnosticDetails": dict(diagnostic.details or {}), + }, + ) + ) + return facts + + @staticmethod + def _evidence(records, produced: ProducedExtraction) -> tuple[EvidenceSpan, ...]: + return tuple( + EvidenceSpan( + path=record.path, + start_line=record.start_line, + end_line=record.end_line, + granularity=record.granularity, + extractor=produced.producer_name, + extractor_version=produced.producer_version, + ) + for record in records + ) + + @staticmethod + def _merge_compatible_nodes(facts: list[Fact]) -> list[Fact]: + """Mirror evidence union while retaining exact duplicate emissions as FPs.""" + + merged: OrderedDict[tuple[object, ...], Fact] = OrderedDict() + seen_emissions: set[tuple[object, ...]] = set() + duplicate_nodes: list[Fact] = [] + others: list[Fact] = [] + for fact in facts: + if fact.fact_type != "node": + others.append(fact) + continue + identity = ( + fact.kind, + fact.subject, + fact.name, + fact.language, + fact.truth_class, + fact.value, + ) + emission = (*identity, tuple(span.key() for span in fact.evidence)) + if emission in seen_emissions: + duplicate_nodes.append(fact) + continue + seen_emissions.add(emission) + existing = merged.get(identity) + if existing is None: + merged[identity] = fact + continue + evidence = tuple( + sorted( + {*existing.evidence, *fact.evidence}, + key=lambda span: span.key(), + ) + ) + merged[identity] = Fact( + fact_type="node", + kind=fact.kind, + subject=fact.subject, + name=fact.name, + language=fact.language, + truth_class=fact.truth_class, + value=fact.value, + evidence=evidence, + details=fact.details, + ) + return [*merged.values(), *duplicate_nodes, *others] + + +def default_adapter() -> ExtractionAdapter: + return RealExtractionAdapter() diff --git a/apps/backend/tests/benchmark/config/benchmark_support_matrix.json b/apps/backend/tests/benchmark/config/benchmark_support_matrix.json new file mode 100644 index 00000000..3c04b26c --- /dev/null +++ b/apps/backend/tests/benchmark/config/benchmark_support_matrix.json @@ -0,0 +1,132 @@ +{ + "schemaVersion": "ri-benchmark-support-matrix.v2", + "note": "Benchmark ids are fixture taxonomy only. Each id maps to exactly one authoritative production capability id; support status, language, limitation, and required diagnostic come from app/extraction/support_matrix.py.", + "constructs": { + "py.module": {"description": "A directory-scoped Python module node."}, + "py.function.def": {"description": "A top-level function definition."}, + "py.async_function.def": {"description": "A top-level async function definition."}, + "py.class.def": {"description": "A class definition."}, + "py.method.def": {"description": "A method defined inside a class."}, + "py.nested_function": {"description": "A function nested inside another function."}, + "py.duplicate_symbol": {"description": "A redefined name resolved with an ordinal discriminator."}, + "py.import": {"description": "A plain import statement."}, + "py.import_alias": {"description": "An aliased import statement."}, + "py.from_import": {"description": "A from-import statement."}, + "py.decorator": {"description": "A decorator observation and symbol property."}, + "py.fastapi_route": {"description": "A FastAPI decorator route observation."}, + "py.dynamic_import": {"description": "Dynamic import."}, + "py.monkeypatch": {"description": "Attribute rebinding on an imported name."}, + "py.reflection": {"description": "Reflection calls."}, + "py.star_import": {"description": "Wildcard import."}, + "py.metaclass": {"description": "Metaclass declaration."}, + "py.syntax_error": {"description": "Malformed Python source."}, + "ts.module": {"description": "A TypeScript file node."}, + "ts.directory_module": {"description": "A directory-scoped TypeScript module node."}, + "ts.function": {"description": "A function declaration."}, + "ts.async_function": {"description": "An async function declaration."}, + "ts.class": {"description": "A class declaration."}, + "ts.method": {"description": "A class method."}, + "ts.interface": {"description": "An interface declaration."}, + "ts.type": {"description": "A type alias declaration."}, + "ts.enum": {"description": "An enum declaration."}, + "ts.const": {"description": "A top-level const binding."}, + "ts.import": {"description": "An import statement."}, + "ts.import_alias": {"description": "An aliased import statement."}, + "ts.export": {"description": "An exported symbol property."}, + "ts.reexport": {"description": "A re-export import observation."}, + "ts.route": {"description": "A react-router route observation."}, + "ts.dynamic_import": {"description": "Dynamic import expression."}, + "ts.decorator": {"description": "TypeScript decorator."}, + "ts.namespace": {"description": "Namespace declaration."}, + "ts.commonjs_require": {"description": "CommonJS require call."}, + "ts.ambient_module": {"description": "Ambient module declaration."}, + "ts.syntax_error": {"description": "Malformed TypeScript source."}, + "src.repository": {"description": "The repository root inventory node."}, + "src.file": {"description": "A repository inventory file node."}, + "src.empty_file": {"description": "A zero-byte text file with one logical line."}, + "src.trailing_newline": {"description": "A trailing newline contributes the final logical line."}, + "py.http_requests": {"description": "A requests attribute call with a literal absolute URL."}, + "py.http_httpx": {"description": "An httpx attribute call through an aliased module import."}, + "py.http_session": {"description": "A call on a client/session object constructed from a supported library."}, + "py.http_dynamic": {"description": "An HTTP call whose method or destination is not a syntax-visible absolute literal."}, + "ts.http_fetch": {"description": "A fetch call with a literal absolute URL."}, + "ts.http_axios": {"description": "An axios method call through a default import."}, + "ts.http_dynamic": {"description": "An HTTP call whose method or destination is not a syntax-visible absolute literal."}, + "src.npm_lockfile": {"description": "A resolved npm version in package-lock.json v2/v3."}, + "src.npm_lockfile_nested": {"description": "A second resolved version of one package in a nested npm tree."}, + "src.npm_lockfile_v1": {"description": "A package-lock.json using the unsupported lockfileVersion 1."}, + "src.poetry_lockfile": {"description": "A resolved PyPI version in poetry.lock."}, + "src.compose_service": {"description": "A declared Docker Compose service."}, + "src.compose_volume": {"description": "A declared Docker Compose volume."}, + "src.compose_network": {"description": "A declared Docker Compose network."}, + "src.compose_templated": {"description": "A Compose value interpolated from the environment."}, + "src.binary_file": {"description": "A NUL-containing source file."}, + "src.malformed_source": {"description": "Undecodable source bytes."}, + "src.large_file": {"description": "A source above the configured byte budget."}, + "src.path_escape": {"description": "A source path escaping the repository root."} + }, + "productionMappings": { + "py.module": "python.module", + "py.function.def": "python.function", + "py.async_function.def": "python.function", + "py.nested_function": "python.function", + "py.duplicate_symbol": "python.function", + "py.class.def": "python.class", + "py.method.def": "python.method", + "py.import": "python.import", + "py.import_alias": "python.import", + "py.from_import": "python.import", + "py.decorator": "python.decorator", + "py.fastapi_route": "python.route", + "py.dynamic_import": "python.dynamic-import", + "py.monkeypatch": "python.monkeypatch", + "py.reflection": "python.reflection", + "py.star_import": "python.star-import", + "py.metaclass": "python.metaclass", + "py.syntax_error": "source.malformed-source", + "ts.module": "typescript.file", + "ts.directory_module": "typescript.module", + "ts.function": "typescript.function", + "ts.async_function": "typescript.function", + "ts.class": "typescript.class", + "ts.method": "typescript.method", + "ts.interface": "typescript.interface", + "ts.type": "typescript.type", + "ts.enum": "typescript.enum", + "ts.const": "typescript.const", + "ts.import": "typescript.import", + "ts.import_alias": "typescript.import", + "ts.export": "typescript.export", + "ts.reexport": "typescript.export", + "ts.route": "typescript.route", + "ts.dynamic_import": "typescript.dynamic-import", + "ts.decorator": "typescript.decorator", + "ts.namespace": "typescript.namespace", + "ts.commonjs_require": "typescript.commonjs-require", + "ts.ambient_module": "typescript.ambient-module", + "ts.syntax_error": "source.malformed-source", + "src.repository": "source.repository", + "src.file": "source.file", + "src.empty_file": "source.empty-file", + "src.trailing_newline": "source.trailing-newline", + "py.http_requests": "python.http-client", + "py.http_httpx": "python.http-client", + "py.http_session": "python.http-client", + "py.http_dynamic": "python.http-dynamic-destination", + "ts.http_fetch": "typescript.http-client", + "ts.http_axios": "typescript.http-client", + "ts.http_dynamic": "typescript.http-dynamic-destination", + "src.npm_lockfile": "lockfile.npm-package-lock", + "src.npm_lockfile_nested": "lockfile.npm-package-lock", + "src.npm_lockfile_v1": "lockfile.npm-package-lock-v1", + "src.poetry_lockfile": "lockfile.poetry-lock", + "src.compose_service": "iac.docker-compose", + "src.compose_volume": "iac.docker-compose", + "src.compose_network": "iac.docker-compose", + "src.compose_templated": "iac.templated-value", + "src.binary_file": "source.binary-file", + "src.malformed_source": "source.malformed-source", + "src.large_file": "source.large-file", + "src.path_escape": "source.path-escape" + } +} diff --git a/apps/backend/tests/benchmark/config/thresholds.json b/apps/backend/tests/benchmark/config/thresholds.json new file mode 100644 index 00000000..a2c43bef --- /dev/null +++ b/apps/backend/tests/benchmark/config/thresholds.json @@ -0,0 +1,8 @@ +{ + "schemaVersion": "ri-benchmark-thresholds.v1", + "precision": "0.95", + "recall": "0.95", + "provenanceValidity": "1.00", + "determinism": "1.00", + "note": "Issue #94 acceptance thresholds. Values are exact fractions and all are enforced against the real extraction workflow. Do NOT lower any threshold without documenting the reason on Issue #94 and obtaining maintainer agreement." +} diff --git a/apps/backend/tests/benchmark/determinism.py b/apps/backend/tests/benchmark/determinism.py new file mode 100644 index 00000000..a0e0d9d0 --- /dev/null +++ b/apps/backend/tests/benchmark/determinism.py @@ -0,0 +1,307 @@ +"""Real-extraction determinism through SnapshotStore's canonical graph hash.""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from uuid import uuid4 + +from sqlalchemy import create_engine +from sqlalchemy.orm import Session, sessionmaker + +from app.core.database import register_sqlite_foreign_key_enforcement +from app.extraction import production_extractors +from app.extraction.dependencies import DEPENDENCY_SET_ARRAY_KEYS, merge_dependency_facts +from app.extraction.pipeline import ExtractionPipeline, ProducedExtraction +from app.intelligence import canonical +from app.intelligence.snapshot_store import Evidence, Revision, SnapshotStore +from app.models import RepositoryRecord, User +from app.models.base import Base + +from benchmark.loader import LoadedFixture + +SCHEMA_VERSION = canonical.SCHEMA_VERSION +_SET_ARRAY_KEYS = frozenset({"decorators", *DEPENDENCY_SET_ARRAY_KEYS}) + + +@dataclass(frozen=True) +class DeterminismResult: + fixture_id: str + sealed_hash_a: str + sealed_hash_b: str + pure_hash_a: str + pure_hash_b: str + + @property + def deterministic(self) -> bool: + return ( + self.sealed_hash_a == self.sealed_hash_b + and self.pure_hash_a == self.pure_hash_b + and self.sealed_hash_a == self.pure_hash_a + and self.sealed_hash_b == self.pure_hash_b + ) + + +def _extract(fixture: LoadedFixture) -> tuple[ProducedExtraction, ...]: + return merge_dependency_facts( + ExtractionPipeline( + production_extractors(), + max_source_bytes=fixture.max_source_bytes, + ).run(fixture.source_files()) + ) + + +def _evidence(record, produced: ProducedExtraction) -> Evidence: + return Evidence( + path=record.path, + start_line=record.start_line, + end_line=record.end_line, + extractor=produced.producer_name, + extractor_version=produced.producer_version, + logical_line_count=record.logical_line_count, + granularity=record.granularity, + ) + + +def _make_repository(session: Session, owner: User, revision_value: str) -> RepositoryRecord: + record = RepositoryRecord( + id=str(uuid4()), + owner_id=owner.id, + name=f"bench-{uuid4().hex[:6]}", + source="upload", + revision_kind="upload", + revision_value=revision_value, + revision_ref=None, + local_path="/stored/revision", + status="completed", + file_tree=[], + ) + session.add(record) + session.commit() + return record + + +def _seal_real_graph( + session: Session, + fixture: LoadedFixture, + runs: tuple[ProducedExtraction, ...], + *, + reverse: bool, +) -> str: + owner = User(id=str(uuid4()), email=f"{uuid4().hex[:8]}@example.com", password_hash=None) + session.add(owner) + session.commit() + repository = _make_repository(session, owner, fixture.revision_value()) + store = SnapshotStore(session) + snapshot = store.begin( + repository_id=repository.id, + revision=Revision("upload", fixture.revision_value()), + producer_version_set=list(fixture.producer_version_set), + config={"max_source_bytes": fixture.max_source_bytes}, + ) + + ordered_runs = list(reversed(runs)) if reverse else list(runs) + nodes = [(produced, node) for produced in ordered_runs for node in produced.result.nodes] + observations = [ + (produced, observation) for produced in ordered_runs for observation in produced.result.observations + ] + diagnostics = [(produced, diagnostic) for produced in ordered_runs for diagnostic in produced.result.diagnostics] + if reverse: + nodes.reverse() + observations.reverse() + diagnostics.reverse() + + for produced, node in nodes: + records = [_evidence(record, produced) for record in node.evidence] + if reverse: + records.reverse() + store.add_node( + snapshot, + node_kind=node.node_kind, + stable_key=node.stable_key, + name=node.name, + language=node.language, + properties=node.properties, + evidence=records, + set_array_keys=_node_set_array_keys(node.properties), + ) + + for produced, observation in observations: + store.add_observation( + snapshot, + observed_kind=observation.observed_kind, + subject_kind=observation.subject_kind, + subject_key=observation.subject_key, + referent_text=observation.referent_text, + ordinal=observation.ordinal, + evidence=_evidence(observation.evidence, produced), + ) + + for produced, diagnostic in diagnostics: + store.add_diagnostic( + snapshot, + code=diagnostic.code, + category=diagnostic.category, + severity=diagnostic.severity, + message=diagnostic.message, + producer=produced.producer, + path=diagnostic.path, + span=diagnostic.span, + subject=diagnostic.subject, + details=diagnostic.details, + ) + return store.seal(snapshot).canonical_graph_hash + + +def _node_set_array_keys(properties) -> frozenset[str]: + return frozenset(key for key in _SET_ARRAY_KEYS if properties and key in properties) + + +def _normalized_properties(properties): + if properties is None: + return None + return canonical.normalize_declared_arrays( + dict(properties), + set_array_keys=_node_set_array_keys(properties), + context="node properties", + ) + + +def _evidence_mapping(record, produced: ProducedExtraction) -> dict[str, object]: + return { + "path": record.path, + "start_line": record.start_line, + "end_line": record.end_line, + "granularity": record.granularity, + "extractor": produced.producer_name, + "extractor_version": produced.producer_version, + } + + +def _pure_real_hash( + fixture: LoadedFixture, + runs: tuple[ProducedExtraction, ...], + *, + reverse: bool, +) -> str: + nodes_by_key: dict[str, dict[str, object]] = {} + observations: list[dict[str, object]] = [] + diagnostics: list[dict[str, object]] = [] + ordered_runs = list(reversed(runs)) if reverse else list(runs) + for produced in ordered_runs: + for node in produced.result.nodes: + evidence = [_evidence_mapping(record, produced) for record in node.evidence] + record = { + "node_kind": node.node_kind, + "stable_key": node.stable_key, + "truth_class": "observed", + "name": node.name, + "language": node.language, + # Declared set arrays must be sorted here exactly as + # ``SnapshotStore.add_node`` sorts them, or this independent + # hash would disagree with the sealed one purely because the + # extractor emitted set members in source order. + "properties": _normalized_properties(node.properties), + "evidence": evidence, + } + existing = nodes_by_key.get(node.stable_key) + if existing is None: + nodes_by_key[node.stable_key] = record + else: + for key in ("node_kind", "truth_class", "name", "language", "properties"): + if existing[key] != record[key]: + raise AssertionError(f"conflicting real node output for {node.stable_key!r}") + existing["evidence"] = [*existing["evidence"], *evidence] + + for observation in produced.result.observations: + evidence = _evidence_mapping(observation.evidence, produced) + observation_id = canonical.compute_observation_id( + revision_kind="upload", + revision_value=fixture.revision_value(), + observed_kind=observation.observed_kind, + subject_kind=observation.subject_kind, + subject_key=observation.subject_key, + referent_text=observation.referent_text, + ordinal=observation.ordinal, + evidence=evidence, + schema_version=SCHEMA_VERSION, + ) + observations.append( + { + "observation_id": observation_id, + "observed_kind": observation.observed_kind, + "subject_kind": observation.subject_kind, + "subject_key": observation.subject_key, + "referent_text": observation.referent_text, + "ordinal": observation.ordinal, + "evidence": evidence, + } + ) + + for diagnostic in produced.result.diagnostics: + diagnostics.append( + { + "code": diagnostic.code, + "category": diagnostic.category, + "severity": diagnostic.severity, + "message": diagnostic.message, + "producer": produced.producer, + "path": diagnostic.path, + "span": ( + { + "start_line": diagnostic.span[0], + "end_line": diagnostic.span[1], + } + if diagnostic.span is not None + else None + ), + "subject": diagnostic.subject, + "object": None, + "details": dict(diagnostic.details) if diagnostic.details else None, + } + ) + + nodes = list(nodes_by_key.values()) + if reverse: + nodes.reverse() + observations.reverse() + diagnostics.reverse() + for node in nodes: + node["evidence"] = list(reversed(node["evidence"])) + return canonical.compute_canonical_graph_hash( + revision_kind="upload", + revision_value=fixture.revision_value(), + producer_version_set=list(fixture.producer_version_set), + config_hash=canonical.compute_config_hash({"max_source_bytes": fixture.max_source_bytes}), + nodes=nodes, + edges=[], + assertions=[], + observations=observations, + diagnostics=diagnostics, + schema_version=SCHEMA_VERSION, + ) + + +def check_fixture(fixture: LoadedFixture, db_path: Path) -> DeterminismResult: + """Extract twice, vary persistence order, and compare real canonical hashes.""" + + runs_a = _extract(fixture) + runs_b = _extract(fixture) + register_sqlite_foreign_key_enforcement() + engine = create_engine(f"sqlite:///{db_path}") + Base.metadata.create_all(engine) + factory = sessionmaker(bind=engine) + try: + with factory() as session: + sealed_a = _seal_real_graph(session, fixture, runs_a, reverse=False) + with factory() as session: + sealed_b = _seal_real_graph(session, fixture, runs_b, reverse=True) + finally: + engine.dispose() + return DeterminismResult( + fixture_id=fixture.fixture_id, + sealed_hash_a=sealed_a, + sealed_hash_b=sealed_b, + pure_hash_a=_pure_real_hash(fixture, runs_a, reverse=False), + pure_hash_b=_pure_real_hash(fixture, runs_b, reverse=True), + ) diff --git a/apps/backend/tests/benchmark/facts.py b/apps/backend/tests/benchmark/facts.py new file mode 100644 index 00000000..75bf4319 --- /dev/null +++ b/apps/backend/tests/benchmark/facts.py @@ -0,0 +1,121 @@ +"""The benchmark fact model and its comparison identity. + +A *fact* is the unit the benchmark scores. It uniformly represents the five +``ri.v1`` output kinds — nodes, edges, assertions, observations, and diagnostics +— so that ``expected`` (golden) facts and ``actual`` (extractor-emitted) facts +can be compared with one exact identity. + +The comparison identity (:meth:`Fact.key`) is deliberately *semantic and +complete*: it captures fact type, kind, subject/object/predicate, node name and +language, and — for facts that carry evidence — the full, normalized evidence +span set (path + one-based inclusive lines + granularity + extractor). A node +with the right stable key but the wrong name or language therefore does **not** +match; neither does a fact with the right structural identity but the wrong line +span. That is the whole point of a provenance-aware benchmark (RFC-0001 §6.2, +Issue #94). + +Identities never include volatile database ids, so the benchmark never compares +unstable primary keys (Issue #94 "Define the comparison identity explicitly"). +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Literal + +from app.intelligence import canonical + +FactType = Literal["node", "edge", "assertion", "observation", "diagnostic"] + +FACT_TYPES: tuple[FactType, ...] = ("node", "edge", "assertion", "observation", "diagnostic") + + +@dataclass(frozen=True) +class EvidenceSpan: + """One normalized citation: a repository-relative span (RFC-0001 §6.1).""" + + path: str + start_line: int + end_line: int + extractor: str + extractor_version: str + granularity: str = "span" + + def key(self) -> tuple[Any, ...]: + return ( + self.path, + self.start_line, + self.end_line, + self.granularity, + self.extractor, + self.extractor_version, + ) + + +def _evidence_set_key(evidence: tuple[EvidenceSpan, ...]) -> tuple[Any, ...]: + # Order-independent: evidence is a set at the semantic level (RFC §5.4/§12.3), + # so the identity is the sorted tuple of the individual span keys. + return tuple(sorted(span.key() for span in evidence)) + + +@dataclass(frozen=True) +class Fact: + """A single comparable claim. + + Only the fields relevant to a given ``fact_type`` are populated; the + comparison key uses exactly the fields that make the fact semantically + distinct, so an incomplete or mis-located fact can never be scored correct. + """ + + fact_type: FactType + # Structural identity ---------------------------------------------------- + kind: str = "" # node_kind, edge/observation predicate, or diagnostic code + subject: str = "" # stable key / subject, or diagnostic subject + object: str = "" # object stable key (edges), or diagnostic object + predicate: str = "" # edge/assertion/observation predicate + name: str = "" # node display name; empty for non-node facts + language: str = "" # node language; empty when absent / not applicable + referent: str = "" # observation referent_text + ordinal: int = 0 # observation identity ordinal; zero when not applicable + truth_class: str = "" # observed | resolved | inferred + value: str = "" # canonical JCS of an assertion value, when relevant + severity: str = "" # diagnostic severity + category: str = "" # diagnostic category + message: str = "" # deterministic diagnostic message + producer: str = "" # diagnostic producer (producer@version) + # Provenance ------------------------------------------------------------- + evidence: tuple[EvidenceSpan, ...] = () + # Bookkeeping (never part of identity) ----------------------------------- + details: dict[str, Any] = field(default_factory=dict, compare=False, hash=False) + + def key(self) -> tuple[Any, ...]: + """Return the exact, hashable comparison identity of this fact.""" + + return ( + self.fact_type, + self.kind, + self.subject, + self.object, + self.predicate, + self.name, + self.language, + self.referent, + self.ordinal, + self.truth_class, + self.value, + self.severity, + self.producer, + _evidence_set_key(self.evidence), + ) + + +def canonical_value(value: Any) -> str: + """Stable string form of an assertion value for the comparison key.""" + + return canonical.canonical_json_bytes(value).decode("utf-8") + + +def normalize_evidence_path(path: str) -> str: + """Normalize an evidence path with the real RFC-0001 §4.2 normalizer.""" + + return canonical.normalize_repo_path(path) diff --git a/apps/backend/tests/benchmark/fixtures/adversarial/adv-py-blindspots/manifest.json b/apps/backend/tests/benchmark/fixtures/adversarial/adv-py-blindspots/manifest.json new file mode 100644 index 00000000..485a9f6f --- /dev/null +++ b/apps/backend/tests/benchmark/fixtures/adversarial/adv-py-blindspots/manifest.json @@ -0,0 +1,35 @@ +{ + "schemaVersion": "ri-benchmark.v1", + "fixtureId": "adv-py-blindspots", + "fixtureClass": "adversarial", + "language": "python", + "title": "Adversarial Python — declared blind spots", + "description": "Star import, dynamic import, imported-name monkey-patching, and reflection produce exact real diagnostics.", + "sourceRoot": ".", + "revisionIdentity": "upload-sha256", + "producerVersionSet": ["python-ast@1.1.0", "repository-inventory@1.1.0"], + "constructsCovered": ["src.repository", "src.file", "py.module", "py.import", "py.star_import", "py.dynamic_import", "py.monkeypatch", "py.reflection"], + "deterministic": false, + "expected": { + "nodes": [ + {"nodeKind": "repository", "stableKey": "repo:root", "name": "repository", "constructs": ["src.repository"], + "evidence": [{"path": "src/dynamic.py", "startLine": 1, "endLine": 1, "extractor": "repository-inventory", "extractorVersion": "1.1.0"}]}, + {"nodeKind": "file", "stableKey": "file:src/dynamic.py", "properties": {"content_sha256": "sha256:3a573501dabf9de11fd3c263d985271a210509d8fff00f92a61d27f969c4a4e0"}, "name": "dynamic.py", "language": "python", "constructs": ["src.file"], + "evidence": [{"path": "src/dynamic.py", "startLine": 1, "endLine": 7, "granularity": "file", "extractor": "repository-inventory", "extractorVersion": "1.1.0"}]}, + {"nodeKind": "module", "stableKey": "mod:src", "name": "src", "constructs": ["py.module"], + "evidence": [{"path": "src/dynamic.py", "startLine": 1, "endLine": 7, "granularity": "file", "extractor": "python-ast", "extractorVersion": "1.1.0"}]} + ], + "edges": [], + "observations": [ + {"observedKind": "import", "subjectKind": "module", "subjectKey": "mod:src", "referentText": "importlib", "ordinal": 1, "constructs": ["py.import"], + "evidence": {"path": "src/dynamic.py", "startLine": 2, "endLine": 2, "extractor": "python-ast", "extractorVersion": "1.1.0"}} + ], + "assertions": [], + "diagnostics": [ + {"code": "RI-EXT-UNSUPPORTED", "category": "unsupported construct", "severity": "info", "message": "star-import is unsupported", "producer": "python-ast@1.1.0", "path": "src/dynamic.py", "span": {"startLine": 1, "endLine": 1}, "subject": "file:src/dynamic.py", "constructs": ["py.star_import"]}, + {"code": "RI-EXT-UNSUPPORTED", "category": "unsupported construct", "severity": "info", "message": "dynamic import via import_module() is unsupported", "producer": "python-ast@1.1.0", "path": "src/dynamic.py", "span": {"startLine": 4, "endLine": 4}, "subject": "file:src/dynamic.py", "constructs": ["py.dynamic_import"]}, + {"code": "RI-EXT-UNSUPPORTED", "category": "unsupported construct", "severity": "info", "message": "monkey-patching an imported name is unsupported", "producer": "python-ast@1.1.0", "path": "src/dynamic.py", "span": {"startLine": 5, "endLine": 5}, "subject": "file:src/dynamic.py", "constructs": ["py.monkeypatch"]}, + {"code": "RI-EXT-UNSUPPORTED", "category": "unsupported construct", "severity": "info", "message": "reflection via getattr() is unsupported", "producer": "python-ast@1.1.0", "path": "src/dynamic.py", "span": {"startLine": 6, "endLine": 6}, "subject": "file:src/dynamic.py", "constructs": ["py.reflection"]} + ] + } +} diff --git a/apps/backend/tests/benchmark/fixtures/adversarial/adv-py-blindspots/src/dynamic.py b/apps/backend/tests/benchmark/fixtures/adversarial/adv-py-blindspots/src/dynamic.py new file mode 100644 index 00000000..6cc0ba39 --- /dev/null +++ b/apps/backend/tests/benchmark/fixtures/adversarial/adv-py-blindspots/src/dynamic.py @@ -0,0 +1,6 @@ +from os import * +import importlib + +module = importlib.import_module("json") +importlib.custom = 1 +value = getattr(module, "custom") diff --git a/apps/backend/tests/benchmark/fixtures/adversarial/adv-py-malformed/manifest.json b/apps/backend/tests/benchmark/fixtures/adversarial/adv-py-malformed/manifest.json new file mode 100644 index 00000000..4568e684 --- /dev/null +++ b/apps/backend/tests/benchmark/fixtures/adversarial/adv-py-malformed/manifest.json @@ -0,0 +1,24 @@ +{ + "schemaVersion": "ri-benchmark.v1", + "fixtureId": "adv-py-malformed", + "fixtureClass": "adversarial", + "language": "python", + "title": "Adversarial Python — syntax error", + "description": "A file that fails to parse. It must emit RI-SRC-MALFORMED while the mandatory repository root remains evidenced by the stored text source (RFC-0001 §4.3, §6.2, §8). Exercises py.syntax_error.", + "sourceRoot": ".", + "revisionIdentity": "upload-sha256", + "producerVersionSet": ["python-ast@1.1.0", "repository-inventory@1.1.0"], + "constructsCovered": ["py.syntax_error", "src.malformed_source"], + "deterministic": false, + "expected": { + "nodes": [ + {"nodeKind": "repository", "stableKey": "repo:root", "name": "repository", + "evidence": [{"path": "src/broken.py", "startLine": 1, "endLine": 1, "extractor": "repository-inventory", "extractorVersion": "1.1.0"}]} + ], "edges": [], "observations": [], "assertions": [], + "diagnostics": [ + {"code": "RI-SRC-MALFORMED", "category": "malformed source", "severity": "error", + "message": "src/broken.py could not be parsed.", "producer": "python-ast@1.1.0", + "path": "src/broken.py", "subject": "file:src/broken.py", "constructs": ["py.syntax_error", "src.malformed_source"]} + ] + } +} diff --git a/apps/backend/tests/benchmark/fixtures/adversarial/adv-py-malformed/src/broken.py b/apps/backend/tests/benchmark/fixtures/adversarial/adv-py-malformed/src/broken.py new file mode 100644 index 00000000..25828cc2 --- /dev/null +++ b/apps/backend/tests/benchmark/fixtures/adversarial/adv-py-malformed/src/broken.py @@ -0,0 +1,2 @@ +def broken(: + return diff --git a/apps/backend/tests/benchmark/fixtures/adversarial/adv-py-metaclass/manifest.json b/apps/backend/tests/benchmark/fixtures/adversarial/adv-py-metaclass/manifest.json new file mode 100644 index 00000000..3d28a567 --- /dev/null +++ b/apps/backend/tests/benchmark/fixtures/adversarial/adv-py-metaclass/manifest.json @@ -0,0 +1,16 @@ +{ + "schemaVersion":"ri-benchmark.v1","fixtureId":"adv-py-metaclass","fixtureClass":"adversarial","language":"python", + "title":"Adversarial Python — metaclass","description":"A declared metaclass remains visible as an unsupported diagnostic while the class definition is still extracted.", + "sourceRoot":".","revisionIdentity":"upload-sha256","producerVersionSet":["python-ast@1.1.0","repository-inventory@1.1.0"], + "constructsCovered":["py.module","py.class.def","py.metaclass"],"deterministic":true, + "expected":{"nodes":[ + {"nodeKind":"repository","stableKey":"repo:root","name":"repository","evidence":[{"path":"src/meta.py","startLine":1,"endLine":1,"extractor":"repository-inventory","extractorVersion":"1.1.0"}]}, + {"nodeKind":"file","stableKey":"file:src/meta.py", "properties": {"content_sha256": "sha256:7de073d03a5beb5fddca00eceabdb180faa6d5e9e888eb3f668a4c21b402148a"},"name":"meta.py","language":"python","evidence":[{"path":"src/meta.py","startLine":1,"endLine":3,"granularity":"file","extractor":"repository-inventory","extractorVersion":"1.1.0"}]}, + {"nodeKind":"module","stableKey":"mod:src","name":"src","constructs":["py.module"],"evidence":[{"path":"src/meta.py","startLine":1,"endLine":3,"granularity":"file","extractor":"python-ast","extractorVersion":"1.1.0"}]}, + {"nodeKind":"symbol","stableKey":"src/meta.py::Model","name":"Model","language":"python","constructs":["py.class.def"],"evidence":[{"path":"src/meta.py","startLine":1,"endLine":2,"extractor":"python-ast","extractorVersion":"1.1.0"}]} + ],"edges":[],"observations":[ + {"observedKind":"definition","subjectKind":"symbol","subjectKey":"src/meta.py::Model","ordinal":1,"constructs":["py.class.def"],"evidence":{"path":"src/meta.py","startLine":1,"endLine":2,"extractor":"python-ast","extractorVersion":"1.1.0"}} + ],"assertions":[],"diagnostics":[ + {"code":"RI-EXT-UNSUPPORTED","category":"unsupported construct","severity":"info","message":"metaclass is unsupported","producer":"python-ast@1.1.0","path":"src/meta.py","span":{"startLine":1,"endLine":2},"subject":"file:src/meta.py","constructs":["py.metaclass"]} + ]} +} diff --git a/apps/backend/tests/benchmark/fixtures/adversarial/adv-py-metaclass/src/meta.py b/apps/backend/tests/benchmark/fixtures/adversarial/adv-py-metaclass/src/meta.py new file mode 100644 index 00000000..beb415b7 --- /dev/null +++ b/apps/backend/tests/benchmark/fixtures/adversarial/adv-py-metaclass/src/meta.py @@ -0,0 +1,2 @@ +class Model(metaclass=Registry): + pass diff --git a/apps/backend/tests/benchmark/fixtures/adversarial/adv-source-edgecases/blob.bin b/apps/backend/tests/benchmark/fixtures/adversarial/adv-source-edgecases/blob.bin new file mode 100644 index 00000000..4adfaf16 Binary files /dev/null and b/apps/backend/tests/benchmark/fixtures/adversarial/adv-source-edgecases/blob.bin differ diff --git a/apps/backend/tests/benchmark/fixtures/adversarial/adv-source-edgecases/manifest.json b/apps/backend/tests/benchmark/fixtures/adversarial/adv-source-edgecases/manifest.json new file mode 100644 index 00000000..add31790 --- /dev/null +++ b/apps/backend/tests/benchmark/fixtures/adversarial/adv-source-edgecases/manifest.json @@ -0,0 +1,33 @@ +{ + "schemaVersion": "ri-benchmark.v1", + "fixtureId": "adv-source-edgecases", + "fixtureClass": "adversarial", + "language": "mixed", + "title": "Adversarial source policy — binary, size limit, and path escape", + "description": "Stored bytes exercise the real repository source-policy layer with a fixture-specific 64-byte budget and a manifest-declared raw escape path that no host filesystem must materialize.", + "sourceRoot": ".", + "revisionIdentity": "upload-sha256", + "producerVersionSet": ["repository-inventory@1.1.0"], + "maxSourceBytes": 64, + "constructsCovered": ["src.binary_file", "src.large_file", "src.path_escape"], + "deterministic": false, + "syntheticFiles": {"..\\escape.py": "def must_not_extract():\n pass\n"}, + "expected": { + "nodes": [ + {"nodeKind": "repository", "stableKey": "repo:root", "name": "repository", + "evidence": [{"path": "src/large.py", "startLine": 1, "endLine": 1, "extractor": "repository-inventory", "extractorVersion": "1.1.0"}]} + ], "edges": [], "observations": [], "assertions": [], + "diagnostics": [ + {"code": "RI-SEC-PATH-ESCAPE", "category": "path escape", "severity": "error", + "message": "source path is absolute or escapes the repository root", "producer": "repository-inventory@1.1.0", + "constructs": ["src.path_escape"]}, + {"code": "RI-SRC-BINARY", "category": "binary source", "severity": "info", + "message": "file contains a NUL byte and is excluded from line-addressed extraction", "producer": "repository-inventory@1.1.0", + "path": "blob.bin", "subject": "file:blob.bin", "constructs": ["src.binary_file"]}, + {"code": "RI-LIMIT-SKIP", "category": "resource-limit skip", "severity": "info", + "message": "file exceeds the configured source-size budget", "producer": "repository-inventory@1.1.0", + "path": "src/large.py", "subject": "file:src/large.py", + "details": {"budgetBytes": 64, "reportedBytes": 96}, "constructs": ["src.large_file"]} + ] + } +} diff --git a/apps/backend/tests/benchmark/fixtures/adversarial/adv-source-edgecases/src/large.py b/apps/backend/tests/benchmark/fixtures/adversarial/adv-source-edgecases/src/large.py new file mode 100644 index 00000000..e9fc9836 --- /dev/null +++ b/apps/backend/tests/benchmark/fixtures/adversarial/adv-source-edgecases/src/large.py @@ -0,0 +1 @@ +value = "This stored source is intentionally larger than the fixture's configured byte budget." diff --git a/apps/backend/tests/benchmark/fixtures/adversarial/adv-src-lockfile-v1/manifest.json b/apps/backend/tests/benchmark/fixtures/adversarial/adv-src-lockfile-v1/manifest.json new file mode 100644 index 00000000..cd207613 --- /dev/null +++ b/apps/backend/tests/benchmark/fixtures/adversarial/adv-src-lockfile-v1/manifest.json @@ -0,0 +1,13 @@ +{ + "schemaVersion":"ri-benchmark.v1","fixtureId":"adv-src-lockfile-v1","fixtureClass":"adversarial","language":"mixed", + "title":"Adversarial lockfile — unsupported npm lockfileVersion 1","description":"A lockfileVersion 1 package-lock.json has real resolved versions in a legacy dependencies tree, and none of them are claimed: the unsupported revision is disclosed and no dependency fact is invented.", + "sourceRoot":".","revisionIdentity":"upload-sha256","producerVersionSet":["dependency-lockfile@1.0.0","repository-inventory@1.1.0"], + "constructsCovered":["src.repository","src.npm_lockfile_v1"],"deterministic":true, + "expected":{"nodes":[ + {"comment":"The repository entity is established from stored bytes before extractor dispatch, so a diagnostics-only revision is still sealable. There is deliberately no file node and no dependency node: the extractor emitted no facts at all for this revision.", + "nodeKind":"repository","stableKey":"repo:root","name":"repository","constructs":["src.repository"],"evidence":[{"path":"package-lock.json","startLine":1,"endLine":1,"extractor":"repository-inventory","extractorVersion":"1.1.0"}]} + ],"edges":[],"observations":[],"assertions":[],"diagnostics":[ + {"comment":"An unsupported format revision is a disclosure, not a parse failure: the file is well-formed JSON, so RI-SRC-MALFORMED would be a false claim about the source.", + "code":"RI-EXT-UNSUPPORTED","category":"unsupported construct","severity":"info","message":"package-lock.json lockfileVersion is outside the supported set [2, 3] and no resolutions are claimed","producer":"dependency-lockfile@1.0.0","path":"package-lock.json","subject":"file:package-lock.json","constructs":["src.npm_lockfile_v1"]} + ]} +} diff --git a/apps/backend/tests/benchmark/fixtures/adversarial/adv-src-lockfile-v1/package-lock.json b/apps/backend/tests/benchmark/fixtures/adversarial/adv-src-lockfile-v1/package-lock.json new file mode 100644 index 00000000..44fae7ec --- /dev/null +++ b/apps/backend/tests/benchmark/fixtures/adversarial/adv-src-lockfile-v1/package-lock.json @@ -0,0 +1,9 @@ +{ + "name": "legacy", + "lockfileVersion": 1, + "dependencies": { + "left-pad": { + "version": "1.3.0" + } + } +} diff --git a/apps/backend/tests/benchmark/fixtures/adversarial/adv-ts-blindspots/manifest.json b/apps/backend/tests/benchmark/fixtures/adversarial/adv-ts-blindspots/manifest.json new file mode 100644 index 00000000..b0426354 --- /dev/null +++ b/apps/backend/tests/benchmark/fixtures/adversarial/adv-ts-blindspots/manifest.json @@ -0,0 +1,34 @@ +{ + "schemaVersion": "ri-benchmark.v1", + "fixtureId": "adv-ts-blindspots", + "fixtureClass": "adversarial", + "language": "typescript", + "title": "Adversarial TypeScript — declared blind spots", + "description": "A namespace declaration and a dynamic import() expression. Both are outside the support matrix and MUST produce RI-EXT-UNSUPPORTED diagnostics.", + "sourceRoot": ".", + "revisionIdentity": "upload-sha256", + "producerVersionSet": ["repository-inventory@1.1.0", "typescript-ast@1.2.0"], + "constructsCovered": ["ts.module", "ts.namespace", "ts.dynamic_import"], + "deterministic": false, + "expected": { + "nodes": [ + {"nodeKind": "repository", "stableKey": "repo:root", "name": "repository", + "evidence": [{"path": "src/dynamic.ts", "startLine": 1, "endLine": 1, "granularity": "span", "extractor": "repository-inventory", "extractorVersion": "1.1.0"}]}, + {"nodeKind": "file", "stableKey": "file:src/dynamic.ts", "properties": {"content_sha256": "sha256:90636de41f5f7e2387422531ae1aac0214abda206431799efba5072961588add"}, "name": "dynamic.ts", "language": "typescript", "constructs": ["ts.module"], + "evidence": [{"path": "src/dynamic.ts", "startLine": 1, "endLine": 8, "granularity": "file", "extractor": "typescript-ast", "extractorVersion": "1.2.0"}]}, + {"nodeKind":"module","stableKey":"mod:src","name":"src","constructs":["ts.directory_module"],"evidence":[{"path":"src/dynamic.ts","startLine":1,"endLine":8,"granularity":"file","extractor":"typescript-ast","extractorVersion":"1.2.0"}]}, + {"nodeKind":"symbol","stableKey":"src/dynamic.ts::load","name":"load","language":"typescript","constructs":["ts.function"],"evidence":[{"path":"src/dynamic.ts","startLine":5,"endLine":7,"extractor":"typescript-ast","extractorVersion":"1.2.0"}]} + ], + "edges": [], "observations": [ + {"observedKind":"definition","subjectKind":"symbol","subjectKey":"src/dynamic.ts::load","ordinal":1,"constructs":["ts.function"],"evidence":{"path":"src/dynamic.ts","startLine":5,"endLine":7,"extractor":"typescript-ast","extractorVersion":"1.2.0"}} + ], "assertions": [], + "diagnostics": [ + {"code": "RI-EXT-UNSUPPORTED", "category": "unsupported construct", "severity": "info", + "message": "TypeScript namespace is outside the support matrix.", "producer": "typescript-ast@1.2.0", + "path": "src/dynamic.ts", "span": {"startLine": 1, "endLine": 3}, "subject": "file:src/dynamic.ts", "constructs": ["ts.namespace"]}, + {"code": "RI-EXT-UNSUPPORTED", "category": "unsupported construct", "severity": "info", + "message": "Dynamic import() is outside the TypeScript support matrix.", "producer": "typescript-ast@1.2.0", + "path": "src/dynamic.ts", "span": {"startLine": 6, "endLine": 6}, "subject": "file:src/dynamic.ts", "constructs": ["ts.dynamic_import"]} + ] + } +} diff --git a/apps/backend/tests/benchmark/fixtures/adversarial/adv-ts-blindspots/src/dynamic.ts b/apps/backend/tests/benchmark/fixtures/adversarial/adv-ts-blindspots/src/dynamic.ts new file mode 100644 index 00000000..d8f88dcd --- /dev/null +++ b/apps/backend/tests/benchmark/fixtures/adversarial/adv-ts-blindspots/src/dynamic.ts @@ -0,0 +1,7 @@ +export namespace Legacy { + export const flag = true; +} + +async function load(name: string) { + return import(name); +} diff --git a/apps/backend/tests/benchmark/fixtures/adversarial/adv-ts-malformed/manifest.json b/apps/backend/tests/benchmark/fixtures/adversarial/adv-ts-malformed/manifest.json new file mode 100644 index 00000000..af70876f --- /dev/null +++ b/apps/backend/tests/benchmark/fixtures/adversarial/adv-ts-malformed/manifest.json @@ -0,0 +1,24 @@ +{ + "schemaVersion": "ri-benchmark.v1", + "fixtureId": "adv-ts-malformed", + "fixtureClass": "adversarial", + "language": "typescript", + "title": "Adversarial TypeScript — syntax error", + "description": "A file that fails to parse. It must emit RI-SRC-MALFORMED while the mandatory repository root remains evidenced by the stored text source (RFC-0001 §4.3, §6.2, §8). Exercises ts.syntax_error.", + "sourceRoot": ".", + "revisionIdentity": "upload-sha256", + "producerVersionSet": ["typescript-ast@1.2.0", "repository-inventory@1.1.0"], + "constructsCovered": ["ts.syntax_error"], + "deterministic": false, + "expected": { + "nodes": [ + {"nodeKind": "repository", "stableKey": "repo:root", "name": "repository", + "evidence": [{"path": "src/broken.ts", "startLine": 1, "endLine": 1, "extractor": "repository-inventory", "extractorVersion": "1.1.0"}]} + ], "edges": [], "observations": [], "assertions": [], + "diagnostics": [ + {"code": "RI-SRC-MALFORMED", "category": "malformed source", "severity": "error", + "message": "src/broken.ts could not be parsed.", "producer": "typescript-ast@1.2.0", + "path": "src/broken.ts", "subject": "file:src/broken.ts", "constructs": ["ts.syntax_error"]} + ] + } +} diff --git a/apps/backend/tests/benchmark/fixtures/adversarial/adv-ts-malformed/src/broken.ts b/apps/backend/tests/benchmark/fixtures/adversarial/adv-ts-malformed/src/broken.ts new file mode 100644 index 00000000..a09e4365 --- /dev/null +++ b/apps/backend/tests/benchmark/fixtures/adversarial/adv-ts-malformed/src/broken.ts @@ -0,0 +1,3 @@ +export function broken( { + return; +} diff --git a/apps/backend/tests/benchmark/fixtures/adversarial/adv-ts-more-blindspots/manifest.json b/apps/backend/tests/benchmark/fixtures/adversarial/adv-ts-more-blindspots/manifest.json new file mode 100644 index 00000000..41cb3fef --- /dev/null +++ b/apps/backend/tests/benchmark/fixtures/adversarial/adv-ts-more-blindspots/manifest.json @@ -0,0 +1,20 @@ +{ + "schemaVersion":"ri-benchmark.v1","fixtureId":"adv-ts-more-blindspots","fixtureClass":"adversarial","language":"typescript", + "title":"Adversarial TypeScript — ambient module, decorator, and CommonJS","description":"Each remaining declared TypeScript blind spot produces its exact real diagnostic.", + "sourceRoot":".","revisionIdentity":"upload-sha256","producerVersionSet":["repository-inventory@1.1.0","typescript-ast@1.2.0"], + "constructsCovered":["ts.module","ts.directory_module","ts.class","ts.const","ts.ambient_module","ts.decorator","ts.commonjs_require"],"deterministic":true, + "expected":{"nodes":[ + {"nodeKind":"repository","stableKey":"repo:root","name":"repository","evidence":[{"path":"src/legacy.ts","startLine":1,"endLine":1,"extractor":"repository-inventory","extractorVersion":"1.1.0"}]}, + {"nodeKind":"file","stableKey":"file:src/legacy.ts", "properties": {"content_sha256": "sha256:b74c2ae8a704f46d7622e55abf98bf829436dca9f038ec5daf84ba8909c760f9"},"name":"legacy.ts","language":"typescript","constructs":["ts.module"],"evidence":[{"path":"src/legacy.ts","startLine":1,"endLine":7,"granularity":"file","extractor":"typescript-ast","extractorVersion":"1.2.0"}]}, + {"nodeKind":"module","stableKey":"mod:src","name":"src","constructs":["ts.directory_module"],"evidence":[{"path":"src/legacy.ts","startLine":1,"endLine":7,"granularity":"file","extractor":"typescript-ast","extractorVersion":"1.2.0"}]}, + {"nodeKind":"symbol","stableKey":"src/legacy.ts::Service","name":"Service","language":"typescript","constructs":["ts.class"],"evidence":[{"path":"src/legacy.ts","startLine":3,"endLine":4,"extractor":"typescript-ast","extractorVersion":"1.2.0"}]}, + {"nodeKind":"symbol","stableKey":"src/legacy.ts::fs","name":"fs","language":"typescript","constructs":["ts.const"],"evidence":[{"path":"src/legacy.ts","startLine":6,"endLine":6,"extractor":"typescript-ast","extractorVersion":"1.2.0"}]} + ],"edges":[],"observations":[ + {"observedKind":"definition","subjectKind":"symbol","subjectKey":"src/legacy.ts::Service","ordinal":1,"constructs":["ts.class"],"evidence":{"path":"src/legacy.ts","startLine":3,"endLine":4,"extractor":"typescript-ast","extractorVersion":"1.2.0"}}, + {"observedKind":"definition","subjectKind":"symbol","subjectKey":"src/legacy.ts::fs","ordinal":1,"constructs":["ts.const"],"evidence":{"path":"src/legacy.ts","startLine":6,"endLine":6,"extractor":"typescript-ast","extractorVersion":"1.2.0"}} + ],"assertions":[],"diagnostics":[ + {"code":"RI-EXT-UNSUPPORTED","category":"unsupported construct","severity":"info","message":"namespace/module declaration is unsupported","producer":"typescript-ast@1.2.0","path":"src/legacy.ts","span":{"startLine":1,"endLine":1},"subject":"file:src/legacy.ts","constructs":["ts.ambient_module"]}, + {"code":"RI-EXT-UNSUPPORTED","category":"unsupported construct","severity":"info","message":"TypeScript decorator is unsupported","producer":"typescript-ast@1.2.0","path":"src/legacy.ts","span":{"startLine":3,"endLine":3},"subject":"file:src/legacy.ts","constructs":["ts.decorator"]}, + {"code":"RI-EXT-UNSUPPORTED","category":"unsupported construct","severity":"info","message":"CommonJS require() is unsupported","producer":"typescript-ast@1.2.0","path":"src/legacy.ts","span":{"startLine":6,"endLine":6},"subject":"file:src/legacy.ts","constructs":["ts.commonjs_require"]} + ]} +} diff --git a/apps/backend/tests/benchmark/fixtures/adversarial/adv-ts-more-blindspots/src/legacy.ts b/apps/backend/tests/benchmark/fixtures/adversarial/adv-ts-more-blindspots/src/legacy.ts new file mode 100644 index 00000000..42865810 --- /dev/null +++ b/apps/backend/tests/benchmark/fixtures/adversarial/adv-ts-more-blindspots/src/legacy.ts @@ -0,0 +1,6 @@ +declare module "legacy" { export const enabled: boolean; } + +@sealed +class Service {} + +const fs = require("fs"); diff --git a/apps/backend/tests/benchmark/fixtures/minimal/min-empty-file/empty.txt b/apps/backend/tests/benchmark/fixtures/minimal/min-empty-file/empty.txt new file mode 100644 index 00000000..e69de29b diff --git a/apps/backend/tests/benchmark/fixtures/minimal/min-empty-file/manifest.json b/apps/backend/tests/benchmark/fixtures/minimal/min-empty-file/manifest.json new file mode 100644 index 00000000..f9153e31 --- /dev/null +++ b/apps/backend/tests/benchmark/fixtures/minimal/min-empty-file/manifest.json @@ -0,0 +1,22 @@ +{ + "schemaVersion": "ri-benchmark.v1", + "fixtureId": "min-empty-file", + "fixtureClass": "minimal", + "language": "mixed", + "title": "Minimal — empty file (one logical empty line)", + "description": "A zero-byte text file: logical_line_count == 1 and whole-file evidence is exactly 1..1 (RFC-0001 §6.2). Exercises src.empty_file.", + "sourceRoot": ".", + "revisionIdentity": "upload-sha256", + "producerVersionSet": ["repository-inventory@1.1.0"], + "constructsCovered": ["src.empty_file"], + "deterministic": true, + "expected": { + "nodes": [ + {"nodeKind": "repository", "stableKey": "repo:root", "name": "repository", + "evidence": [{"path": "empty.txt", "startLine": 1, "endLine": 1, "granularity": "span", "extractor": "repository-inventory", "extractorVersion": "1.1.0"}]}, + {"nodeKind": "file", "stableKey": "file:empty.txt", "properties": {"content_sha256": "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"}, "name": "empty.txt", "constructs": ["src.empty_file"], + "evidence": [{"path": "empty.txt", "startLine": 1, "endLine": 1, "granularity": "file", "extractor": "repository-inventory", "extractorVersion": "1.1.0"}]} + ], + "edges": [], "observations": [], "assertions": [], "diagnostics": [] + } +} diff --git a/apps/backend/tests/benchmark/fixtures/minimal/min-py-async/manifest.json b/apps/backend/tests/benchmark/fixtures/minimal/min-py-async/manifest.json new file mode 100644 index 00000000..492ecbe1 --- /dev/null +++ b/apps/backend/tests/benchmark/fixtures/minimal/min-py-async/manifest.json @@ -0,0 +1,29 @@ +{ + "schemaVersion": "ri-benchmark.v1", + "fixtureId": "min-py-async", + "fixtureClass": "minimal", + "language": "python", + "title": "Minimal Python — async function definition", + "description": "A single top-level async function. Exercises py.async_function.def.", + "sourceRoot": ".", + "revisionIdentity": "upload-sha256", + "producerVersionSet": ["python-ast@1.1.0", "repository-inventory@1.1.0"], + "constructsCovered": ["py.async_function.def", "py.module"], + "deterministic": true, + "expected": { + "nodes": [ + {"nodeKind": "repository", "stableKey": "repo:root", "name": "repository", + "evidence": [{"path": "src/tasks.py", "startLine": 1, "endLine": 1, "granularity": "span", "extractor": "repository-inventory", "extractorVersion": "1.1.0"}]}, + {"nodeKind": "file", "stableKey": "file:src/tasks.py", "properties": {"content_sha256": "sha256:e0004001b959490cb3fb45740873648de84b9a9e786ce8a0c69ff303f921b1a2"}, "name": "tasks.py", "language": "python", "constructs": ["src.file"], + "evidence": [{"path": "src/tasks.py", "startLine": 1, "endLine": 3, "granularity": "file", "extractor": "repository-inventory", "extractorVersion": "1.1.0"}]}, + {"nodeKind": "module", "stableKey": "mod:src", "name": "src", "constructs": ["py.module"], + "evidence": [{"path": "src/tasks.py", "startLine": 1, "endLine": 3, "granularity": "file", "extractor": "python-ast", "extractorVersion": "1.1.0"}]}, + {"nodeKind": "symbol", "stableKey": "src/tasks.py::fetch", "name": "fetch", "language": "python", "constructs": ["py.async_function.def"], + "evidence": [{"path": "src/tasks.py", "startLine": 1, "endLine": 2, "granularity": "span", "extractor": "python-ast", "extractorVersion": "1.1.0"}]} + ], + "edges": [], "observations": [ + {"observedKind": "definition", "subjectKind": "symbol", "subjectKey": "src/tasks.py::fetch", "ordinal": 1, "constructs": ["py.async_function.def"], + "evidence": {"path": "src/tasks.py", "startLine": 1, "endLine": 2, "extractor": "python-ast", "extractorVersion": "1.1.0"}} + ], "assertions": [], "diagnostics": [] + } +} diff --git a/apps/backend/tests/benchmark/fixtures/minimal/min-py-async/src/tasks.py b/apps/backend/tests/benchmark/fixtures/minimal/min-py-async/src/tasks.py new file mode 100644 index 00000000..2bb06ed8 --- /dev/null +++ b/apps/backend/tests/benchmark/fixtures/minimal/min-py-async/src/tasks.py @@ -0,0 +1,2 @@ +async def fetch(value): + return value diff --git a/apps/backend/tests/benchmark/fixtures/minimal/min-py-class/manifest.json b/apps/backend/tests/benchmark/fixtures/minimal/min-py-class/manifest.json new file mode 100644 index 00000000..c5fb6f75 --- /dev/null +++ b/apps/backend/tests/benchmark/fixtures/minimal/min-py-class/manifest.json @@ -0,0 +1,34 @@ +{ + "schemaVersion": "ri-benchmark.v1", + "fixtureId": "min-py-class", + "fixtureClass": "minimal", + "language": "python", + "title": "Minimal Python — class with methods", + "description": "A class with two methods. Exercises py.class.def and py.method.def.", + "sourceRoot": ".", + "revisionIdentity": "upload-sha256", + "producerVersionSet": ["python-ast@1.1.0", "repository-inventory@1.1.0"], + "constructsCovered": ["py.class.def", "py.method.def", "py.module"], + "deterministic": true, + "expected": { + "nodes": [ + {"nodeKind": "repository", "stableKey": "repo:root", "name": "repository", + "evidence": [{"path": "src/models.py", "startLine": 1, "endLine": 1, "granularity": "span", "extractor": "repository-inventory", "extractorVersion": "1.1.0"}]}, + {"nodeKind": "file", "stableKey": "file:src/models.py", "properties": {"content_sha256": "sha256:2958e70efe09e236598988f6d7989a0501385de82be9659b487b262a96b2258a"}, "name": "models.py", "language": "python", "constructs": ["src.file"], + "evidence": [{"path": "src/models.py", "startLine": 1, "endLine": 7, "granularity": "file", "extractor": "repository-inventory", "extractorVersion": "1.1.0"}]}, + {"nodeKind": "module", "stableKey": "mod:src", "name": "src", "constructs": ["py.module"], + "evidence": [{"path": "src/models.py", "startLine": 1, "endLine": 7, "granularity": "file", "extractor": "python-ast", "extractorVersion": "1.1.0"}]}, + {"nodeKind": "symbol", "stableKey": "src/models.py::Account", "name": "Account", "language": "python", "constructs": ["py.class.def"], + "evidence": [{"path": "src/models.py", "startLine": 1, "endLine": 6, "granularity": "span", "extractor": "python-ast", "extractorVersion": "1.1.0"}]}, + {"nodeKind": "symbol", "stableKey": "src/models.py::Account.deposit", "name": "deposit", "language": "python", "constructs": ["py.method.def"], + "evidence": [{"path": "src/models.py", "startLine": 2, "endLine": 3, "granularity": "span", "extractor": "python-ast", "extractorVersion": "1.1.0"}]}, + {"nodeKind": "symbol", "stableKey": "src/models.py::Account.balance", "name": "balance", "language": "python", "constructs": ["py.method.def"], + "evidence": [{"path": "src/models.py", "startLine": 5, "endLine": 6, "granularity": "span", "extractor": "python-ast", "extractorVersion": "1.1.0"}]} + ], + "edges": [], "observations": [ + {"observedKind":"definition","subjectKind":"symbol","subjectKey":"src/models.py::Account","ordinal":1,"constructs":["py.class.def"],"evidence":{"path":"src/models.py","startLine":1,"endLine":6,"extractor":"python-ast","extractorVersion":"1.1.0"}}, + {"observedKind":"definition","subjectKind":"symbol","subjectKey":"src/models.py::Account.deposit","ordinal":1,"constructs":["py.method.def"],"evidence":{"path":"src/models.py","startLine":2,"endLine":3,"extractor":"python-ast","extractorVersion":"1.1.0"}}, + {"observedKind":"definition","subjectKind":"symbol","subjectKey":"src/models.py::Account.balance","ordinal":1,"constructs":["py.method.def"],"evidence":{"path":"src/models.py","startLine":5,"endLine":6,"extractor":"python-ast","extractorVersion":"1.1.0"}} + ], "assertions": [], "diagnostics": [] + } +} diff --git a/apps/backend/tests/benchmark/fixtures/minimal/min-py-class/src/models.py b/apps/backend/tests/benchmark/fixtures/minimal/min-py-class/src/models.py new file mode 100644 index 00000000..f1661723 --- /dev/null +++ b/apps/backend/tests/benchmark/fixtures/minimal/min-py-class/src/models.py @@ -0,0 +1,6 @@ +class Account: + def deposit(self, amount): + return amount + + def balance(self): + return 0 diff --git a/apps/backend/tests/benchmark/fixtures/minimal/min-py-decorator-route/manifest.json b/apps/backend/tests/benchmark/fixtures/minimal/min-py-decorator-route/manifest.json new file mode 100644 index 00000000..7416577a --- /dev/null +++ b/apps/backend/tests/benchmark/fixtures/minimal/min-py-decorator-route/manifest.json @@ -0,0 +1,42 @@ +{ + "schemaVersion": "ri-benchmark.v1", + "fixtureId": "min-py-decorator-route", + "fixtureClass": "minimal", + "language": "python", + "title": "Minimal Python — decorator and FastAPI route", + "description": "A decorated handler registered as a FastAPI route. Exercises py.decorator and py.fastapi_route.", + "sourceRoot": ".", + "revisionIdentity": "upload-sha256", + "producerVersionSet": ["python-ast@1.1.0", "repository-inventory@1.1.0"], + "constructsCovered": ["py.decorator", "py.fastapi_route", "py.function.def", "py.module"], + "deterministic": false, + "expected": { + "nodes": [ + {"nodeKind": "repository", "stableKey": "repo:root", "name": "repository", + "evidence": [{"path": "src/api.py", "startLine": 1, "endLine": 1, "granularity": "span", "extractor": "repository-inventory", "extractorVersion": "1.1.0"}]}, + {"nodeKind": "file", "stableKey": "file:src/api.py", "properties": {"content_sha256": "sha256:46a792fb22fe222d3d2ad106b3e84fa5ab9998b97b6bbc7010a6b7831da7c4b4"}, "name": "api.py", "language": "python", "constructs": ["src.file"], + "evidence": [{"path": "src/api.py", "startLine": 1, "endLine": 14, "granularity": "file", "extractor": "repository-inventory", "extractorVersion": "1.1.0"}]}, + {"nodeKind": "module", "stableKey": "mod:src", "name": "src", "constructs": ["py.module"], + "evidence": [{"path": "src/api.py", "startLine": 1, "endLine": 14, "granularity": "file", "extractor": "python-ast", "extractorVersion": "1.1.0"}]}, + {"nodeKind": "symbol", "stableKey": "src/api.py::audit", "name": "audit", "language": "python", "constructs": ["py.function.def"], + "evidence": [{"path": "src/api.py", "startLine": 6, "endLine": 7, "granularity": "span", "extractor": "python-ast", "extractorVersion": "1.1.0"}]}, + {"nodeKind": "symbol", "stableKey": "src/api.py::health", "name": "health", "language": "python", "properties": {"decorators": ["audit", "router.get"]}, "constructs": ["py.function.def", "py.decorator"], + "evidence": [{"path": "src/api.py", "startLine": 12, "endLine": 13, "granularity": "span", "extractor": "python-ast", "extractorVersion": "1.1.0"}]}, + {"nodeKind": "symbol", "stableKey": "src/api.py::(anonymous:route#1)", "name": "route", "language": "python", "properties": {"route_path": "/health"}, "constructs": ["py.fastapi_route"], + "evidence": [{"path": "src/api.py", "startLine": 11, "endLine": 11, "extractor": "python-ast", "extractorVersion": "1.1.0"}]} + ], + "edges": [], + "observations": [ + {"observedKind":"import","subjectKind":"module","subjectKey":"mod:src","referentText":"fastapi.APIRouter","ordinal":1,"constructs":["py.import"],"evidence":{"path":"src/api.py","startLine":1,"endLine":1,"extractor":"python-ast","extractorVersion":"1.1.0"}}, + {"observedKind":"import_binding","subjectKind":"module","subjectKey":"mod:src","referentText":"fastapi|APIRouter|APIRouter","ordinal":1,"constructs":["py.import"],"evidence":{"path":"src/api.py","startLine":1,"endLine":1,"extractor":"python-ast","extractorVersion":"1.1.0"}}, + {"observedKind":"call","subjectKind":"module","subjectKey":"mod:src","referentText":"APIRouter","ordinal":1,"constructs":["py.fastapi_route"],"evidence":{"path":"src/api.py","startLine":3,"endLine":3,"extractor":"python-ast","extractorVersion":"1.1.0"}}, + {"observedKind":"definition","subjectKind":"symbol","subjectKey":"src/api.py::audit","ordinal":1,"constructs":["py.function.def"],"evidence":{"path":"src/api.py","startLine":6,"endLine":7,"extractor":"python-ast","extractorVersion":"1.1.0"}}, + {"observedKind":"definition","subjectKind":"symbol","subjectKey":"src/api.py::health","ordinal":1,"constructs":["py.function.def"],"evidence":{"path":"src/api.py","startLine":12,"endLine":13,"extractor":"python-ast","extractorVersion":"1.1.0"}}, + {"observedKind":"decorator","subjectKind":"symbol","subjectKey":"src/api.py::health","referentText":"audit","ordinal":1,"constructs":["py.decorator"],"evidence":{"path":"src/api.py","startLine":10,"endLine":10,"extractor":"python-ast","extractorVersion":"1.1.0"}}, + {"observedKind":"decorator","subjectKind":"symbol","subjectKey":"src/api.py::health","referentText":"router.get","ordinal":1,"constructs":["py.decorator"],"evidence":{"path":"src/api.py","startLine":11,"endLine":11,"extractor":"python-ast","extractorVersion":"1.1.0"}}, + {"observedKind":"route","subjectKind":"symbol","subjectKey":"src/api.py::(anonymous:route#1)","referentText":"/health","ordinal":1,"constructs":["py.fastapi_route"],"evidence":{"path":"src/api.py","startLine":11,"endLine":11,"extractor":"python-ast","extractorVersion":"1.1.0"}}, + {"observedKind":"route_handler","subjectKind":"symbol","subjectKey":"src/api.py::(anonymous:route#1)","referentText":"src/api.py::health","ordinal":1,"constructs":["py.fastapi_route"],"evidence":{"path":"src/api.py","startLine":11,"endLine":11,"extractor":"python-ast","extractorVersion":"1.1.0"}} + ], + "assertions": [], "diagnostics": [] + } +} diff --git a/apps/backend/tests/benchmark/fixtures/minimal/min-py-decorator-route/src/api.py b/apps/backend/tests/benchmark/fixtures/minimal/min-py-decorator-route/src/api.py new file mode 100644 index 00000000..6c4cbbb2 --- /dev/null +++ b/apps/backend/tests/benchmark/fixtures/minimal/min-py-decorator-route/src/api.py @@ -0,0 +1,13 @@ +from fastapi import APIRouter + +router = APIRouter() + + +def audit(handler): + return handler + + +@audit +@router.get("/health") +def health(): + return {"status": "ok"} diff --git a/apps/backend/tests/benchmark/fixtures/minimal/min-py-function/README.md b/apps/backend/tests/benchmark/fixtures/minimal/min-py-function/README.md new file mode 100644 index 00000000..4eeea8ac --- /dev/null +++ b/apps/backend/tests/benchmark/fixtures/minimal/min-py-function/README.md @@ -0,0 +1,3 @@ +# min-py-function + +Two top-level function definitions, one construct family. diff --git a/apps/backend/tests/benchmark/fixtures/minimal/min-py-function/manifest.json b/apps/backend/tests/benchmark/fixtures/minimal/min-py-function/manifest.json new file mode 100644 index 00000000..142e3904 --- /dev/null +++ b/apps/backend/tests/benchmark/fixtures/minimal/min-py-function/manifest.json @@ -0,0 +1,79 @@ +{ + "schemaVersion": "ri-benchmark.v1", + "fixtureId": "min-py-function", + "fixtureClass": "minimal", + "language": "python", + "title": "Minimal Python — top-level function definitions", + "description": "Two top-level function definitions and their module/repository nodes. Exercises py.function.def and py.module with hand-verified line spans.", + "sourceRoot": ".", + "revisionIdentity": "upload-sha256", + "producerVersionSet": ["python-ast@1.1.0", "repository-inventory@1.1.0"], + "constructsCovered": ["py.function.def", "py.module"], + "deterministic": true, + "expected": { + "nodes": [ + { + "nodeKind": "repository", + "stableKey": "repo:root", + "name": "repository", + "evidence": [ + {"path": "README.md", "startLine": 1, "endLine": 1, "granularity": "span", "extractor": "repository-inventory", "extractorVersion": "1.1.0"} + ] + }, + { + "nodeKind": "file", + "stableKey": "file:README.md", "properties": {"content_sha256": "sha256:93a4f5891bc749cab5e3a971fd92dafa8474b44195dde264a989f1395f6a7086"}, + "name": "README.md", + "evidence": [ + {"path": "README.md", "startLine": 1, "endLine": 4, "granularity": "file", "extractor": "repository-inventory", "extractorVersion": "1.1.0"} + ] + }, + { + "nodeKind": "file", + "stableKey": "file:src/greeting.py", "properties": {"content_sha256": "sha256:cd06a4ecc6601302298533289ed47c77469c0368da7a1fe7ccc0a9a5142f390d"}, + "name": "greeting.py", + "language": "python", + "constructs": ["src.file"], + "evidence": [ + {"path": "src/greeting.py", "startLine": 1, "endLine": 7, "granularity": "file", "extractor": "repository-inventory", "extractorVersion": "1.1.0"} + ] + }, + { + "nodeKind": "module", + "stableKey": "mod:src", + "name": "src", + "constructs": ["py.module"], + "evidence": [ + {"path": "src/greeting.py", "startLine": 1, "endLine": 7, "granularity": "file", "extractor": "python-ast", "extractorVersion": "1.1.0"} + ] + }, + { + "nodeKind": "symbol", + "stableKey": "src/greeting.py::greet", + "name": "greet", + "language": "python", + "constructs": ["py.function.def"], + "evidence": [ + {"path": "src/greeting.py", "startLine": 1, "endLine": 2, "granularity": "span", "extractor": "python-ast", "extractorVersion": "1.1.0"} + ] + }, + { + "nodeKind": "symbol", + "stableKey": "src/greeting.py::farewell", + "name": "farewell", + "language": "python", + "constructs": ["py.function.def"], + "evidence": [ + {"path": "src/greeting.py", "startLine": 5, "endLine": 6, "granularity": "span", "extractor": "python-ast", "extractorVersion": "1.1.0"} + ] + } + ], + "edges": [], + "observations": [ + {"observedKind":"definition","subjectKind":"symbol","subjectKey":"src/greeting.py::greet","ordinal":1,"constructs":["py.function.def"],"evidence":{"path":"src/greeting.py","startLine":1,"endLine":2,"extractor":"python-ast","extractorVersion":"1.1.0"}}, + {"observedKind":"definition","subjectKind":"symbol","subjectKey":"src/greeting.py::farewell","ordinal":1,"constructs":["py.function.def"],"evidence":{"path":"src/greeting.py","startLine":5,"endLine":6,"extractor":"python-ast","extractorVersion":"1.1.0"}} + ], + "assertions": [], + "diagnostics": [] + } +} diff --git a/apps/backend/tests/benchmark/fixtures/minimal/min-py-function/src/greeting.py b/apps/backend/tests/benchmark/fixtures/minimal/min-py-function/src/greeting.py new file mode 100644 index 00000000..6b7032e2 --- /dev/null +++ b/apps/backend/tests/benchmark/fixtures/minimal/min-py-function/src/greeting.py @@ -0,0 +1,6 @@ +def greet(name): + return f"Hello, {name}" + + +def farewell(name): + return f"Goodbye, {name}" diff --git a/apps/backend/tests/benchmark/fixtures/minimal/min-py-imports/manifest.json b/apps/backend/tests/benchmark/fixtures/minimal/min-py-imports/manifest.json new file mode 100644 index 00000000..4b7f6661 --- /dev/null +++ b/apps/backend/tests/benchmark/fixtures/minimal/min-py-imports/manifest.json @@ -0,0 +1,35 @@ +{ + "schemaVersion": "ri-benchmark.v1", + "fixtureId": "min-py-imports", + "fixtureClass": "minimal", + "language": "python", + "title": "Minimal Python — imports, aliases, from-imports", + "description": "Plain, aliased, and from-imports as observed import occurrences (RFC-0001 §6.4). Exercises py.import, py.import_alias, py.from_import.", + "sourceRoot": ".", + "revisionIdentity": "upload-sha256", + "producerVersionSet": ["python-ast@1.1.0", "repository-inventory@1.1.0"], + "constructsCovered": ["py.import", "py.import_alias", "py.from_import", "py.module"], + "deterministic": false, + "expected": { + "nodes": [ + {"nodeKind": "repository", "stableKey": "repo:root", "name": "repository", + "evidence": [{"path": "src/wiring.py", "startLine": 1, "endLine": 1, "granularity": "span", "extractor": "repository-inventory", "extractorVersion": "1.1.0"}]}, + {"nodeKind": "file", "stableKey": "file:src/wiring.py", "properties": {"content_sha256": "sha256:7d2a93943dd56d0d18b1623928724305970070c7a6840606809e6d1eaca6fc87"}, "name": "wiring.py", "language": "python", "constructs": ["src.file"], + "evidence": [{"path": "src/wiring.py", "startLine": 1, "endLine": 4, "granularity": "file", "extractor": "repository-inventory", "extractorVersion": "1.1.0"}]} + ,{"nodeKind": "module", "stableKey": "mod:src", "name": "src", "constructs": ["py.module"], + "evidence": [{"path": "src/wiring.py", "startLine": 1, "endLine": 4, "granularity": "file", "extractor": "python-ast", "extractorVersion": "1.1.0"}]} + ], + "edges": [], + "observations": [ + {"observedKind": "import", "subjectKind": "module", "subjectKey": "mod:src", "referentText": "os", "ordinal": 1, "constructs": ["py.import"], + "evidence": {"path": "src/wiring.py", "startLine": 1, "endLine": 1, "granularity": "span", "extractor": "python-ast", "extractorVersion": "1.1.0"}}, + {"observedKind": "import", "subjectKind": "module", "subjectKey": "mod:src", "referentText": "json", "ordinal": 1, "constructs": ["py.import_alias"], + "evidence": {"path": "src/wiring.py", "startLine": 2, "endLine": 2, "granularity": "span", "extractor": "python-ast", "extractorVersion": "1.1.0"}}, + {"observedKind": "import", "subjectKind": "module", "subjectKey": "mod:src", "referentText": "typing.List", "ordinal": 1, "constructs": ["py.from_import"], + "evidence": {"path": "src/wiring.py", "startLine": 3, "endLine": 3, "granularity": "span", "extractor": "python-ast", "extractorVersion": "1.1.0"}}, + {"observedKind": "import_binding", "subjectKind": "module", "subjectKey": "mod:src", "referentText": "typing|List|List", "ordinal": 1, "constructs": ["py.from_import"], + "evidence": {"path": "src/wiring.py", "startLine": 3, "endLine": 3, "granularity": "span", "extractor": "python-ast", "extractorVersion": "1.1.0"}} + ], + "assertions": [], "diagnostics": [] + } +} diff --git a/apps/backend/tests/benchmark/fixtures/minimal/min-py-imports/src/wiring.py b/apps/backend/tests/benchmark/fixtures/minimal/min-py-imports/src/wiring.py new file mode 100644 index 00000000..3f7c58bf --- /dev/null +++ b/apps/backend/tests/benchmark/fixtures/minimal/min-py-imports/src/wiring.py @@ -0,0 +1,3 @@ +import os +import json as encoder +from typing import List diff --git a/apps/backend/tests/benchmark/fixtures/minimal/min-py-nested-dup/manifest.json b/apps/backend/tests/benchmark/fixtures/minimal/min-py-nested-dup/manifest.json new file mode 100644 index 00000000..d2dd9227 --- /dev/null +++ b/apps/backend/tests/benchmark/fixtures/minimal/min-py-nested-dup/manifest.json @@ -0,0 +1,39 @@ +{ + "schemaVersion": "ri-benchmark.v1", + "fixtureId": "min-py-nested-dup", + "fixtureClass": "minimal", + "language": "python", + "title": "Minimal Python — nested and duplicate definitions", + "description": "A nested function and a redefined name resolved with a discriminator. Exercises py.nested_function and py.duplicate_symbol, with an informational RI-KEY-DUP-SYMBOL diagnostic.", + "sourceRoot": ".", + "revisionIdentity": "upload-sha256", + "producerVersionSet": ["python-ast@1.1.0", "repository-inventory@1.1.0"], + "constructsCovered": ["py.function.def", "py.nested_function", "py.duplicate_symbol", "py.module"], + "deterministic": true, + "expected": { + "nodes": [ + {"nodeKind": "repository", "stableKey": "repo:root", "name": "repository", + "evidence": [{"path": "src/util.py", "startLine": 1, "endLine": 1, "granularity": "span", "extractor": "repository-inventory", "extractorVersion": "1.1.0"}]}, + {"nodeKind": "file", "stableKey": "file:src/util.py", "properties": {"content_sha256": "sha256:c554a8a791d2ff0efefbcec4a14f0f0be2fdb53c6254ee668fc30b7077bf949e"}, "name": "util.py", "language": "python", "constructs": ["src.file"], + "evidence": [{"path": "src/util.py", "startLine": 1, "endLine": 9, "granularity": "file", "extractor": "repository-inventory", "extractorVersion": "1.1.0"}]}, + {"nodeKind": "module", "stableKey": "mod:src", "name": "src", "constructs": ["py.module"], + "evidence": [{"path": "src/util.py", "startLine": 1, "endLine": 9, "granularity": "file", "extractor": "python-ast", "extractorVersion": "1.1.0"}]}, + {"nodeKind": "symbol", "stableKey": "src/util.py::outer", "name": "outer", "language": "python", "constructs": ["py.function.def", "py.duplicate_symbol"], + "evidence": [{"path": "src/util.py", "startLine": 1, "endLine": 4, "granularity": "span", "extractor": "python-ast", "extractorVersion": "1.1.0"}]}, + {"nodeKind": "symbol", "stableKey": "src/util.py::outer.inner", "name": "inner", "language": "python", "constructs": ["py.nested_function"], + "evidence": [{"path": "src/util.py", "startLine": 2, "endLine": 3, "granularity": "span", "extractor": "python-ast", "extractorVersion": "1.1.0"}]}, + {"nodeKind": "symbol", "stableKey": "src/util.py::outer#2", "name": "outer", "language": "python", "constructs": ["py.function.def", "py.duplicate_symbol"], + "evidence": [{"path": "src/util.py", "startLine": 7, "endLine": 8, "granularity": "span", "extractor": "python-ast", "extractorVersion": "1.1.0"}]} + ], + "edges": [], "observations": [ + {"observedKind":"definition","subjectKind":"symbol","subjectKey":"src/util.py::outer","ordinal":1,"constructs":["py.function.def","py.duplicate_symbol"],"evidence":{"path":"src/util.py","startLine":1,"endLine":4,"extractor":"python-ast","extractorVersion":"1.1.0"}}, + {"observedKind":"definition","subjectKind":"symbol","subjectKey":"src/util.py::outer.inner","ordinal":1,"constructs":["py.nested_function"],"evidence":{"path":"src/util.py","startLine":2,"endLine":3,"extractor":"python-ast","extractorVersion":"1.1.0"}}, + {"observedKind":"definition","subjectKind":"symbol","subjectKey":"src/util.py::outer#2","ordinal":1,"constructs":["py.function.def","py.duplicate_symbol"],"evidence":{"path":"src/util.py","startLine":7,"endLine":8,"extractor":"python-ast","extractorVersion":"1.1.0"}} + ], "assertions": [], + "diagnostics": [ + {"code": "RI-KEY-DUP-SYMBOL", "category": "duplicate symbol", "severity": "info", + "message": "Redefined name 'outer' resolved with discriminator #2.", "producer": "python-ast@1.1.0", + "path": "src/util.py", "subject": "src/util.py::outer#2", "constructs": ["py.duplicate_symbol"]} + ] + } +} diff --git a/apps/backend/tests/benchmark/fixtures/minimal/min-py-nested-dup/src/util.py b/apps/backend/tests/benchmark/fixtures/minimal/min-py-nested-dup/src/util.py new file mode 100644 index 00000000..e41d39a4 --- /dev/null +++ b/apps/backend/tests/benchmark/fixtures/minimal/min-py-nested-dup/src/util.py @@ -0,0 +1,8 @@ +def outer(): + def inner(): + return 1 + return inner + + +def outer(): + return 2 diff --git a/apps/backend/tests/benchmark/fixtures/minimal/min-trailing-newline/data.txt b/apps/backend/tests/benchmark/fixtures/minimal/min-trailing-newline/data.txt new file mode 100644 index 00000000..fbbee861 --- /dev/null +++ b/apps/backend/tests/benchmark/fixtures/minimal/min-trailing-newline/data.txt @@ -0,0 +1,2 @@ +alpha +beta diff --git a/apps/backend/tests/benchmark/fixtures/minimal/min-trailing-newline/manifest.json b/apps/backend/tests/benchmark/fixtures/minimal/min-trailing-newline/manifest.json new file mode 100644 index 00000000..3f653cfd --- /dev/null +++ b/apps/backend/tests/benchmark/fixtures/minimal/min-trailing-newline/manifest.json @@ -0,0 +1,22 @@ +{ + "schemaVersion": "ri-benchmark.v1", + "fixtureId": "min-trailing-newline", + "fixtureClass": "minimal", + "language": "mixed", + "title": "Minimal — trailing newline (final empty logical line)", + "description": "A file ending in a newline has a final empty logical line, so 'alpha\\nbeta\\n' has logical_line_count 3 (RFC-0001 §6.2). Exercises src.trailing_newline.", + "sourceRoot": ".", + "revisionIdentity": "upload-sha256", + "producerVersionSet": ["repository-inventory@1.1.0"], + "constructsCovered": ["src.trailing_newline"], + "deterministic": true, + "expected": { + "nodes": [ + {"nodeKind": "repository", "stableKey": "repo:root", "name": "repository", + "evidence": [{"path": "data.txt", "startLine": 1, "endLine": 1, "granularity": "span", "extractor": "repository-inventory", "extractorVersion": "1.1.0"}]}, + {"nodeKind": "file", "stableKey": "file:data.txt", "properties": {"content_sha256": "sha256:e49c81e2d2f84e259d40e2fb8192f3bcd198b355184845d76d8f58807d0d78ee"}, "name": "data.txt", "constructs": ["src.trailing_newline"], + "evidence": [{"path": "data.txt", "startLine": 1, "endLine": 3, "granularity": "file", "extractor": "repository-inventory", "extractorVersion": "1.1.0"}]} + ], + "edges": [], "observations": [], "assertions": [], "diagnostics": [] + } +} diff --git a/apps/backend/tests/benchmark/fixtures/minimal/min-ts-class/manifest.json b/apps/backend/tests/benchmark/fixtures/minimal/min-ts-class/manifest.json new file mode 100644 index 00000000..68f11d6f --- /dev/null +++ b/apps/backend/tests/benchmark/fixtures/minimal/min-ts-class/manifest.json @@ -0,0 +1,34 @@ +{ + "schemaVersion": "ri-benchmark.v1", + "fixtureId": "min-ts-class", + "fixtureClass": "minimal", + "language": "typescript", + "title": "Minimal TypeScript — class with methods", + "description": "A class with two methods. Exercises ts.class and ts.method.", + "sourceRoot": ".", + "revisionIdentity": "upload-sha256", + "producerVersionSet": ["repository-inventory@1.1.0", "typescript-ast@1.2.0"], + "constructsCovered": ["ts.module", "ts.class", "ts.method"], + "deterministic": true, + "expected": { + "nodes": [ + {"nodeKind": "repository", "stableKey": "repo:root", "name": "repository", + "evidence": [{"path": "src/service.ts", "startLine": 1, "endLine": 1, "granularity": "span", "extractor": "repository-inventory", "extractorVersion": "1.1.0"}]}, + {"nodeKind": "file", "stableKey": "file:src/service.ts", "properties": {"content_sha256": "sha256:424db9934ede4aac6b9a1e1dfb7426de9f26ba60dedf752ae5c62ca61e3dd6a1"}, "name": "service.ts", "language": "typescript", "constructs": ["ts.module"], + "evidence": [{"path": "src/service.ts", "startLine": 1, "endLine": 10, "granularity": "file", "extractor": "typescript-ast", "extractorVersion": "1.2.0"}]}, + {"nodeKind": "module", "stableKey": "mod:src", "name": "src", "constructs": ["ts.directory_module"], + "evidence": [{"path": "src/service.ts", "startLine": 1, "endLine": 10, "granularity": "file", "extractor": "typescript-ast", "extractorVersion": "1.2.0"}]}, + {"nodeKind": "symbol", "stableKey": "src/service.ts::Cache", "name": "Cache", "language": "typescript", "constructs": ["ts.class"], + "evidence": [{"path": "src/service.ts", "startLine": 1, "endLine": 9, "granularity": "span", "extractor": "typescript-ast", "extractorVersion": "1.2.0"}]}, + {"nodeKind": "symbol", "stableKey": "src/service.ts::Cache.get", "name": "get", "language": "typescript", "constructs": ["ts.method"], + "evidence": [{"path": "src/service.ts", "startLine": 2, "endLine": 4, "granularity": "span", "extractor": "typescript-ast", "extractorVersion": "1.2.0"}]}, + {"nodeKind": "symbol", "stableKey": "src/service.ts::Cache.clear", "name": "clear", "language": "typescript", "constructs": ["ts.method"], + "evidence": [{"path": "src/service.ts", "startLine": 6, "endLine": 8, "granularity": "span", "extractor": "typescript-ast", "extractorVersion": "1.2.0"}]} + ], + "edges": [], "observations": [ + {"observedKind":"definition","subjectKind":"symbol","subjectKey":"src/service.ts::Cache","ordinal":1,"constructs":["ts.class"],"evidence":{"path":"src/service.ts","startLine":1,"endLine":9,"extractor":"typescript-ast","extractorVersion":"1.2.0"}}, + {"observedKind":"definition","subjectKind":"symbol","subjectKey":"src/service.ts::Cache.get","ordinal":1,"constructs":["ts.method"],"evidence":{"path":"src/service.ts","startLine":2,"endLine":4,"extractor":"typescript-ast","extractorVersion":"1.2.0"}}, + {"observedKind":"definition","subjectKind":"symbol","subjectKey":"src/service.ts::Cache.clear","ordinal":1,"constructs":["ts.method"],"evidence":{"path":"src/service.ts","startLine":6,"endLine":8,"extractor":"typescript-ast","extractorVersion":"1.2.0"}} + ], "assertions": [], "diagnostics": [] + } +} diff --git a/apps/backend/tests/benchmark/fixtures/minimal/min-ts-class/src/service.ts b/apps/backend/tests/benchmark/fixtures/minimal/min-ts-class/src/service.ts new file mode 100644 index 00000000..679e6f79 --- /dev/null +++ b/apps/backend/tests/benchmark/fixtures/minimal/min-ts-class/src/service.ts @@ -0,0 +1,9 @@ +class Cache { + get(key: string): string { + return key; + } + + clear(): void { + return; + } +} diff --git a/apps/backend/tests/benchmark/fixtures/minimal/min-ts-functions/manifest.json b/apps/backend/tests/benchmark/fixtures/minimal/min-ts-functions/manifest.json new file mode 100644 index 00000000..f00fc633 --- /dev/null +++ b/apps/backend/tests/benchmark/fixtures/minimal/min-ts-functions/manifest.json @@ -0,0 +1,31 @@ +{ + "schemaVersion": "ri-benchmark.v1", + "fixtureId": "min-ts-functions", + "fixtureClass": "minimal", + "language": "typescript", + "title": "Minimal TypeScript — function declarations", + "description": "A sync and an async function declaration. Exercises ts.function and ts.async_function.", + "sourceRoot": ".", + "revisionIdentity": "upload-sha256", + "producerVersionSet": ["repository-inventory@1.1.0", "typescript-ast@1.2.0"], + "constructsCovered": ["ts.module", "ts.function", "ts.async_function"], + "deterministic": true, + "expected": { + "nodes": [ + {"nodeKind": "repository", "stableKey": "repo:root", "name": "repository", + "evidence": [{"path": "src/util.ts", "startLine": 1, "endLine": 1, "granularity": "span", "extractor": "repository-inventory", "extractorVersion": "1.1.0"}]}, + {"nodeKind": "file", "stableKey": "file:src/util.ts", "properties": {"content_sha256": "sha256:3afa8c2ee964e444b087970b30a3fdb6c07e5911b4d2c1a520554e132e7b5249"}, "name": "util.ts", "language": "typescript", "constructs": ["ts.module"], + "evidence": [{"path": "src/util.ts", "startLine": 1, "endLine": 8, "granularity": "file", "extractor": "typescript-ast", "extractorVersion": "1.2.0"}]}, + {"nodeKind": "module", "stableKey": "mod:src", "name": "src", "constructs": ["ts.directory_module"], + "evidence": [{"path": "src/util.ts", "startLine": 1, "endLine": 8, "granularity": "file", "extractor": "typescript-ast", "extractorVersion": "1.2.0"}]}, + {"nodeKind": "symbol", "stableKey": "src/util.ts::add", "name": "add", "language": "typescript", "constructs": ["ts.function"], + "evidence": [{"path": "src/util.ts", "startLine": 1, "endLine": 3, "granularity": "span", "extractor": "typescript-ast", "extractorVersion": "1.2.0"}]}, + {"nodeKind": "symbol", "stableKey": "src/util.ts::load", "name": "load", "language": "typescript", "constructs": ["ts.async_function"], + "evidence": [{"path": "src/util.ts", "startLine": 5, "endLine": 7, "granularity": "span", "extractor": "typescript-ast", "extractorVersion": "1.2.0"}]} + ], + "edges": [], "observations": [ + {"observedKind":"definition","subjectKind":"symbol","subjectKey":"src/util.ts::add","ordinal":1,"constructs":["ts.function"],"evidence":{"path":"src/util.ts","startLine":1,"endLine":3,"extractor":"typescript-ast","extractorVersion":"1.2.0"}}, + {"observedKind":"definition","subjectKind":"symbol","subjectKey":"src/util.ts::load","ordinal":1,"constructs":["ts.async_function"],"evidence":{"path":"src/util.ts","startLine":5,"endLine":7,"extractor":"typescript-ast","extractorVersion":"1.2.0"}} + ], "assertions": [], "diagnostics": [] + } +} diff --git a/apps/backend/tests/benchmark/fixtures/minimal/min-ts-functions/src/util.ts b/apps/backend/tests/benchmark/fixtures/minimal/min-ts-functions/src/util.ts new file mode 100644 index 00000000..902e478f --- /dev/null +++ b/apps/backend/tests/benchmark/fixtures/minimal/min-ts-functions/src/util.ts @@ -0,0 +1,7 @@ +function add(a: number, b: number): number { + return a + b; +} + +async function load(id: string): Promise { + return id; +} diff --git a/apps/backend/tests/benchmark/fixtures/minimal/min-ts-imports-exports/manifest.json b/apps/backend/tests/benchmark/fixtures/minimal/min-ts-imports-exports/manifest.json new file mode 100644 index 00000000..88db3dae --- /dev/null +++ b/apps/backend/tests/benchmark/fixtures/minimal/min-ts-imports-exports/manifest.json @@ -0,0 +1,39 @@ +{ + "schemaVersion": "ri-benchmark.v1", + "fixtureId": "min-ts-imports-exports", + "fixtureClass": "minimal", + "language": "typescript", + "title": "Minimal TypeScript — imports, aliases, exports, re-exports", + "description": "Import, aliased import, export, and re-export as observed occurrences. Exercises ts.import, ts.import_alias, ts.export, ts.reexport.", + "sourceRoot": ".", + "revisionIdentity": "upload-sha256", + "producerVersionSet": ["repository-inventory@1.1.0", "typescript-ast@1.2.0"], + "constructsCovered": ["ts.module", "ts.import", "ts.import_alias", "ts.export", "ts.reexport"], + "deterministic": false, + "expected": { + "nodes": [ + {"nodeKind": "repository", "stableKey": "repo:root", "name": "repository", + "evidence": [{"path": "src/index.ts", "startLine": 1, "endLine": 1, "granularity": "span", "extractor": "repository-inventory", "extractorVersion": "1.1.0"}]}, + {"nodeKind": "file", "stableKey": "file:src/index.ts", "properties": {"content_sha256": "sha256:3463de6392986ede90933aaf9f609a46426011cbe932ed28827d4cb7913484db"}, "name": "index.ts", "language": "typescript", "constructs": ["ts.module"], + "evidence": [{"path": "src/index.ts", "startLine": 1, "endLine": 7, "granularity": "file", "extractor": "typescript-ast", "extractorVersion": "1.2.0"}]}, + {"nodeKind":"module","stableKey":"mod:src","name":"src","constructs":["ts.directory_module"],"evidence":[{"path":"src/index.ts","startLine":1,"endLine":7,"granularity":"file","extractor":"typescript-ast","extractorVersion":"1.2.0"}]}, + {"nodeKind":"symbol","stableKey":"src/index.ts::VERSION","name":"VERSION","language":"typescript","properties":{"exported":true},"constructs":["ts.const","ts.export"],"evidence":[{"path":"src/index.ts","startLine":4,"endLine":4,"extractor":"typescript-ast","extractorVersion":"1.2.0"}]} + ], + "edges": [], + "observations": [ + {"observedKind": "import", "subjectKind": "file", "subjectKey": "file:src/index.ts", "referentText": "fs", "ordinal": 1, "constructs": ["ts.import"], + "evidence": {"path": "src/index.ts", "startLine": 1, "endLine": 1, "granularity": "span", "extractor": "typescript-ast", "extractorVersion": "1.2.0"}}, + {"observedKind": "import_binding", "subjectKind": "file", "subjectKey": "file:src/index.ts", "referentText": "fs|readFile|readFile", "ordinal": 1, "constructs": ["ts.import"], + "evidence": {"path": "src/index.ts", "startLine": 1, "endLine": 1, "granularity": "span", "extractor": "typescript-ast", "extractorVersion": "1.2.0"}}, + {"observedKind": "import", "subjectKind": "file", "subjectKey": "file:src/index.ts", "referentText": "path", "ordinal": 1, "constructs": ["ts.import_alias"], + "evidence": {"path": "src/index.ts", "startLine": 2, "endLine": 2, "granularity": "span", "extractor": "typescript-ast", "extractorVersion": "1.2.0"}}, + {"observedKind": "import_binding", "subjectKind": "file", "subjectKey": "file:src/index.ts", "referentText": "path|join|pathJoin", "ordinal": 1, "constructs": ["ts.import_alias"], + "evidence": {"path": "src/index.ts", "startLine": 2, "endLine": 2, "granularity": "span", "extractor": "typescript-ast", "extractorVersion": "1.2.0"}}, + {"observedKind": "definition", "subjectKind": "symbol", "subjectKey": "src/index.ts::VERSION", "ordinal": 1, "constructs": ["ts.const", "ts.export"], + "evidence": {"path": "src/index.ts", "startLine": 4, "endLine": 4, "granularity": "span", "extractor": "typescript-ast", "extractorVersion": "1.2.0"}}, + {"observedKind": "import", "subjectKind": "file", "subjectKey": "file:src/index.ts", "referentText": "fs", "ordinal": 1, "constructs": ["ts.reexport"], + "evidence": {"path": "src/index.ts", "startLine": 6, "endLine": 6, "granularity": "span", "extractor": "typescript-ast", "extractorVersion": "1.2.0"}} + ], + "assertions": [], "diagnostics": [] + } +} diff --git a/apps/backend/tests/benchmark/fixtures/minimal/min-ts-imports-exports/src/index.ts b/apps/backend/tests/benchmark/fixtures/minimal/min-ts-imports-exports/src/index.ts new file mode 100644 index 00000000..f0efe824 --- /dev/null +++ b/apps/backend/tests/benchmark/fixtures/minimal/min-ts-imports-exports/src/index.ts @@ -0,0 +1,6 @@ +import { readFile } from "fs"; +import { join as pathJoin } from "path"; + +export const VERSION = "1.0.0"; + +export { readFile } from "fs"; diff --git a/apps/backend/tests/benchmark/fixtures/minimal/min-ts-route/manifest.json b/apps/backend/tests/benchmark/fixtures/minimal/min-ts-route/manifest.json new file mode 100644 index 00000000..e0e659de --- /dev/null +++ b/apps/backend/tests/benchmark/fixtures/minimal/min-ts-route/manifest.json @@ -0,0 +1,37 @@ +{ + "schemaVersion": "ri-benchmark.v1", + "fixtureId": "min-ts-route", + "fixtureClass": "minimal", + "language": "typescript", + "title": "Minimal TypeScript — react-router route", + "description": "A createBrowserRouter route-table entry exercises the actual declared TypeScript route support.", + "sourceRoot": ".", + "revisionIdentity": "upload-sha256", + "producerVersionSet": ["repository-inventory@1.1.0", "typescript-ast@1.2.0"], + "constructsCovered": ["src.repository", "ts.module", "ts.directory_module", "ts.const", "ts.route"], + "deterministic": true, + "expected": { + "nodes": [ + {"nodeKind": "repository", "stableKey": "repo:root", "name": "repository", "constructs": ["src.repository"], + "evidence": [{"path": "src/router.ts", "startLine": 1, "endLine": 1, "extractor": "repository-inventory", "extractorVersion": "1.1.0"}]}, + {"nodeKind": "file", "stableKey": "file:src/router.ts", "properties": {"content_sha256": "sha256:1f2ed43ce38cdff374e03d9455b0ce7b65189df98f4d1e5914f48abcfdc5cdbd"}, "name": "router.ts", "language": "typescript", "constructs": ["ts.module"], + "evidence": [{"path": "src/router.ts", "startLine": 1, "endLine": 4, "granularity": "file", "extractor": "typescript-ast", "extractorVersion": "1.2.0"}]}, + {"nodeKind": "module", "stableKey": "mod:src", "name": "src", "constructs": ["ts.directory_module"], + "evidence": [{"path": "src/router.ts", "startLine": 1, "endLine": 4, "granularity": "file", "extractor": "typescript-ast", "extractorVersion": "1.2.0"}]}, + {"nodeKind": "symbol", "stableKey": "src/router.ts::router", "name": "router", "language": "typescript", "constructs": ["ts.const"], + "evidence": [{"path": "src/router.ts", "startLine": 1, "endLine": 3, "extractor": "typescript-ast", "extractorVersion": "1.2.0"}]}, + {"nodeKind": "symbol", "stableKey": "src/router.ts::(anonymous:route#1)", "name": "route", "language": "typescript", "properties": {"route_path": "/status"}, "constructs": ["ts.route"], + "evidence": [{"path": "src/router.ts", "startLine": 2, "endLine": 2, "extractor": "typescript-ast", "extractorVersion": "1.2.0"}]} + ], + "edges": [], + "observations": [ + {"observedKind": "definition", "subjectKind": "symbol", "subjectKey": "src/router.ts::router", "ordinal": 1, "constructs": ["ts.const"], + "evidence": {"path": "src/router.ts", "startLine": 1, "endLine": 3, "extractor": "typescript-ast", "extractorVersion": "1.2.0"}}, + {"observedKind": "call", "subjectKind": "file", "subjectKey": "file:src/router.ts", "referentText": "createBrowserRouter", "ordinal": 1, "constructs": ["ts.route"], + "evidence": {"path": "src/router.ts", "startLine": 1, "endLine": 3, "extractor": "typescript-ast", "extractorVersion": "1.2.0"}}, + {"observedKind": "route", "subjectKind": "symbol", "subjectKey": "src/router.ts::(anonymous:route#1)", "referentText": "/status", "ordinal": 1, "constructs": ["ts.route"], + "evidence": {"path": "src/router.ts", "startLine": 2, "endLine": 2, "extractor": "typescript-ast", "extractorVersion": "1.2.0"}} + ], + "assertions": [], "diagnostics": [] + } +} diff --git a/apps/backend/tests/benchmark/fixtures/minimal/min-ts-route/src/router.ts b/apps/backend/tests/benchmark/fixtures/minimal/min-ts-route/src/router.ts new file mode 100644 index 00000000..14864991 --- /dev/null +++ b/apps/backend/tests/benchmark/fixtures/minimal/min-ts-route/src/router.ts @@ -0,0 +1,3 @@ +const router = createBrowserRouter([ + { path: "/status", element: null }, +]); diff --git a/apps/backend/tests/benchmark/fixtures/minimal/min-ts-types/manifest.json b/apps/backend/tests/benchmark/fixtures/minimal/min-ts-types/manifest.json new file mode 100644 index 00000000..f8838e9d --- /dev/null +++ b/apps/backend/tests/benchmark/fixtures/minimal/min-ts-types/manifest.json @@ -0,0 +1,24 @@ +{ + "schemaVersion": "ri-benchmark.v1", "fixtureId": "min-ts-types", "fixtureClass": "minimal", "language": "typescript", + "title": "Minimal TypeScript — interface, type, enum, and const", "description": "One independently authored declaration for each supported type-level construct.", + "sourceRoot": ".", "revisionIdentity": "upload-sha256", + "producerVersionSet": ["repository-inventory@1.1.0", "typescript-ast@1.2.0"], + "constructsCovered": ["ts.module", "ts.directory_module", "ts.interface", "ts.type", "ts.enum", "ts.const", "ts.export"], "deterministic": true, + "expected": { + "nodes": [ + {"nodeKind":"repository","stableKey":"repo:root","name":"repository","evidence":[{"path":"src/types.ts","startLine":1,"endLine":1,"extractor":"repository-inventory","extractorVersion":"1.1.0"}]}, + {"nodeKind":"file","stableKey":"file:src/types.ts", "properties": {"content_sha256": "sha256:b0478a1fef7c1c8cc8e25a00a295bcb5aeb560461578a36fc7d324bbf70f3754"},"name":"types.ts","language":"typescript","constructs":["ts.module"],"evidence":[{"path":"src/types.ts","startLine":1,"endLine":5,"granularity":"file","extractor":"typescript-ast","extractorVersion":"1.2.0"}]}, + {"nodeKind":"module","stableKey":"mod:src","name":"src","constructs":["ts.directory_module"],"evidence":[{"path":"src/types.ts","startLine":1,"endLine":5,"granularity":"file","extractor":"typescript-ast","extractorVersion":"1.2.0"}]}, + {"nodeKind":"symbol","stableKey":"src/types.ts::User","name":"User","language":"typescript","properties":{"exported":true},"constructs":["ts.interface","ts.export"],"evidence":[{"path":"src/types.ts","startLine":1,"endLine":1,"extractor":"typescript-ast","extractorVersion":"1.2.0"}]}, + {"nodeKind":"symbol","stableKey":"src/types.ts::UserId","name":"UserId","language":"typescript","properties":{"exported":true},"constructs":["ts.type","ts.export"],"evidence":[{"path":"src/types.ts","startLine":2,"endLine":2,"extractor":"typescript-ast","extractorVersion":"1.2.0"}]}, + {"nodeKind":"symbol","stableKey":"src/types.ts::Role","name":"Role","language":"typescript","properties":{"exported":true},"constructs":["ts.enum","ts.export"],"evidence":[{"path":"src/types.ts","startLine":3,"endLine":3,"extractor":"typescript-ast","extractorVersion":"1.2.0"}]}, + {"nodeKind":"symbol","stableKey":"src/types.ts::DEFAULT_ROLE","name":"DEFAULT_ROLE","language":"typescript","properties":{"exported":true},"constructs":["ts.const","ts.export"],"evidence":[{"path":"src/types.ts","startLine":4,"endLine":4,"extractor":"typescript-ast","extractorVersion":"1.2.0"}]} + ], "edges": [], + "observations": [ + {"observedKind":"definition","subjectKind":"symbol","subjectKey":"src/types.ts::User","ordinal":1,"constructs":["ts.interface"],"evidence":{"path":"src/types.ts","startLine":1,"endLine":1,"extractor":"typescript-ast","extractorVersion":"1.2.0"}}, + {"observedKind":"definition","subjectKind":"symbol","subjectKey":"src/types.ts::UserId","ordinal":1,"constructs":["ts.type"],"evidence":{"path":"src/types.ts","startLine":2,"endLine":2,"extractor":"typescript-ast","extractorVersion":"1.2.0"}}, + {"observedKind":"definition","subjectKind":"symbol","subjectKey":"src/types.ts::Role","ordinal":1,"constructs":["ts.enum"],"evidence":{"path":"src/types.ts","startLine":3,"endLine":3,"extractor":"typescript-ast","extractorVersion":"1.2.0"}}, + {"observedKind":"definition","subjectKind":"symbol","subjectKey":"src/types.ts::DEFAULT_ROLE","ordinal":1,"constructs":["ts.const"],"evidence":{"path":"src/types.ts","startLine":4,"endLine":4,"extractor":"typescript-ast","extractorVersion":"1.2.0"}} + ], "assertions": [], "diagnostics": [] + } +} diff --git a/apps/backend/tests/benchmark/fixtures/minimal/min-ts-types/src/types.ts b/apps/backend/tests/benchmark/fixtures/minimal/min-ts-types/src/types.ts new file mode 100644 index 00000000..5bad8272 --- /dev/null +++ b/apps/backend/tests/benchmark/fixtures/minimal/min-ts-types/src/types.ts @@ -0,0 +1,4 @@ +export interface User { id: string } +export type UserId = string; +export enum Role { Admin } +export const DEFAULT_ROLE = Role.Admin; diff --git a/apps/backend/tests/benchmark/fixtures/realistic/real-mixed-compose/docker-compose.yml b/apps/backend/tests/benchmark/fixtures/realistic/real-mixed-compose/docker-compose.yml new file mode 100644 index 00000000..9e188b37 --- /dev/null +++ b/apps/backend/tests/benchmark/fixtures/realistic/real-mixed-compose/docker-compose.yml @@ -0,0 +1,9 @@ +services: + api: + image: python:3.13-slim + worker: + image: ${WORKER_IMAGE} +volumes: + pgdata: +networks: + backend: diff --git a/apps/backend/tests/benchmark/fixtures/realistic/real-mixed-compose/manifest.json b/apps/backend/tests/benchmark/fixtures/realistic/real-mixed-compose/manifest.json new file mode 100644 index 00000000..6bc63cc1 --- /dev/null +++ b/apps/backend/tests/benchmark/fixtures/realistic/real-mixed-compose/manifest.json @@ -0,0 +1,26 @@ +{ + "schemaVersion":"ri-benchmark.v1","fixtureId":"real-mixed-compose","fixtureClass":"realistic","language":"mixed", + "title":"Realistic IaC — Docker Compose resources","description":"Declared Compose services, volumes, and networks become iac_resource nodes at their exact declaration lines; an interpolated image is disclosed rather than recorded as an observed value.", + "sourceRoot":".","revisionIdentity":"upload-sha256","producerVersionSet":["iac-manifest@1.0.0","repository-inventory@1.1.0"], + "constructsCovered":["src.file","src.repository","src.compose_service","src.compose_volume","src.compose_network","src.compose_templated"],"deterministic":true, + "expected":{"nodes":[ + {"nodeKind":"repository","stableKey":"repo:root","name":"repository","constructs":["src.repository"],"evidence":[{"path":"docker-compose.yml","startLine":1,"endLine":1,"extractor":"repository-inventory","extractorVersion":"1.1.0"}]}, + {"nodeKind":"file","stableKey":"file:docker-compose.yml","properties":{"content_sha256":"sha256:a08b976e00f7cb9114fcc5793da7d6600c3d016f7d3474f9435c1c9e2cdf25f7"},"name":"docker-compose.yml","constructs":["src.file"],"evidence":[{"path":"docker-compose.yml","startLine":1,"endLine":10,"granularity":"file","extractor":"repository-inventory","extractorVersion":"1.1.0"}]}, + + {"comment":"Identity carries the manifest path: a db service in a second Compose file would be a different resource, not a redefinition of this one.", + "nodeKind":"iac_resource","stableKey":"iac:docker-compose.yml::network/backend","name":"backend","properties":{"resource_type":"network","manifest_format":"docker-compose","manifest_path":"docker-compose.yml"},"constructs":["src.compose_network"],"evidence":[{"path":"docker-compose.yml","startLine":9,"endLine":9,"extractor":"iac-manifest","extractorVersion":"1.0.0"}]}, + {"comment":"A literal image is recorded as an observed property.", + "nodeKind":"iac_resource","stableKey":"iac:docker-compose.yml::service/api","name":"api","properties":{"resource_type":"service","manifest_format":"docker-compose","manifest_path":"docker-compose.yml","image":"python:3.13-slim"},"constructs":["src.compose_service"],"evidence":[{"path":"docker-compose.yml","startLine":2,"endLine":2,"extractor":"iac-manifest","extractorVersion":"1.0.0"}]}, + {"comment":"The resource itself is still declared literally, so it is a real node; only the interpolated image property is withheld.", + "nodeKind":"iac_resource","stableKey":"iac:docker-compose.yml::service/worker","name":"worker","properties":{"resource_type":"service","manifest_format":"docker-compose","manifest_path":"docker-compose.yml"},"constructs":["src.compose_service","src.compose_templated"],"evidence":[{"path":"docker-compose.yml","startLine":4,"endLine":4,"extractor":"iac-manifest","extractorVersion":"1.0.0"}]}, + {"nodeKind":"iac_resource","stableKey":"iac:docker-compose.yml::volume/pgdata","name":"pgdata","properties":{"resource_type":"volume","manifest_format":"docker-compose","manifest_path":"docker-compose.yml"},"constructs":["src.compose_volume"],"evidence":[{"path":"docker-compose.yml","startLine":7,"endLine":7,"extractor":"iac-manifest","extractorVersion":"1.0.0"}]} + ],"edges":[],"observations":[ + {"observedKind":"iac_resource","subjectKind":"iac_resource","subjectKey":"iac:docker-compose.yml::network/backend","referentText":"network/backend","ordinal":1,"constructs":["src.compose_network"],"evidence":{"path":"docker-compose.yml","startLine":9,"endLine":9,"extractor":"iac-manifest","extractorVersion":"1.0.0"}}, + {"observedKind":"iac_resource","subjectKind":"iac_resource","subjectKey":"iac:docker-compose.yml::service/api","referentText":"service/api","ordinal":1,"constructs":["src.compose_service"],"evidence":{"path":"docker-compose.yml","startLine":2,"endLine":2,"extractor":"iac-manifest","extractorVersion":"1.0.0"}}, + {"observedKind":"iac_resource","subjectKind":"iac_resource","subjectKey":"iac:docker-compose.yml::service/worker","referentText":"service/worker","ordinal":1,"constructs":["src.compose_service"],"evidence":{"path":"docker-compose.yml","startLine":4,"endLine":4,"extractor":"iac-manifest","extractorVersion":"1.0.0"}}, + {"observedKind":"iac_resource","subjectKind":"iac_resource","subjectKey":"iac:docker-compose.yml::volume/pgdata","referentText":"volume/pgdata","ordinal":1,"constructs":["src.compose_volume"],"evidence":{"path":"docker-compose.yml","startLine":7,"endLine":7,"extractor":"iac-manifest","extractorVersion":"1.0.0"}} + ],"assertions":[],"diagnostics":[ + {"comment":"image: ${WORKER_IMAGE} is a template. The blind spot is disclosed at the resource it belongs to, and no image property is claimed.", + "code":"RI-EXT-UNSUPPORTED","category":"unsupported construct","severity":"info","message":"templated IaC value is unsupported","producer":"iac-manifest@1.0.0","path":"docker-compose.yml","span":{"startLine":4,"endLine":4},"subject":"iac:docker-compose.yml::service/worker","constructs":["src.compose_templated"]} + ]} +} diff --git a/apps/backend/tests/benchmark/fixtures/realistic/real-mixed-lockfiles/manifest.json b/apps/backend/tests/benchmark/fixtures/realistic/real-mixed-lockfiles/manifest.json new file mode 100644 index 00000000..f48eefbd --- /dev/null +++ b/apps/backend/tests/benchmark/fixtures/realistic/real-mixed-lockfiles/manifest.json @@ -0,0 +1,44 @@ +{ + "schemaVersion":"ri-benchmark.v1","fixtureId":"real-mixed-lockfiles","fixtureClass":"realistic","language":"mixed", + "title":"Realistic lockfiles — npm and poetry resolved pins","description":"Resolved versions from package-lock.json v3 and poetry.lock land on the logical dependency identity as resolutions, never as declarations, including two installed versions of one npm package and a PEP 503 name fold.", + "sourceRoot":".","revisionIdentity":"upload-sha256","producerVersionSet":["dependency-lockfile@1.0.0","repository-inventory@1.1.0"], + "constructsCovered":["src.file","src.repository","src.npm_lockfile","src.npm_lockfile_nested","src.poetry_lockfile"],"deterministic":true, + "expected":{"nodes":[ + {"nodeKind":"repository","stableKey":"repo:root","name":"repository","constructs":["src.repository"],"evidence":[{"path":"package-lock.json","startLine":1,"endLine":1,"extractor":"repository-inventory","extractorVersion":"1.1.0"}]}, + {"nodeKind":"file","stableKey":"file:package-lock.json","properties":{"content_sha256":"sha256:890a22fc5820add670eda1bfd4dae934eddd83ae24802e90545ec83c2c6fd277"},"name":"package-lock.json","constructs":["src.file"],"evidence":[{"path":"package-lock.json","startLine":1,"endLine":20,"granularity":"file","extractor":"repository-inventory","extractorVersion":"1.1.0"}]}, + {"nodeKind":"file","stableKey":"file:poetry.lock","properties":{"content_sha256":"sha256:731ff7439917d7886c3f0edd2d7e42e60b9706c83f5c98ad07049efad1182787"},"name":"poetry.lock","constructs":["src.file"],"evidence":[{"path":"poetry.lock","startLine":1,"endLine":13,"granularity":"file","extractor":"repository-inventory","extractorVersion":"1.1.0"}]}, + + {"comment":"Two installed versions of one package — the root tree and a nested one under node_modules/util — are two resolutions of a single dep:npm:left-pad identity, not two dependencies. declarations is empty because no manifest in this fixture asked for it: 'resolved but not declared here' is stated, not implied.", + "nodeKind":"dependency","stableKey":"dep:npm:left-pad","name":"left-pad","constructs":["src.npm_lockfile","src.npm_lockfile_nested"], + "properties":{"ecosystem":"npm","declarations":[],"resolutions":[ + {"name":"left-pad","start_line":8,"end_line":8,"extractor":"dependency-lockfile","extractor_version":"1.0.0","resolved_version":"1.3.0","dependency_scope":"production","lockfile_path":"package-lock.json","lockfile_format":"npm-package-lock","lockfile_entry":"node_modules/left-pad","workspace_path":"."}, + {"name":"left-pad","start_line":15,"end_line":15,"extractor":"dependency-lockfile","extractor_version":"1.0.0","resolved_version":"1.2.0","dependency_scope":"production","lockfile_path":"package-lock.json","lockfile_format":"npm-package-lock","lockfile_entry":"node_modules/util/node_modules/left-pad","workspace_path":"."}]}, + "evidence":[ + {"path":"package-lock.json","startLine":8,"endLine":8,"extractor":"dependency-lockfile","extractorVersion":"1.0.0"}, + {"path":"package-lock.json","startLine":15,"endLine":15,"extractor":"dependency-lockfile","extractorVersion":"1.0.0"}]}, + + {"comment":"npm's dev flag maps onto the manifest dependency vocabulary.", + "nodeKind":"dependency","stableKey":"dep:npm:util","name":"util","constructs":["src.npm_lockfile"], + "properties":{"ecosystem":"npm","declarations":[],"resolutions":[ + {"name":"util","start_line":11,"end_line":11,"extractor":"dependency-lockfile","extractor_version":"1.0.0","resolved_version":"0.12.5","dependency_scope":"development","lockfile_path":"package-lock.json","lockfile_format":"npm-package-lock","lockfile_entry":"node_modules/util","workspace_path":"."}]}, + "evidence":[{"path":"package-lock.json","startLine":11,"endLine":11,"extractor":"dependency-lockfile","extractorVersion":"1.0.0"}]}, + + {"comment":"Poetry keeps the pre-2.0 category field here, so the development scope is recorded rather than guessed.", + "nodeKind":"dependency","stableKey":"dep:pypi:pytest","name":"pytest","constructs":["src.poetry_lockfile"], + "properties":{"ecosystem":"pypi","declarations":[],"resolutions":[ + {"name":"pytest","start_line":6,"end_line":6,"extractor":"dependency-lockfile","extractor_version":"1.0.0","resolved_version":"8.3.4","dependency_scope":"development","lockfile_path":"poetry.lock","lockfile_format":"poetry-lock","lockfile_entry":"pytest","workspace_path":"."}]}, + "evidence":[{"path":"poetry.lock","startLine":6,"endLine":6,"extractor":"dependency-lockfile","extractorVersion":"1.0.0"}]}, + + {"comment":"Requests-Toolbelt folds to the PEP 503 identity dep:pypi:requests-toolbelt so a manifest spelling it differently would share this node, while the record keeps the name the lockfile actually wrote. dependency_scope is null: this entry carries no category, and inventing production would be a guess.", + "nodeKind":"dependency","stableKey":"dep:pypi:requests-toolbelt","name":"Requests-Toolbelt","constructs":["src.poetry_lockfile"], + "properties":{"ecosystem":"pypi","declarations":[],"resolutions":[ + {"name":"Requests-Toolbelt","start_line":1,"end_line":1,"extractor":"dependency-lockfile","extractor_version":"1.0.0","resolved_version":"1.0.0","dependency_scope":null,"lockfile_path":"poetry.lock","lockfile_format":"poetry-lock","lockfile_entry":"Requests-Toolbelt","workspace_path":"."}]}, + "evidence":[{"path":"poetry.lock","startLine":1,"endLine":1,"extractor":"dependency-lockfile","extractorVersion":"1.0.0"}]} + ],"edges":[],"observations":[ + {"observedKind":"resolution","subjectKind":"dependency","subjectKey":"dep:npm:left-pad","referentText":"1.3.0","ordinal":1,"constructs":["src.npm_lockfile"],"evidence":{"path":"package-lock.json","startLine":8,"endLine":8,"extractor":"dependency-lockfile","extractorVersion":"1.0.0"}}, + {"observedKind":"resolution","subjectKind":"dependency","subjectKey":"dep:npm:util","referentText":"0.12.5","ordinal":1,"constructs":["src.npm_lockfile"],"evidence":{"path":"package-lock.json","startLine":11,"endLine":11,"extractor":"dependency-lockfile","extractorVersion":"1.0.0"}}, + {"observedKind":"resolution","subjectKind":"dependency","subjectKey":"dep:npm:left-pad","referentText":"1.2.0","ordinal":1,"constructs":["src.npm_lockfile_nested"],"evidence":{"path":"package-lock.json","startLine":15,"endLine":15,"extractor":"dependency-lockfile","extractorVersion":"1.0.0"}}, + {"observedKind":"resolution","subjectKind":"dependency","subjectKey":"dep:pypi:requests-toolbelt","referentText":"1.0.0","ordinal":1,"constructs":["src.poetry_lockfile"],"evidence":{"path":"poetry.lock","startLine":1,"endLine":1,"extractor":"dependency-lockfile","extractorVersion":"1.0.0"}}, + {"observedKind":"resolution","subjectKind":"dependency","subjectKey":"dep:pypi:pytest","referentText":"8.3.4","ordinal":1,"constructs":["src.poetry_lockfile"],"evidence":{"path":"poetry.lock","startLine":6,"endLine":6,"extractor":"dependency-lockfile","extractorVersion":"1.0.0"}} + ],"assertions":[],"diagnostics":[]} +} diff --git a/apps/backend/tests/benchmark/fixtures/realistic/real-mixed-lockfiles/package-lock.json b/apps/backend/tests/benchmark/fixtures/realistic/real-mixed-lockfiles/package-lock.json new file mode 100644 index 00000000..e4cefea8 --- /dev/null +++ b/apps/backend/tests/benchmark/fixtures/realistic/real-mixed-lockfiles/package-lock.json @@ -0,0 +1,19 @@ +{ + "name": "web", + "lockfileVersion": 3, + "packages": { + "": { + "name": "web" + }, + "node_modules/left-pad": { + "version": "1.3.0" + }, + "node_modules/util": { + "version": "0.12.5", + "dev": true + }, + "node_modules/util/node_modules/left-pad": { + "version": "1.2.0" + } + } +} diff --git a/apps/backend/tests/benchmark/fixtures/realistic/real-mixed-lockfiles/poetry.lock b/apps/backend/tests/benchmark/fixtures/realistic/real-mixed-lockfiles/poetry.lock new file mode 100644 index 00000000..d4edef10 --- /dev/null +++ b/apps/backend/tests/benchmark/fixtures/realistic/real-mixed-lockfiles/poetry.lock @@ -0,0 +1,12 @@ +[[package]] +name = "Requests-Toolbelt" +version = "1.0.0" +optional = false + +[[package]] +name = "pytest" +version = "8.3.4" +category = "dev" + +[metadata] +lock-version = "2.0" diff --git a/apps/backend/tests/benchmark/fixtures/realistic/real-py-auth-service/manifest.json b/apps/backend/tests/benchmark/fixtures/realistic/real-py-auth-service/manifest.json new file mode 100644 index 00000000..d2e4fbb4 --- /dev/null +++ b/apps/backend/tests/benchmark/fixtures/realistic/real-py-auth-service/manifest.json @@ -0,0 +1,74 @@ +{ + "schemaVersion": "ri-benchmark.v1", + "fixtureId": "real-py-auth-service", + "fixtureClass": "realistic", + "language": "python", + "title": "Realistic Python — a FastAPI auth-guarded service", + "description": "A coherent FastAPI module composing an authentication dependency (Depends()), a service class, a model class, and one guarded route, exercising the #95 evidence-backed authentication explanation end to end.", + "sourceRoot": ".", + "revisionIdentity": "upload-sha256", + "producerVersionSet": ["python-ast@1.1.0", "repository-inventory@1.1.0"], + "constructsCovered": ["py.module", "py.from_import", "py.class.def", "py.function.def", "py.decorator", "py.fastapi_route"], + "deterministic": true, + "expected": { + "nodes": [ + {"nodeKind": "repository", "stableKey": "repo:root", "name": "repository", + "evidence": [{"path": "src/service.py", "startLine": 1, "endLine": 1, "granularity": "span", "extractor": "repository-inventory", "extractorVersion": "1.1.0"}]}, + {"nodeKind": "file", "stableKey": "file:src/service.py", "properties": {"content_sha256": "sha256:2147ef2cc54c77d72f2fde4beb09ff828aa8ff1efd935fc4628d3efdde05ecfd"}, "name": "service.py", "language": "python", "constructs": ["src.file"], + "evidence": [{"path": "src/service.py", "startLine": 1, "endLine": 22, "granularity": "file", "extractor": "repository-inventory", "extractorVersion": "1.1.0"}]}, + {"nodeKind": "module", "stableKey": "mod:src", "name": "src", "constructs": ["py.module"], + "evidence": [{"path": "src/service.py", "startLine": 1, "endLine": 22, "granularity": "file", "extractor": "python-ast", "extractorVersion": "1.1.0"}]}, + {"nodeKind": "symbol", "stableKey": "src/service.py::get_current_user", "name": "get_current_user", "language": "python", "constructs": ["py.function.def"], + "evidence": [{"path": "src/service.py", "startLine": 6, "endLine": 7, "granularity": "span", "extractor": "python-ast", "extractorVersion": "1.1.0"}]}, + {"nodeKind": "symbol", "stableKey": "src/service.py::UserService", "name": "UserService", "language": "python", "constructs": ["py.class.def"], + "evidence": [{"path": "src/service.py", "startLine": 10, "endLine": 12, "granularity": "span", "extractor": "python-ast", "extractorVersion": "1.1.0"}]}, + {"nodeKind": "symbol", "stableKey": "src/service.py::UserService.get_user", "name": "get_user", "language": "python", "constructs": ["py.function.def"], + "evidence": [{"path": "src/service.py", "startLine": 11, "endLine": 12, "granularity": "span", "extractor": "python-ast", "extractorVersion": "1.1.0"}]}, + {"nodeKind": "symbol", "stableKey": "src/service.py::UserModel", "name": "UserModel", "language": "python", "constructs": ["py.class.def"], + "evidence": [{"path": "src/service.py", "startLine": 15, "endLine": 16, "granularity": "span", "extractor": "python-ast", "extractorVersion": "1.1.0"}]}, + {"nodeKind": "symbol", "stableKey": "src/service.py::read_me", "name": "read_me", "language": "python", "properties": {"decorators": ["app.get"]}, "constructs": ["py.function.def", "py.decorator"], + "evidence": [{"path": "src/service.py", "startLine": 20, "endLine": 21, "granularity": "span", "extractor": "python-ast", "extractorVersion": "1.1.0"}]}, + {"nodeKind": "symbol", "stableKey": "src/service.py::(anonymous:route#1)", "name": "route", "language": "python", "properties": {"route_path": "/me"}, "constructs": ["py.fastapi_route"], + "evidence": [{"path": "src/service.py", "startLine": 19, "endLine": 19, "granularity": "span", "extractor": "python-ast", "extractorVersion": "1.1.0"}]} + ], + "edges": [], + "observations": [ + {"observedKind": "import", "subjectKind": "module", "subjectKey": "mod:src", "referentText": "fastapi.FastAPI", "ordinal": 1, "constructs": ["py.from_import"], + "evidence": {"path": "src/service.py", "startLine": 1, "endLine": 1, "granularity": "span", "extractor": "python-ast", "extractorVersion": "1.1.0"}}, + {"observedKind": "import", "subjectKind": "module", "subjectKey": "mod:src", "referentText": "fastapi.Depends", "ordinal": 1, "constructs": ["py.from_import"], + "evidence": {"path": "src/service.py", "startLine": 1, "endLine": 1, "granularity": "span", "extractor": "python-ast", "extractorVersion": "1.1.0"}}, + {"observedKind": "import_binding", "subjectKind": "module", "subjectKey": "mod:src", "referentText": "fastapi|FastAPI|FastAPI", "ordinal": 1, "constructs": ["py.from_import"], + "evidence": {"path": "src/service.py", "startLine": 1, "endLine": 1, "granularity": "span", "extractor": "python-ast", "extractorVersion": "1.1.0"}}, + {"observedKind": "import_binding", "subjectKind": "module", "subjectKey": "mod:src", "referentText": "fastapi|Depends|Depends", "ordinal": 1, "constructs": ["py.from_import"], + "evidence": {"path": "src/service.py", "startLine": 1, "endLine": 1, "granularity": "span", "extractor": "python-ast", "extractorVersion": "1.1.0"}}, + {"observedKind": "definition", "subjectKind": "symbol", "subjectKey": "src/service.py::get_current_user", "ordinal": 1, "constructs": ["py.function.def"], + "evidence": {"path": "src/service.py", "startLine": 6, "endLine": 7, "extractor": "python-ast", "extractorVersion": "1.1.0"}}, + {"observedKind": "definition", "subjectKind": "symbol", "subjectKey": "src/service.py::UserService", "ordinal": 1, "constructs": ["py.class.def"], + "evidence": {"path": "src/service.py", "startLine": 10, "endLine": 12, "extractor": "python-ast", "extractorVersion": "1.1.0"}}, + {"observedKind": "definition", "subjectKind": "symbol", "subjectKey": "src/service.py::UserService.get_user", "ordinal": 1, "constructs": ["py.function.def"], + "evidence": {"path": "src/service.py", "startLine": 11, "endLine": 12, "extractor": "python-ast", "extractorVersion": "1.1.0"}}, + {"observedKind": "definition", "subjectKind": "symbol", "subjectKey": "src/service.py::UserModel", "ordinal": 1, "constructs": ["py.class.def"], + "evidence": {"path": "src/service.py", "startLine": 15, "endLine": 16, "extractor": "python-ast", "extractorVersion": "1.1.0"}}, + {"observedKind": "definition", "subjectKind": "symbol", "subjectKey": "src/service.py::read_me", "ordinal": 1, "constructs": ["py.function.def"], + "evidence": {"path": "src/service.py", "startLine": 20, "endLine": 21, "extractor": "python-ast", "extractorVersion": "1.1.0"}}, + {"observedKind": "decorator", "subjectKind": "symbol", "subjectKey": "src/service.py::read_me", "referentText": "app.get", "ordinal": 1, "constructs": ["py.decorator"], + "evidence": {"path": "src/service.py", "startLine": 19, "endLine": 19, "extractor": "python-ast", "extractorVersion": "1.1.0"}}, + {"observedKind": "route", "subjectKind": "symbol", "subjectKey": "src/service.py::(anonymous:route#1)", "referentText": "/me", "ordinal": 1, "constructs": ["py.fastapi_route"], + "evidence": {"path": "src/service.py", "startLine": 19, "endLine": 19, "granularity": "span", "extractor": "python-ast", "extractorVersion": "1.1.0"}}, + {"observedKind": "route_handler", "subjectKind": "symbol", "subjectKey": "src/service.py::(anonymous:route#1)", "referentText": "src/service.py::read_me", "ordinal": 1, "constructs": ["py.fastapi_route"], + "evidence": {"path": "src/service.py", "startLine": 19, "endLine": 19, "granularity": "span", "extractor": "python-ast", "extractorVersion": "1.1.0"}}, + {"observedKind": "call", "subjectKind": "module", "subjectKey": "mod:src", "referentText": "FastAPI", "ordinal": 1, + "evidence": {"path": "src/service.py", "startLine": 3, "endLine": 3, "extractor": "python-ast", "extractorVersion": "1.1.0"}}, + {"observedKind": "call", "subjectKind": "module", "subjectKey": "mod:src", "referentText": "Depends", "ordinal": 1, + "evidence": {"path": "src/service.py", "startLine": 6, "endLine": 6, "extractor": "python-ast", "extractorVersion": "1.1.0"}}, + {"observedKind": "injects", "subjectKind": "module", "subjectKey": "mod:src", "referentText": "oauth2_scheme", "ordinal": 1, + "evidence": {"path": "src/service.py", "startLine": 6, "endLine": 6, "extractor": "python-ast", "extractorVersion": "1.1.0"}}, + {"observedKind": "call", "subjectKind": "module", "subjectKey": "mod:src", "referentText": "Depends", "ordinal": 1, + "evidence": {"path": "src/service.py", "startLine": 20, "endLine": 20, "extractor": "python-ast", "extractorVersion": "1.1.0"}}, + {"observedKind": "injects", "subjectKind": "module", "subjectKey": "mod:src", "referentText": "get_current_user", "ordinal": 1, + "evidence": {"path": "src/service.py", "startLine": 20, "endLine": 20, "extractor": "python-ast", "extractorVersion": "1.1.0"}} + ], + "assertions": [], + "diagnostics": [] + } +} diff --git a/apps/backend/tests/benchmark/fixtures/realistic/real-py-auth-service/src/service.py b/apps/backend/tests/benchmark/fixtures/realistic/real-py-auth-service/src/service.py new file mode 100644 index 00000000..f3797e8d --- /dev/null +++ b/apps/backend/tests/benchmark/fixtures/realistic/real-py-auth-service/src/service.py @@ -0,0 +1,21 @@ +from fastapi import FastAPI, Depends + +app = FastAPI() + + +def get_current_user(token: str = Depends(oauth2_scheme)): + return token + + +class UserService: + def get_user(self): + pass + + +class UserModel: + pass + + +@app.get("/me") +def read_me(user=Depends(get_current_user)): + return user diff --git a/apps/backend/tests/benchmark/fixtures/realistic/real-py-fastapi/manifest.json b/apps/backend/tests/benchmark/fixtures/realistic/real-py-fastapi/manifest.json new file mode 100644 index 00000000..3d1fd0d7 --- /dev/null +++ b/apps/backend/tests/benchmark/fixtures/realistic/real-py-fastapi/manifest.json @@ -0,0 +1,68 @@ +{ + "schemaVersion": "ri-benchmark.v1", + "fixtureId": "real-py-fastapi", + "fixtureClass": "realistic", + "language": "python", + "title": "Realistic Python — a small FastAPI service", + "description": "A coherent FastAPI module: a from-import, a settings class, a factory function, and two decorated routes, plus a resolved contains edge and an inferred classification assertion. Exercises the full fact model end to end.", + "sourceRoot": ".", + "revisionIdentity": "upload-sha256", + "producerVersionSet": ["python-ast@1.1.0", "relationship-resolver@1.1.0", "repository-inventory@1.1.0", "role-classifier@1.0.0"], + "constructsCovered": ["py.module", "py.from_import", "py.class.def", "py.function.def", "py.decorator", "py.fastapi_route"], + "deterministic": true, + "expected": { + "nodes": [ + {"nodeKind": "repository", "stableKey": "repo:root", "name": "repository", + "evidence": [{"path": "src/service.py", "startLine": 1, "endLine": 1, "granularity": "span", "extractor": "repository-inventory", "extractorVersion": "1.1.0"}]}, + {"nodeKind": "file", "stableKey": "file:src/service.py", "properties": {"content_sha256": "sha256:64216660d12792d3774399cc363487fdd85b372a59690f0013555da5162ad654"}, "name": "service.py", "language": "python", "constructs": ["src.file"], + "evidence": [{"path": "src/service.py", "startLine": 1, "endLine": 22, "granularity": "file", "extractor": "repository-inventory", "extractorVersion": "1.1.0"}]}, + {"nodeKind": "module", "stableKey": "mod:src", "name": "src", "constructs": ["py.module"], + "evidence": [{"path": "src/service.py", "startLine": 1, "endLine": 22, "granularity": "file", "extractor": "python-ast", "extractorVersion": "1.1.0"}]}, + {"nodeKind": "symbol", "stableKey": "src/service.py::Settings", "name": "Settings", "language": "python", "constructs": ["py.class.def"], + "evidence": [{"path": "src/service.py", "startLine": 6, "endLine": 7, "granularity": "span", "extractor": "python-ast", "extractorVersion": "1.1.0"}]}, + {"nodeKind": "symbol", "stableKey": "src/service.py::get_settings", "name": "get_settings", "language": "python", "constructs": ["py.function.def"], + "evidence": [{"path": "src/service.py", "startLine": 10, "endLine": 11, "granularity": "span", "extractor": "python-ast", "extractorVersion": "1.1.0"}]}, + {"nodeKind": "symbol", "stableKey": "src/service.py::read_user", "name": "read_user", "language": "python", "properties": {"decorators": ["app.get"]}, "constructs": ["py.function.def", "py.decorator"], + "evidence": [{"path": "src/service.py", "startLine": 15, "endLine": 16, "granularity": "span", "extractor": "python-ast", "extractorVersion": "1.1.0"}]}, + {"nodeKind": "symbol", "stableKey": "src/service.py::create_user", "name": "create_user", "language": "python", "properties": {"decorators": ["app.post"]}, "constructs": ["py.function.def", "py.decorator"], + "evidence": [{"path": "src/service.py", "startLine": 20, "endLine": 21, "granularity": "span", "extractor": "python-ast", "extractorVersion": "1.1.0"}]}, + {"nodeKind": "symbol", "stableKey": "src/service.py::(anonymous:route#1)", "name": "route", "language": "python", "properties": {"route_path": "/users/{user_id}"}, "constructs": ["py.fastapi_route"], + "evidence": [{"path": "src/service.py", "startLine": 14, "endLine": 14, "extractor": "python-ast", "extractorVersion": "1.1.0"}]}, + {"nodeKind": "symbol", "stableKey": "src/service.py::(anonymous:route#2)", "name": "route", "language": "python", "properties": {"route_path": "/users"}, "constructs": ["py.fastapi_route"], + "evidence": [{"path": "src/service.py", "startLine": 19, "endLine": 19, "extractor": "python-ast", "extractorVersion": "1.1.0"}]} + ], + "edges": [ + {"subjectKind": "repository", "subjectKey": "repo:root", "predicate": "contains", "objectKind": "file", "objectKey": "file:src/service.py", + "producer": "relationship-resolver", "producerVersion": "1.1.0", + "evidence": [{"path": "src/service.py", "startLine": 1, "endLine": 22, "granularity": "file", "extractor": "relationship-resolver", "extractorVersion": "1.1.0"}]} + ], + "observations": [ + {"observedKind": "import", "subjectKind": "module", "subjectKey": "mod:src", "referentText": "fastapi.FastAPI", "ordinal": 1, "constructs": ["py.from_import"], + "evidence": {"path": "src/service.py", "startLine": 1, "endLine": 1, "granularity": "span", "extractor": "python-ast", "extractorVersion": "1.1.0"}}, + {"observedKind": "import_binding", "subjectKind": "module", "subjectKey": "mod:src", "referentText": "fastapi|FastAPI|FastAPI", "ordinal": 1, "constructs": ["py.from_import"], + "evidence": {"path": "src/service.py", "startLine": 1, "endLine": 1, "granularity": "span", "extractor": "python-ast", "extractorVersion": "1.1.0"}}, + {"observedKind":"call","subjectKind":"module","subjectKey":"mod:src","referentText":"FastAPI","ordinal":1,"constructs":["py.from_import"],"evidence":{"path":"src/service.py","startLine":3,"endLine":3,"extractor":"python-ast","extractorVersion":"1.1.0"}}, + {"observedKind":"call","subjectKind":"module","subjectKey":"mod:src","referentText":"Settings","ordinal":1,"constructs":["py.function.def"],"evidence":{"path":"src/service.py","startLine":11,"endLine":11,"extractor":"python-ast","extractorVersion":"1.1.0"}}, + {"observedKind":"definition","subjectKind":"symbol","subjectKey":"src/service.py::Settings","ordinal":1,"constructs":["py.class.def"],"evidence":{"path":"src/service.py","startLine":6,"endLine":7,"extractor":"python-ast","extractorVersion":"1.1.0"}}, + {"observedKind":"definition","subjectKind":"symbol","subjectKey":"src/service.py::get_settings","ordinal":1,"constructs":["py.function.def"],"evidence":{"path":"src/service.py","startLine":10,"endLine":11,"extractor":"python-ast","extractorVersion":"1.1.0"}}, + {"observedKind":"definition","subjectKind":"symbol","subjectKey":"src/service.py::read_user","ordinal":1,"constructs":["py.function.def"],"evidence":{"path":"src/service.py","startLine":15,"endLine":16,"extractor":"python-ast","extractorVersion":"1.1.0"}}, + {"observedKind":"definition","subjectKind":"symbol","subjectKey":"src/service.py::create_user","ordinal":1,"constructs":["py.function.def"],"evidence":{"path":"src/service.py","startLine":20,"endLine":21,"extractor":"python-ast","extractorVersion":"1.1.0"}}, + {"observedKind":"decorator","subjectKind":"symbol","subjectKey":"src/service.py::read_user","referentText":"app.get","ordinal":1,"constructs":["py.decorator"],"evidence":{"path":"src/service.py","startLine":14,"endLine":14,"extractor":"python-ast","extractorVersion":"1.1.0"}}, + {"observedKind":"decorator","subjectKind":"symbol","subjectKey":"src/service.py::create_user","referentText":"app.post","ordinal":1,"constructs":["py.decorator"],"evidence":{"path":"src/service.py","startLine":19,"endLine":19,"extractor":"python-ast","extractorVersion":"1.1.0"}}, + {"observedKind": "route", "subjectKind": "symbol", "subjectKey": "src/service.py::(anonymous:route#1)", "referentText": "/users/{user_id}", "ordinal": 1, "constructs": ["py.fastapi_route"], + "evidence": {"path": "src/service.py", "startLine": 14, "endLine": 14, "granularity": "span", "extractor": "python-ast", "extractorVersion": "1.1.0"}}, + {"observedKind": "route_handler", "subjectKind": "symbol", "subjectKey": "src/service.py::(anonymous:route#1)", "referentText": "src/service.py::read_user", "ordinal": 1, "constructs": ["py.fastapi_route"], + "evidence": {"path": "src/service.py", "startLine": 14, "endLine": 14, "granularity": "span", "extractor": "python-ast", "extractorVersion": "1.1.0"}}, + {"observedKind": "route", "subjectKind": "symbol", "subjectKey": "src/service.py::(anonymous:route#2)", "referentText": "/users", "ordinal": 1, "constructs": ["py.fastapi_route"], + "evidence": {"path": "src/service.py", "startLine": 19, "endLine": 19, "granularity": "span", "extractor": "python-ast", "extractorVersion": "1.1.0"}}, + {"observedKind": "route_handler", "subjectKind": "symbol", "subjectKey": "src/service.py::(anonymous:route#2)", "referentText": "src/service.py::create_user", "ordinal": 1, "constructs": ["py.fastapi_route"], + "evidence": {"path": "src/service.py", "startLine": 19, "endLine": 19, "granularity": "span", "extractor": "python-ast", "extractorVersion": "1.1.0"}} + ], + "assertions": [ + {"subjectKind": "file", "subjectKey": "file:src/service.py", "predicate": "classified_as", + "value": {"classification": "route_module", "confidence": "heuristic"}, + "producer": "role-classifier", "producerVersion": "1.0.0"} + ], + "diagnostics": [] + } +} diff --git a/apps/backend/tests/benchmark/fixtures/realistic/real-py-fastapi/src/service.py b/apps/backend/tests/benchmark/fixtures/realistic/real-py-fastapi/src/service.py new file mode 100644 index 00000000..12957cc4 --- /dev/null +++ b/apps/backend/tests/benchmark/fixtures/realistic/real-py-fastapi/src/service.py @@ -0,0 +1,21 @@ +from fastapi import FastAPI + +app = FastAPI() + + +class Settings: + debug = False + + +def get_settings(): + return Settings() + + +@app.get("/users/{user_id}") +def read_user(user_id: int): + return {"id": user_id} + + +@app.post("/users") +def create_user(name: str): + return {"name": name} diff --git a/apps/backend/tests/benchmark/fixtures/realistic/real-py-http-clients/manifest.json b/apps/backend/tests/benchmark/fixtures/realistic/real-py-http-clients/manifest.json new file mode 100644 index 00000000..2f7a7aff --- /dev/null +++ b/apps/backend/tests/benchmark/fixtures/realistic/real-py-http-clients/manifest.json @@ -0,0 +1,38 @@ +{ + "schemaVersion":"ri-benchmark.v1","fixtureId":"real-py-http-clients","fixtureClass":"realistic","language":"python", + "title":"Realistic Python — outbound HTTP clients","description":"requests and httpx call sites with literal absolute URLs resolve to one service identity per origin, while a variable destination stays a diagnostic.", + "sourceRoot":".","revisionIdentity":"upload-sha256","producerVersionSet":["python-ast@1.1.0","repository-inventory@1.1.0"], + "constructsCovered":["py.module","py.function.def","py.import","py.import_alias","py.http_requests","py.http_httpx","py.http_session","py.http_dynamic"],"deterministic":true, + "expected":{"nodes":[ + {"nodeKind":"repository","stableKey":"repo:root","name":"repository","evidence":[{"path":"src/client.py","startLine":1,"endLine":1,"extractor":"repository-inventory","extractorVersion":"1.1.0"}]}, + {"nodeKind":"file","stableKey":"file:src/client.py","properties":{"content_sha256":"sha256:0842e83695136ea7c429cddaf5d54f62a5945e081d3020c8db5aae6105b1b15e"},"name":"client.py","language":"python","constructs":["src.file"],"evidence":[{"path":"src/client.py","startLine":1,"endLine":21,"granularity":"file","extractor":"repository-inventory","extractorVersion":"1.1.0"}]}, + {"nodeKind":"module","stableKey":"mod:src","name":"src","constructs":["py.module"],"evidence":[{"path":"src/client.py","startLine":1,"endLine":21,"granularity":"file","extractor":"python-ast","extractorVersion":"1.1.0"}]}, + {"nodeKind":"symbol","stableKey":"src/client.py::list_users","name":"list_users","language":"python","constructs":["py.function.def"],"evidence":[{"path":"src/client.py","startLine":7,"endLine":8,"extractor":"python-ast","extractorVersion":"1.1.0"}]}, + {"nodeKind":"symbol","stableKey":"src/client.py::create_user","name":"create_user","language":"python","constructs":["py.function.def"],"evidence":[{"path":"src/client.py","startLine":11,"endLine":12,"extractor":"python-ast","extractorVersion":"1.1.0"}]}, + {"nodeKind":"symbol","stableKey":"src/client.py::delete_user","name":"delete_user","language":"python","constructs":["py.function.def"],"evidence":[{"path":"src/client.py","startLine":15,"endLine":16,"extractor":"python-ast","extractorVersion":"1.1.0"}]}, + {"nodeKind":"symbol","stableKey":"src/client.py::probe","name":"probe","language":"python","constructs":["py.function.def"],"evidence":[{"path":"src/client.py","startLine":19,"endLine":20,"extractor":"python-ast","extractorVersion":"1.1.0"}]}, + + {"comment":"One service node per origin, not per call site: three proven call sites union onto a single identity and contribute three evidence spans.", + "nodeKind":"service","stableKey":"svc:https://api.example.com","name":"https://api.example.com","properties":{"origin":"https://api.example.com"},"constructs":["py.http_requests","py.http_httpx","py.http_session"],"evidence":[ + {"path":"src/client.py","startLine":8,"endLine":8,"extractor":"python-ast","extractorVersion":"1.1.0"}, + {"path":"src/client.py","startLine":12,"endLine":12,"extractor":"python-ast","extractorVersion":"1.1.0"}, + {"path":"src/client.py","startLine":16,"endLine":16,"extractor":"python-ast","extractorVersion":"1.1.0"}]} + ],"edges":[],"observations":[ + {"observedKind":"import","subjectKind":"module","subjectKey":"mod:src","referentText":"httpx","ordinal":1,"constructs":["py.import_alias"],"evidence":{"path":"src/client.py","startLine":1,"endLine":1,"extractor":"python-ast","extractorVersion":"1.1.0"}}, + {"observedKind":"import","subjectKind":"module","subjectKey":"mod:src","referentText":"requests","ordinal":1,"constructs":["py.import"],"evidence":{"path":"src/client.py","startLine":2,"endLine":2,"extractor":"python-ast","extractorVersion":"1.1.0"}}, + {"observedKind":"definition","subjectKind":"symbol","subjectKey":"src/client.py::list_users","ordinal":1,"constructs":["py.function.def"],"evidence":{"path":"src/client.py","startLine":7,"endLine":8,"extractor":"python-ast","extractorVersion":"1.1.0"}}, + {"observedKind":"definition","subjectKind":"symbol","subjectKey":"src/client.py::create_user","ordinal":1,"constructs":["py.function.def"],"evidence":{"path":"src/client.py","startLine":11,"endLine":12,"extractor":"python-ast","extractorVersion":"1.1.0"}}, + {"observedKind":"definition","subjectKind":"symbol","subjectKey":"src/client.py::delete_user","ordinal":1,"constructs":["py.function.def"],"evidence":{"path":"src/client.py","startLine":15,"endLine":16,"extractor":"python-ast","extractorVersion":"1.1.0"}}, + {"observedKind":"definition","subjectKind":"symbol","subjectKey":"src/client.py::probe","ordinal":1,"constructs":["py.function.def"],"evidence":{"path":"src/client.py","startLine":19,"endLine":20,"extractor":"python-ast","extractorVersion":"1.1.0"}}, + + {"comment":"requests.get on a module-level import: method from the attribute, origin and path from the literal URL.", + "observedKind":"http_call","subjectKind":"module","subjectKey":"mod:src","referentText":"GET|https://api.example.com|/v1/users","ordinal":1,"constructs":["py.http_requests"],"evidence":{"path":"src/client.py","startLine":8,"endLine":8,"extractor":"python-ast","extractorVersion":"1.1.0"}}, + {"comment":"httpx reached through the module alias hx.", + "observedKind":"http_call","subjectKind":"module","subjectKey":"mod:src","referentText":"POST|https://api.example.com|/v1/users","ordinal":1,"constructs":["py.http_httpx"],"evidence":{"path":"src/client.py","startLine":12,"endLine":12,"extractor":"python-ast","extractorVersion":"1.1.0"}}, + {"comment":"A session object assigned from requests.Session(), with the method given as a literal first argument to request().", + "observedKind":"http_call","subjectKind":"module","subjectKey":"mod:src","referentText":"DELETE|https://api.example.com|/v1/users/1","ordinal":1,"constructs":["py.http_session"],"evidence":{"path":"src/client.py","startLine":16,"endLine":16,"extractor":"python-ast","extractorVersion":"1.1.0"}} + ],"assertions":[],"diagnostics":[ + {"comment":"probe() passes a parameter as the URL. No service node and no http_call: an unproven destination is disclosed, never guessed.", + "code":"RI-EXT-UNSUPPORTED","category":"unsupported construct","severity":"info","message":"dynamic HTTP destination is unsupported","producer":"python-ast@1.1.0","path":"src/client.py","span":{"startLine":20,"endLine":20},"subject":"file:src/client.py","constructs":["py.http_dynamic"]} + ]} +} diff --git a/apps/backend/tests/benchmark/fixtures/realistic/real-py-http-clients/src/client.py b/apps/backend/tests/benchmark/fixtures/realistic/real-py-http-clients/src/client.py new file mode 100644 index 00000000..fe83db32 --- /dev/null +++ b/apps/backend/tests/benchmark/fixtures/realistic/real-py-http-clients/src/client.py @@ -0,0 +1,20 @@ +import httpx as hx +import requests + +SESSION = requests.Session() + + +def list_users(): + return requests.get("https://api.example.com/v1/users") + + +def create_user(payload): + return hx.post("https://api.example.com/v1/users", json=payload) + + +def delete_user(): + return SESSION.request("DELETE", "https://api.example.com/v1/users/1") + + +def probe(host): + return requests.get(host) diff --git a/apps/backend/tests/benchmark/fixtures/realistic/real-ts-http-clients/manifest.json b/apps/backend/tests/benchmark/fixtures/realistic/real-ts-http-clients/manifest.json new file mode 100644 index 00000000..4bdfe255 --- /dev/null +++ b/apps/backend/tests/benchmark/fixtures/realistic/real-ts-http-clients/manifest.json @@ -0,0 +1,41 @@ +{ + "schemaVersion":"ri-benchmark.v1","fixtureId":"real-ts-http-clients","fixtureClass":"realistic","language":"typescript", + "title":"Realistic TypeScript — fetch and axios call sites","description":"fetch and a default-imported axios client with literal absolute URLs resolve to one service identity, while a template literal with a substitution stays a diagnostic and keeps only its generic call observation.", + "sourceRoot":".","revisionIdentity":"upload-sha256","producerVersionSet":["repository-inventory@1.1.0","typescript-ast@1.2.0"], + "constructsCovered":["ts.module","ts.directory_module","ts.async_function","ts.import","ts.export","ts.http_fetch","ts.http_axios","ts.http_dynamic"],"deterministic":true, + "expected":{"nodes":[ + {"nodeKind":"repository","stableKey":"repo:root","name":"repository","evidence":[{"path":"src/api.ts","startLine":1,"endLine":1,"extractor":"repository-inventory","extractorVersion":"1.1.0"}]}, + {"nodeKind":"file","stableKey":"file:src/api.ts","properties":{"content_sha256":"sha256:7f1e915ddddf2ddacd7184f115fa0c7d92b1a8b41c67f82844fdd76391c67e4f"},"name":"api.ts","language":"typescript","constructs":["ts.module"],"evidence":[{"path":"src/api.ts","startLine":1,"endLine":18,"granularity":"file","extractor":"typescript-ast","extractorVersion":"1.2.0"}]}, + {"nodeKind":"module","stableKey":"mod:src","name":"src","constructs":["ts.directory_module"],"evidence":[{"path":"src/api.ts","startLine":1,"endLine":18,"granularity":"file","extractor":"typescript-ast","extractorVersion":"1.2.0"}]}, + {"nodeKind":"symbol","stableKey":"src/api.ts::listUsers","name":"listUsers","language":"typescript","properties":{"exported":true},"constructs":["ts.async_function","ts.export"],"evidence":[{"path":"src/api.ts","startLine":3,"endLine":5,"extractor":"typescript-ast","extractorVersion":"1.2.0"}]}, + {"nodeKind":"symbol","stableKey":"src/api.ts::createUser","name":"createUser","language":"typescript","properties":{"exported":true},"constructs":["ts.async_function","ts.export"],"evidence":[{"path":"src/api.ts","startLine":7,"endLine":9,"extractor":"typescript-ast","extractorVersion":"1.2.0"}]}, + {"nodeKind":"symbol","stableKey":"src/api.ts::deleteUser","name":"deleteUser","language":"typescript","properties":{"exported":true},"constructs":["ts.async_function","ts.export"],"evidence":[{"path":"src/api.ts","startLine":11,"endLine":13,"extractor":"typescript-ast","extractorVersion":"1.2.0"}]}, + {"nodeKind":"symbol","stableKey":"src/api.ts::probe","name":"probe","language":"typescript","properties":{"exported":true},"constructs":["ts.async_function","ts.export"],"evidence":[{"path":"src/api.ts","startLine":15,"endLine":17,"extractor":"typescript-ast","extractorVersion":"1.2.0"}]}, + + {"comment":"The same origin the Python fixture observes, proving the service identity is language-neutral: no language field, and one node for three call sites.", + "nodeKind":"service","stableKey":"svc:https://api.example.com","name":"https://api.example.com","properties":{"origin":"https://api.example.com"},"constructs":["ts.http_fetch","ts.http_axios"],"evidence":[ + {"path":"src/api.ts","startLine":4,"endLine":4,"extractor":"typescript-ast","extractorVersion":"1.2.0"}, + {"path":"src/api.ts","startLine":8,"endLine":8,"extractor":"typescript-ast","extractorVersion":"1.2.0"}, + {"path":"src/api.ts","startLine":12,"endLine":12,"extractor":"typescript-ast","extractorVersion":"1.2.0"}]} + ],"edges":[],"observations":[ + {"observedKind":"import","subjectKind":"file","subjectKey":"file:src/api.ts","referentText":"axios","ordinal":1,"constructs":["ts.import"],"evidence":{"path":"src/api.ts","startLine":1,"endLine":1,"extractor":"typescript-ast","extractorVersion":"1.2.0"}}, + {"observedKind":"import_binding","subjectKind":"file","subjectKey":"file:src/api.ts","referentText":"axios|default|client","ordinal":1,"constructs":["ts.import"],"evidence":{"path":"src/api.ts","startLine":1,"endLine":1,"extractor":"typescript-ast","extractorVersion":"1.2.0"}}, + {"observedKind":"definition","subjectKind":"symbol","subjectKey":"src/api.ts::listUsers","ordinal":1,"constructs":["ts.async_function"],"evidence":{"path":"src/api.ts","startLine":3,"endLine":5,"extractor":"typescript-ast","extractorVersion":"1.2.0"}}, + {"observedKind":"definition","subjectKind":"symbol","subjectKey":"src/api.ts::createUser","ordinal":1,"constructs":["ts.async_function"],"evidence":{"path":"src/api.ts","startLine":7,"endLine":9,"extractor":"typescript-ast","extractorVersion":"1.2.0"}}, + {"observedKind":"definition","subjectKind":"symbol","subjectKey":"src/api.ts::deleteUser","ordinal":1,"constructs":["ts.async_function"],"evidence":{"path":"src/api.ts","startLine":11,"endLine":13,"extractor":"typescript-ast","extractorVersion":"1.2.0"}}, + {"observedKind":"definition","subjectKind":"symbol","subjectKey":"src/api.ts::probe","ordinal":1,"constructs":["ts.async_function"],"evidence":{"path":"src/api.ts","startLine":15,"endLine":17,"extractor":"typescript-ast","extractorVersion":"1.2.0"}}, + + {"comment":"fetch(input) with no init is a GET by specification, so the method is proven by the call shape rather than assumed.", + "observedKind":"http_call","subjectKind":"file","subjectKey":"file:src/api.ts","referentText":"GET|https://api.example.com|/v1/users","ordinal":1,"constructs":["ts.http_fetch"],"evidence":{"path":"src/api.ts","startLine":4,"endLine":4,"extractor":"typescript-ast","extractorVersion":"1.2.0"}}, + {"comment":"A literal method in an inline init object overrides the GET default.", + "observedKind":"http_call","subjectKind":"file","subjectKey":"file:src/api.ts","referentText":"POST|https://api.example.com|/v1/users","ordinal":1,"constructs":["ts.http_fetch"],"evidence":{"path":"src/api.ts","startLine":8,"endLine":8,"extractor":"typescript-ast","extractorVersion":"1.2.0"}}, + {"comment":"axios reached through the local name its default import bound.", + "observedKind":"http_call","subjectKind":"file","subjectKey":"file:src/api.ts","referentText":"DELETE|https://api.example.com|/v1/users/1","ordinal":1,"constructs":["ts.http_axios"],"evidence":{"path":"src/api.ts","startLine":12,"endLine":12,"extractor":"typescript-ast","extractorVersion":"1.2.0"}}, + + {"comment":"Only the unproven call site keeps a generic call observation. The three proven fetch/axios sites above emit an http_call instead, so no line is counted twice.", + "observedKind":"call","subjectKind":"file","subjectKey":"file:src/api.ts","referentText":"fetch","ordinal":1,"constructs":["ts.http_dynamic"],"evidence":{"path":"src/api.ts","startLine":16,"endLine":16,"extractor":"typescript-ast","extractorVersion":"1.2.0"}} + ],"assertions":[],"diagnostics":[ + {"comment":"A template literal with a substitution is not a literal destination.", + "code":"RI-EXT-UNSUPPORTED","category":"unsupported construct","severity":"info","message":"dynamic HTTP destination is unsupported","producer":"typescript-ast@1.2.0","path":"src/api.ts","span":{"startLine":16,"endLine":16},"subject":"file:src/api.ts","constructs":["ts.http_dynamic"]} + ]} +} diff --git a/apps/backend/tests/benchmark/fixtures/realistic/real-ts-http-clients/src/api.ts b/apps/backend/tests/benchmark/fixtures/realistic/real-ts-http-clients/src/api.ts new file mode 100644 index 00000000..9702298b --- /dev/null +++ b/apps/backend/tests/benchmark/fixtures/realistic/real-ts-http-clients/src/api.ts @@ -0,0 +1,17 @@ +import client from "axios"; + +export async function listUsers() { + return fetch("https://api.example.com/v1/users"); +} + +export async function createUser(body: string) { + return fetch("https://api.example.com/v1/users", { method: "POST", body }); +} + +export async function deleteUser() { + return client.delete("https://api.example.com/v1/users/1"); +} + +export async function probe(host: string) { + return fetch(`https://${host}/v1`); +} diff --git a/apps/backend/tests/benchmark/fixtures/realistic/real-ts-service/manifest.json b/apps/backend/tests/benchmark/fixtures/realistic/real-ts-service/manifest.json new file mode 100644 index 00000000..819d46eb --- /dev/null +++ b/apps/backend/tests/benchmark/fixtures/realistic/real-ts-service/manifest.json @@ -0,0 +1,37 @@ +{ + "schemaVersion": "ri-benchmark.v1", + "fixtureId": "real-ts-service", + "fixtureClass": "realistic", + "language": "typescript", + "title": "Realistic TypeScript — a small HTTP service", + "description": "A coherent TypeScript module: an import, an exported function, and an exported binding. Exercises ts.module, ts.import, ts.function, and ts.export.", + "sourceRoot": ".", + "revisionIdentity": "upload-sha256", + "producerVersionSet": ["repository-inventory@1.1.0", "typescript-ast@1.2.0"], + "constructsCovered": ["ts.module", "ts.import", "ts.function", "ts.export"], + "deterministic": true, + "expected": { + "nodes": [ + {"nodeKind": "repository", "stableKey": "repo:root", "name": "repository", + "evidence": [{"path": "src/server.ts", "startLine": 1, "endLine": 1, "granularity": "span", "extractor": "repository-inventory", "extractorVersion": "1.1.0"}]}, + {"nodeKind": "file", "stableKey": "file:src/server.ts", "properties": {"content_sha256": "sha256:2dbc043ace77dbe3f24c938d53a1fba85cbf02fdb417f1591b0fd1cc370fee22"}, "name": "server.ts", "language": "typescript", "constructs": ["ts.module"], + "evidence": [{"path": "src/server.ts", "startLine": 1, "endLine": 8, "granularity": "file", "extractor": "typescript-ast", "extractorVersion": "1.2.0"}]}, + {"nodeKind":"module","stableKey":"mod:src","name":"src","constructs":["ts.directory_module"],"evidence":[{"path":"src/server.ts","startLine":1,"endLine":8,"granularity":"file","extractor":"typescript-ast","extractorVersion":"1.2.0"}]}, + {"nodeKind": "symbol", "stableKey": "src/server.ts::start", "name": "start", "language": "typescript", "properties": {"exported": true}, "constructs": ["ts.function", "ts.export"], + "evidence": [{"path": "src/server.ts", "startLine": 3, "endLine": 5, "granularity": "span", "extractor": "typescript-ast", "extractorVersion": "1.2.0"}]} + ,{"nodeKind":"symbol","stableKey":"src/server.ts::server","name":"server","language":"typescript","properties":{"exported":true},"constructs":["ts.const","ts.export"],"evidence":[{"path":"src/server.ts","startLine":7,"endLine":7,"extractor":"typescript-ast","extractorVersion":"1.2.0"}]} + ], + "edges": [], + "observations": [ + {"observedKind": "import", "subjectKind": "file", "subjectKey": "file:src/server.ts", "referentText": "http", "ordinal": 1, "constructs": ["ts.import"], + "evidence": {"path": "src/server.ts", "startLine": 1, "endLine": 1, "granularity": "span", "extractor": "typescript-ast", "extractorVersion": "1.2.0"}}, + {"observedKind": "import_binding", "subjectKind": "file", "subjectKey": "file:src/server.ts", "referentText": "http|createServer|createServer", "ordinal": 1, "constructs": ["ts.import"], + "evidence": {"path": "src/server.ts", "startLine": 1, "endLine": 1, "granularity": "span", "extractor": "typescript-ast", "extractorVersion": "1.2.0"}}, + {"observedKind": "call", "subjectKind": "file", "subjectKey": "file:src/server.ts", "referentText": "createServer", "ordinal": 1, "constructs": ["ts.function"], + "evidence": {"path": "src/server.ts", "startLine": 4, "endLine": 4, "granularity": "span", "extractor": "typescript-ast", "extractorVersion": "1.2.0"}}, + {"observedKind":"definition","subjectKind":"symbol","subjectKey":"src/server.ts::start","ordinal":1,"constructs":["ts.function","ts.export"],"evidence":{"path":"src/server.ts","startLine":3,"endLine":5,"extractor":"typescript-ast","extractorVersion":"1.2.0"}}, + {"observedKind":"definition","subjectKind":"symbol","subjectKey":"src/server.ts::server","ordinal":1,"constructs":["ts.const","ts.export"],"evidence":{"path":"src/server.ts","startLine":7,"endLine":7,"extractor":"typescript-ast","extractorVersion":"1.2.0"}} + ], + "assertions": [], "diagnostics": [] + } +} diff --git a/apps/backend/tests/benchmark/fixtures/realistic/real-ts-service/src/server.ts b/apps/backend/tests/benchmark/fixtures/realistic/real-ts-service/src/server.ts new file mode 100644 index 00000000..2e80df4a --- /dev/null +++ b/apps/backend/tests/benchmark/fixtures/realistic/real-ts-service/src/server.ts @@ -0,0 +1,7 @@ +import { createServer } from "http"; + +export function start(port: number): void { + createServer().listen(port); +} + +export const server = { start }; diff --git a/apps/backend/tests/benchmark/loader.py b/apps/backend/tests/benchmark/loader.py new file mode 100644 index 00000000..1cc6e68e --- /dev/null +++ b/apps/backend/tests/benchmark/loader.py @@ -0,0 +1,553 @@ +"""Strict, deterministic loading and validation of the fixture corpus. + +The loader is the benchmark's integrity gate. It refuses to load anything it +cannot fully validate against the merged #86 evidence contract, so a malformed +or dishonest fixture fails loudly instead of silently degrading a score. It +fails clearly on every condition Issue #94 enumerates: + +unsupported schema versions; duplicate fixture ids; duplicate expected +identities; missing source files; absolute paths; ``..`` escapes; invalid line +ranges; undeclared benchmark construct ids; malformed expected facts; +unsupported languages; inconsistent producer versions; facts missing mandatory +evidence; and accidental machine-blessed output committed as source truth. + +Golden facts are *loaded and checked* here, never generated: there is no +"bless current output" path. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from fractions import Fraction +from pathlib import Path +from typing import Any, Mapping + +from app.extraction.support_matrix import ( + CAPABILITIES_BY_ID, + CONSTRUCT_CAPABILITIES, + SupportStatus, + validate_registry, +) +from app.intelligence import canonical + +from benchmark import schema +from benchmark.facts import EvidenceSpan, Fact, canonical_value +from benchmark.sourcefiles import ( + SourceDecodeError, + decode_strict_utf8, + is_binary, + logical_line_count, +) + + +class ManifestError(ValueError): + """A fixture, capability mapping, or thresholds file failed strict validation.""" + + +_BENCHMARK_LANGUAGE_BY_PREFIX = { + "py": "python", + "src": "source", + "ts": "typescript", +} +_CROSS_LANGUAGE_SOURCE_CONSTRUCTS = {"file", "malformed-source", "repository"} + + +def _benchmark_language(construct_id: str, *, path: Path) -> str: + prefix, separator, _ = construct_id.partition(".") + language = _BENCHMARK_LANGUAGE_BY_PREFIX.get(prefix) + if not separator or language is None: + raise ManifestError(f"{path}: construct {construct_id!r} has an unsupported benchmark id prefix") + return language + + +# --------------------------------------------------------------------------- +# Support matrix and thresholds +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class ConstructSpec: + construct_id: str + language: str + supported: bool + description: str + expected_diagnostic: str | None + matrix_language: str + matrix_construct: str + capability_id: str + + +@dataclass(frozen=True) +class SupportMatrix: + constructs: dict[str, ConstructSpec] + note: str = "" + + def __contains__(self, construct_id: str) -> bool: + return construct_id in self.constructs + + def supported_ids(self) -> list[str]: + return sorted(cid for cid, spec in self.constructs.items() if spec.supported) + + +@dataclass(frozen=True) +class Thresholds: + precision: Fraction + recall: Fraction + provenance_validity: Fraction + determinism: Fraction + + +def _read_json(path: Path) -> Any: + if not path.is_file(): + raise ManifestError(f"missing required file: {path}") + try: + return json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + raise ManifestError(f"{path}: invalid JSON: {exc}") from exc + + +def load_support_matrix(path: Path) -> SupportMatrix: + data = _read_json(path) + if data.get("schemaVersion") != schema.SUPPORT_MATRIX_SCHEMA_VERSION: + raise ManifestError(f"{path}: unsupported capability-mapping schema version {data.get('schemaVersion')!r}") + constructs: dict[str, ConstructSpec] = {} + raw = data.get("constructs") + if not isinstance(raw, dict) or not raw: + raise ManifestError(f"{path}: 'constructs' must be a non-empty object") + mappings = data.get("productionMappings") + if not isinstance(mappings, dict) or set(mappings) != set(raw): + raise ManifestError(f"{path}: productionMappings must map every benchmark construct exactly once") + try: + validate_registry() + except ValueError as exc: + raise ManifestError(f"{path}: invalid production capability registry: {exc}") from exc + covered_capabilities: set[str] = set() + for construct_id, spec in sorted(raw.items()): + if not isinstance(spec, dict) or not isinstance(spec.get("description"), str) or not spec["description"]: + raise ManifestError(f"{path}: construct {construct_id!r} must have a description") + capability_id = mappings[construct_id] + if not isinstance(capability_id, str): + raise ManifestError(f"{path}: construct {construct_id!r} mapping must be a capability id string") + capability = CAPABILITIES_BY_ID.get(capability_id) + if capability is None: + raise ManifestError( + f"{path}: construct {construct_id!r} maps to unknown production capability {capability_id!r}" + ) + if construct_id not in capability.benchmark_ids: + raise ManifestError( + f"{path}: construct {construct_id!r} is not declared by production capability {capability_id!r}" + ) + supported = capability.status == SupportStatus.SUPPORTED + expected_diagnostic = capability.expected_diagnostic + if expected_diagnostic is not None and expected_diagnostic not in schema.DIAGNOSTIC_CODES: + raise ManifestError(f"{path}: capability {capability_id!r} has unknown diagnostic {expected_diagnostic!r}") + covered_capabilities.add(capability_id) + constructs[construct_id] = ConstructSpec( + construct_id=construct_id, + language=_benchmark_language(construct_id, path=path), + supported=supported, + description=str(spec.get("description", "")), + expected_diagnostic=expected_diagnostic, + matrix_language=capability.language, + matrix_construct=capability.construct, + capability_id=capability.id, + ) + missing_benchmark_ids = sorted( + benchmark_id + for capability in CONSTRUCT_CAPABILITIES + for benchmark_id in capability.benchmark_ids + if benchmark_id not in raw + ) + if missing_benchmark_ids: + raise ManifestError( + f"{path}: production capability benchmark ids have no benchmark mapping: {missing_benchmark_ids}" + ) + missing_capabilities = sorted( + capability.id for capability in CONSTRUCT_CAPABILITIES if capability.id not in covered_capabilities + ) + if missing_capabilities: + raise ManifestError(f"{path}: production capabilities have no benchmark mapping: {missing_capabilities}") + return SupportMatrix(constructs=constructs, note=str(data.get("note", ""))) + + +def _fraction(value: Any, *, where: str) -> Fraction: + try: + return Fraction(str(value)) + except (ValueError, ZeroDivisionError) as exc: + raise ManifestError(f"{where}: invalid threshold value {value!r}") from exc + + +def load_thresholds(path: Path) -> Thresholds: + data = _read_json(path) + if data.get("schemaVersion") != schema.THRESHOLDS_SCHEMA_VERSION: + raise ManifestError(f"{path}: unsupported thresholds schema version {data.get('schemaVersion')!r}") + return Thresholds( + precision=_fraction(data.get("precision"), where=f"{path} precision"), + recall=_fraction(data.get("recall"), where=f"{path} recall"), + provenance_validity=_fraction(data.get("provenanceValidity"), where=f"{path} provenanceValidity"), + determinism=_fraction(data.get("determinism"), where=f"{path} determinism"), + ) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class ExpectedFact: + """One loaded expected fact: the comparable :class:`Fact` plus provenance metadata.""" + + group: str + fact: Fact + constructs: tuple[str, ...] + raw: dict[str, Any] + + +def _fixture_source_files( + directory: Path, + synthetic_files: tuple[tuple[str, bytes], ...] = (), +) -> dict[str, bytes]: + """Return physical and manifest-declared source bytes in stable path order.""" + + files: dict[str, bytes] = {} + for path in sorted(directory.rglob("*")): + if not path.is_file() or path.name == "manifest.json": + continue + relative = path.relative_to(directory).as_posix() + files[relative] = path.read_bytes() + for path, content in synthetic_files: + files[path] = content + return {path: files[path] for path in sorted(files)} + + +@dataclass(frozen=True) +class LoadedFixture: + fixture_id: str + fixture_class: str + language: str + title: str + description: str + directory: Path + source_root: str + revision_identity: str + producer_version_set: tuple[str, ...] + constructs_covered: tuple[str, ...] + deterministic: bool + expected: tuple[ExpectedFact, ...] + max_source_bytes: int = 512 * 1024 + synthetic_files: tuple[tuple[str, bytes], ...] = () + + def source_files(self) -> dict[str, bytes]: + """Every stored byte of the synthetic repository (everything but the manifest).""" + + return _fixture_source_files(self.directory, self.synthetic_files) + + def revision_value(self) -> str: + """A real, reproducible ``sha256:`` upload identity over the stored bytes. + + Content-addressed, not a fabricated Git SHA: the SHA-256 over the sorted + ``{path: sha256(bytes)}`` map of the synthetic repository (RFC-0001 §3.2 + upload identity). + """ + + digest_map = {path: canonical.sha256_hex(data) for path, data in self.source_files().items()} + return canonical.sha256_prefixed(canonical.canonical_json_bytes(digest_map)) + + +def _require(condition: bool, message: str) -> None: + if not condition: + raise ManifestError(message) + + +def _evidence_span(raw: dict[str, Any], *, where: str, producers: set[str]) -> EvidenceSpan: + for required in ("path", "startLine", "endLine", "extractor", "extractorVersion"): + _require(required in raw, f"{where}: evidence missing required field {required!r}") + extractor = str(raw["extractor"]) + version = str(raw["extractorVersion"]) + _require( + f"{extractor}@{version}" in producers, + f"{where}: evidence producer {extractor}@{version!r} is not in producerVersionSet", + ) + try: + path = canonical.normalize_repo_path(str(raw["path"])) + except canonical.PathEscapeError as exc: + raise ManifestError(f"{where}: evidence path {raw['path']!r} is absolute or escapes root ({exc})") from exc + _require(bool(path), f"{where}: evidence path must be repository-relative and non-empty") + start, end = raw["startLine"], raw["endLine"] + _require(isinstance(start, int) and isinstance(end, int), f"{where}: line numbers must be integers") + granularity = raw.get("granularity", "span") + _require(granularity in ("span", "file"), f"{where}: granularity must be 'span' or 'file'") + return EvidenceSpan(path, start, end, extractor, version, granularity) + + +def _build_fact(group: str, raw: dict[str, Any], *, where: str, producers: set[str]) -> Fact: + if group == "nodes": + node_kind = str(raw.get("nodeKind", "")) + _require(bool(node_kind), f"{where}: node missing 'nodeKind'") + try: + stable_key = canonical.normalize_stable_key(node_kind, str(raw.get("stableKey", ""))) + except (canonical.CanonicalizationError, canonical.PathEscapeError) as exc: + raise ManifestError(f"{where}: invalid node stableKey ({exc})") from exc + evidence = tuple(_evidence_span(item, where=where, producers=producers) for item in raw.get("evidence", [])) + _require(len(evidence) >= 1, f"{where}: observed node {stable_key!r} must carry >=1 evidence record") + return Fact( + fact_type="node", + kind=node_kind, + subject=stable_key, + name=str(raw.get("name", "")), + language=str(raw.get("language", "")), + truth_class="observed", + value=canonical_value(raw.get("properties")) if raw.get("properties") is not None else "", + evidence=evidence, + ) + if group == "edges": + predicate = canonical.validate_predicate(str(raw.get("predicate", ""))) + subject = canonical.normalize_stable_key(str(raw["subjectKind"]), str(raw["subjectKey"])) + obj = canonical.normalize_stable_key(str(raw["objectKind"]), str(raw["objectKey"])) + evidence = tuple(_evidence_span(item, where=where, producers=producers) for item in raw.get("evidence", [])) + _require(len(evidence) >= 1, f"{where}: edge must carry >=1 evidence record") + producer = f"{raw.get('producer', '')}@{raw.get('producerVersion', '')}" + _require(producer in producers, f"{where}: edge producer {producer!r} not in producerVersionSet") + return Fact( + fact_type="edge", + kind=predicate, + subject=subject, + object=obj, + predicate=predicate, + truth_class="resolved", + evidence=evidence, + ) + if group == "observations": + observed_kind = canonical.validate_predicate(str(raw.get("observedKind", ""))) + subject = canonical.normalize_stable_key(str(raw["subjectKind"]), str(raw["subjectKey"])) + span = _evidence_span(raw["evidence"], where=where, producers=producers) + return Fact( + fact_type="observation", + kind=observed_kind, + subject=subject, + predicate=observed_kind, + referent=str(raw.get("referentText", "")), + ordinal=int(raw.get("ordinal", 1)), + evidence=(span,), + ) + if group == "assertions": + predicate = canonical.validate_predicate(str(raw.get("predicate", ""))) + subject = canonical.normalize_stable_key(str(raw["subjectKind"]), str(raw["subjectKey"])) + producer = f"{raw.get('producer', '')}@{raw.get('producerVersion', '')}" + _require(producer in producers, f"{where}: assertion producer {producer!r} not in producerVersionSet") + return Fact( + fact_type="assertion", + kind=predicate, + subject=subject, + predicate=predicate, + truth_class="inferred", + value=canonical_value(raw.get("value", {})), + ) + if group == "diagnostics": + code = str(raw.get("code", "")) + _require(code in schema.DIAGNOSTIC_CODES, f"{where}: unknown diagnostic code {code!r}") + severity = str(raw.get("severity", "")) + _require(severity in schema.DIAGNOSTIC_SEVERITIES, f"{where}: invalid severity {severity!r}") + producer = str(raw.get("producer", "")) + _require(producer in producers, f"{where}: diagnostic producer {producer!r} not in producerVersionSet") + path = raw.get("path") + normalized_path = None + if path is not None: + try: + normalized_path = canonical.normalize_repo_path(str(path)) + except canonical.PathEscapeError as exc: + raise ManifestError(f"{where}: diagnostic path {path!r} escapes root ({exc})") from exc + span = raw.get("span") + if span is not None: + _require( + isinstance(span.get("startLine"), int) and isinstance(span.get("endLine"), int), + f"{where}: diagnostic span lines must be integers", + ) + _require( + 1 <= span["startLine"] <= span["endLine"], + f"{where}: diagnostic span must be one-based and inclusive", + ) + location = canonical_value( + { + "details": raw.get("details"), + "path": normalized_path, + "span": {"startLine": span["startLine"], "endLine": span["endLine"]} if span else None, + } + ) + return Fact( + fact_type="diagnostic", + kind=code, + subject=str(raw.get("subject", "")), + object=str(raw.get("object", "")), + severity=severity, + category=str(raw.get("category", "")), + message=str(raw.get("message", "")), + producer=producer, + value=location, + ) + raise ManifestError(f"{where}: unknown fact group {group!r}") + + +def _validate_evidence_against_source(sources: Mapping[str, bytes], span: EvidenceSpan, *, where: str) -> None: + """Enforce RFC-0001 §6.2: the cited file exists, decodes, and the span is in range.""" + + data = sources.get(span.path) + _require(data is not None, f"{where}: evidence cites missing source file {span.path!r}") + _require(not is_binary(data), f"{where}: evidence cites binary file {span.path!r} (no line spans allowed)") + try: + text = decode_strict_utf8(data) + except SourceDecodeError as exc: + raise ManifestError(f"{where}: evidence cites non-UTF-8 file {span.path!r} ({exc})") from exc + line_count = logical_line_count(text) + _require(span.start_line >= 1, f"{where}: start_line must be >= 1") + _require(span.end_line >= span.start_line, f"{where}: end_line must be >= start_line") + _require( + span.end_line <= line_count, + f"{where}: end_line {span.end_line} exceeds logical_line_count {line_count} of {span.path!r}", + ) + if span.granularity == "file": + _require( + span.start_line == 1 and span.end_line == line_count, + f"{where}: file-granularity evidence must span 1..{line_count} of {span.path!r}", + ) + + +def _load_synthetic_files(data: dict[str, Any], *, where: str, directory: Path) -> tuple[tuple[str, bytes], ...]: + """Validate manifest-declared UTF-8 sources without normalizing their raw paths.""" + + raw_files = data.get(schema.SYNTHETIC_FILES_FIELD, {}) + _require( + isinstance(raw_files, dict), + f"{where}: {schema.SYNTHETIC_FILES_FIELD} must be an object mapping paths to UTF-8 content", + ) + physical_paths = { + path.relative_to(directory).as_posix() + for path in directory.rglob("*") + if path.is_file() and path.name != "manifest.json" + } + synthetic_files: list[tuple[str, bytes]] = [] + for raw_path, content in sorted(raw_files.items()): + _require( + isinstance(raw_path, str) and raw_path and raw_path != "manifest.json", + f"{where}: synthetic source path must be a non-empty non-manifest string", + ) + _require(isinstance(content, str), f"{where}: synthetic source {raw_path!r} must be UTF-8 text") + _require(raw_path not in physical_paths, f"{where}: synthetic source {raw_path!r} duplicates a stored file") + try: + encoded = content.encode("utf-8") + except UnicodeEncodeError as exc: + raise ManifestError(f"{where}: synthetic source {raw_path!r} is not UTF-8 encodable") from exc + synthetic_files.append((raw_path, encoded)) + return tuple(synthetic_files) + + +def load_fixture(directory: Path, support_matrix: SupportMatrix) -> LoadedFixture: + manifest_path = directory / "manifest.json" + data = _read_json(manifest_path) + where0 = str(manifest_path) + + if data.get("schemaVersion") != schema.FIXTURE_SCHEMA_VERSION: + raise ManifestError(f"{where0}: unsupported fixture schema version {data.get('schemaVersion')!r}") + for forbidden in schema.FORBIDDEN_BLESS_KEYS: + if data.get(forbidden): + raise ManifestError(f"{where0}: golden facts must be hand-authored; forbidden key {forbidden!r} present") + + fixture_id = str(data.get("fixtureId", "")) + _require(bool(fixture_id), f"{where0}: missing 'fixtureId'") + fixture_class = data.get("fixtureClass") + _require(fixture_class in schema.FIXTURE_CLASSES, f"{where0}: invalid fixtureClass {fixture_class!r}") + language = data.get("language") + _require(language in schema.LANGUAGES, f"{where0}: unsupported language {language!r}") + revision_identity = data.get("revisionIdentity") + _require( + revision_identity in schema.REVISION_IDENTITY_METHODS, + f"{where0}: unsupported revisionIdentity {revision_identity!r}", + ) + + producer_list = data.get("producerVersionSet") + _require(isinstance(producer_list, list) and bool(producer_list), f"{where0}: producerVersionSet must be non-empty") + for producer in producer_list: + _require( + isinstance(producer, str) + and "@" in producer + and not producer.startswith("@") + and not producer.endswith("@"), + f"{where0}: producerVersionSet entry {producer!r} must be 'name@version'", + ) + producers = set(producer_list) + synthetic_files = _load_synthetic_files(data, where=where0, directory=directory) + sources = _fixture_source_files(directory, synthetic_files) + + constructs_covered = tuple(data.get("constructsCovered", [])) + max_source_bytes = data.get("maxSourceBytes", 512 * 1024) + _require( + isinstance(max_source_bytes, int) and max_source_bytes >= 1, + f"{where0}: maxSourceBytes must be a positive integer", + ) + for construct_id in constructs_covered: + _require(construct_id in support_matrix, f"{where0}: undeclared benchmark construct {construct_id!r}") + spec = support_matrix.constructs[construct_id] + _require( + spec.language == language + or language == "mixed" + or (spec.language == "source" and spec.matrix_construct in _CROSS_LANGUAGE_SOURCE_CONSTRUCTS), + f"{where0}: construct {construct_id!r} language mismatch with fixture language {language!r}", + ) + + expected: list[ExpectedFact] = [] + seen_identities: set[tuple] = set() + raw_expected = data.get("expected", {}) + for group in schema.FACT_GROUPS: + for index, raw in enumerate(raw_expected.get(group, [])): + where = f"{where0} expected.{group}[{index}]" + _require(isinstance(raw, dict), f"{where}: fact must be an object") + fact = _build_fact(group, raw, where=where, producers=producers) + identity = fact.key() + _require(identity not in seen_identities, f"{where}: duplicate expected identity") + seen_identities.add(identity) + fact_constructs = tuple(raw.get("constructs", [])) + for construct_id in fact_constructs: + _require(construct_id in support_matrix, f"{where}: undeclared construct {construct_id!r}") + # Provenance: every cited span must resolve in the stored revision. + for span in fact.evidence: + _validate_evidence_against_source(sources, span, where=where) + expected.append(ExpectedFact(group=group, fact=fact, constructs=fact_constructs, raw=raw)) + + # Fixtures must actually declare something to measure. + _require(bool(expected), f"{where0}: fixture declares no expected facts") + + return LoadedFixture( + fixture_id=fixture_id, + fixture_class=fixture_class, + language=language, + title=str(data.get("title", fixture_id)), + description=str(data.get("description", "")), + directory=directory, + source_root=str(data.get("sourceRoot", ".")), + revision_identity=revision_identity, + producer_version_set=tuple(producer_list), + constructs_covered=constructs_covered, + deterministic=bool(data.get("deterministic", False)), + expected=tuple(expected), + max_source_bytes=max_source_bytes, + synthetic_files=synthetic_files, + ) + + +def load_corpus(fixtures_dir: Path, support_matrix: SupportMatrix) -> list[LoadedFixture]: + """Load every fixture under ``fixtures_dir`` in deterministic id order.""" + + if not fixtures_dir.is_dir(): + raise ManifestError(f"missing fixtures directory: {fixtures_dir}") + fixtures: list[LoadedFixture] = [] + seen_ids: set[str] = set() + for manifest_path in sorted(fixtures_dir.rglob("manifest.json")): + fixture = load_fixture(manifest_path.parent, support_matrix) + if fixture.fixture_id in seen_ids: + raise ManifestError(f"duplicate fixture id {fixture.fixture_id!r} at {manifest_path}") + seen_ids.add(fixture.fixture_id) + fixtures.append(fixture) + fixtures.sort(key=lambda fixture: fixture.fixture_id) + return fixtures diff --git a/apps/backend/tests/benchmark/paths.py b/apps/backend/tests/benchmark/paths.py new file mode 100644 index 00000000..25a564b0 --- /dev/null +++ b/apps/backend/tests/benchmark/paths.py @@ -0,0 +1,11 @@ +"""Canonical on-disk locations for the benchmark corpus and configuration.""" + +from __future__ import annotations + +from pathlib import Path + +PACKAGE_DIR = Path(__file__).resolve().parent +CONFIG_DIR = PACKAGE_DIR / "config" +FIXTURES_DIR = PACKAGE_DIR / "fixtures" +SUPPORT_MATRIX_PATH = CONFIG_DIR / "benchmark_support_matrix.json" +THRESHOLDS_PATH = CONFIG_DIR / "thresholds.json" diff --git a/apps/backend/tests/benchmark/provenance.py b/apps/backend/tests/benchmark/provenance.py new file mode 100644 index 00000000..41d96b9d --- /dev/null +++ b/apps/backend/tests/benchmark/provenance.py @@ -0,0 +1,124 @@ +"""Citation / provenance validation against the stored fixture revision. + +For every fact that carries evidence, this independently re-derives the RFC-0001 +§6.2 checks from the *stored bytes* — it does not trust the loader or the fact +model. The same validator gates both the independently authored golden corpus and +every citation emitted by the real extractors. + +A citation is valid iff: + +- its path normalizes to a repository-relative POSIX path that cannot escape the + revision root (RFC §4.2); +- the file exists in the fixture's stored revision and is strict-UTF-8 text + (not binary, not malformed); +- ``1 <= start_line <= end_line <= logical_line_count`` where + ``logical_line_count = 1 + count(U+000A)`` (RFC §6.2); +- file-granularity evidence spans exactly ``1..logical_line_count``; +- the extractor name and version are non-empty and declared in the snapshot's + producer set. + +Provenance validity is ``valid_citations / total_citations`` and the benchmark +gate requires it to be exactly ``1``. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from fractions import Fraction + +from app.intelligence import canonical + +from benchmark.facts import EvidenceSpan, Fact +from benchmark.loader import LoadedFixture +from benchmark.sourcefiles import SourceDecodeError, decode_strict_utf8, is_binary, logical_line_count + + +@dataclass(frozen=True) +class CitationCheck: + fixture_id: str + subject: str + path: str + start_line: int + end_line: int + granularity: str + valid: bool + reason: str = "" + + +@dataclass +class ProvenanceResult: + checks: list[CitationCheck] = field(default_factory=list) + + @property + def total(self) -> int: + return len(self.checks) + + @property + def invalid(self) -> list[CitationCheck]: + return [check for check in self.checks if not check.valid] + + @property + def valid_count(self) -> int: + return self.total - len(self.invalid) + + @property + def validity(self) -> Fraction: + # No citations is vacuously valid (nothing wrong was emitted). + return Fraction(1) if self.total == 0 else Fraction(self.valid_count, self.total) + + +def _check_span( + fixture: LoadedFixture, + subject: str, + span: EvidenceSpan, + producer_set: set[str], +) -> CitationCheck: + def result(valid: bool, reason: str = "") -> CitationCheck: + return CitationCheck( + fixture.fixture_id, subject, span.path, span.start_line, span.end_line, span.granularity, valid, reason + ) + + if not span.extractor or not span.extractor_version: + return result(False, "extractor name/version must be non-empty") + if f"{span.extractor}@{span.extractor_version}" not in producer_set: + return result(False, f"undeclared producer {span.extractor}@{span.extractor_version}") + try: + normalized = canonical.normalize_repo_path(span.path) + except canonical.PathEscapeError as exc: + return result(False, f"path escapes revision root: {exc}") + if normalized != span.path or not normalized: + return result(False, "path is not in normalized repository-relative form") + + source = fixture.directory / normalized + if not source.is_file(): + return result(False, "cited file does not exist in the stored revision") + data = source.read_bytes() + if is_binary(data): + return result(False, "cited file is binary (no line spans permitted)") + try: + text = decode_strict_utf8(data) + except SourceDecodeError: + return result(False, "cited file is not strict UTF-8") + line_count = logical_line_count(text) + if not (1 <= span.start_line <= span.end_line <= line_count): + return result(False, f"span {span.start_line}..{span.end_line} outside 1..{line_count}") + if span.granularity == "file" and not (span.start_line == 1 and span.end_line == line_count): + return result(False, f"file-granularity span must be 1..{line_count}") + return result(True) + + +def validate_fixture(fixture: LoadedFixture, facts: list[Fact]) -> ProvenanceResult: + """Validate every citation carried by ``facts`` against ``fixture``'s revision.""" + + producer_set = set(fixture.producer_version_set) + result = ProvenanceResult() + for fact in facts: + for span in fact.evidence: + result.checks.append(_check_span(fixture, fact.subject or fact.kind, span, producer_set)) + return result + + +def validate_expected(fixture: LoadedFixture) -> ProvenanceResult: + """Convenience: validate the fixture's own golden citations.""" + + return validate_fixture(fixture, [expected.fact for expected in fixture.expected]) diff --git a/apps/backend/tests/benchmark/report.py b/apps/backend/tests/benchmark/report.py new file mode 100644 index 00000000..1a72934d --- /dev/null +++ b/apps/backend/tests/benchmark/report.py @@ -0,0 +1,382 @@ +"""Deterministic, human- and machine-readable benchmark reports (Issue #94 §F). + +Renders a :class:`~benchmark.runner.BenchmarkReport` to Markdown and JSON with +sorted, stable content and no absolute paths, secrets, or repository source, so +the output is safe to publish in CI logs and diff across runs. +""" + +from __future__ import annotations + +import json +from fractions import Fraction +from pathlib import Path + +from benchmark.runner import BenchmarkReport +from benchmark.scorer import Counts, LabeledFact + + +def _ratio(value: Fraction) -> str: + return f"{float(value):.4f}" + + +def _counts_dict(counts: Counts) -> dict[str, object]: + return { + "truePositives": counts.true_positives, + "falsePositives": counts.false_positives, + "falseNegatives": counts.false_negatives, + "precision": _ratio(counts.precision), + "recall": _ratio(counts.recall), + } + + +def _citation_dict(report) -> dict[str, object]: + return { + "total": report.total, + "valid": report.valid_count, + "validity": _ratio(report.validity), + "invalid": [ + { + "fixtureId": check.fixture_id, + "subject": check.subject, + "path": check.path, + "startLine": check.start_line, + "endLine": check.end_line, + "reason": check.reason, + } + for check in report.invalid + ], + } + + +def _fact_dict(item: LabeledFact) -> dict[str, object]: + return { + "fixtureId": item.fixture_id, + "fixtureClass": item.fixture_class, + "language": item.language, + "factType": item.fact.fact_type, + "kind": item.fact.kind, + "subject": item.fact.subject, + "key": repr(item.fact.key()), + } + + +def to_json_dict(report: BenchmarkReport) -> dict[str, object]: + scoring: dict[str, object] + if not report.scoring.available or report.scoring.report is None: + scoring = { + "status": "unavailable", + "reason": "The real extraction adapter did not produce measurements.", + } + else: + score_report = report.scoring.report + scoring = { + "status": "scored", + "overall": _counts_dict(score_report.overall), + "byLanguage": {k: _counts_dict(v) for k, v in sorted(score_report.by_language.items())}, + "byClass": {k: _counts_dict(v) for k, v in sorted(score_report.by_class.items())}, + "falsePositives": [_fact_dict(item) for item in score_report.false_positives], + "falseNegatives": [_fact_dict(item) for item in score_report.false_negatives], + "realExtractorProvenance": ( + _citation_dict(report.scoring.actual_provenance) + if report.scoring.actual_provenance is not None + else None + ), + } + return { + "dataVersion": report.data_version, + "result": "pass" if report.passed else "fail", + "thresholds": { + "precision": _ratio(report.thresholds.precision), + "recall": _ratio(report.thresholds.recall), + "provenanceValidity": _ratio(report.thresholds.provenance_validity), + "determinism": _ratio(report.thresholds.determinism), + }, + "supportMatrixNote": report.support_matrix_note, + "corpus": { + "total": report.corpus.total, + "byClass": report.corpus.by_class, + "byLanguage": report.corpus.by_language, + }, + "fixtures": [ + { + "fixtureId": fixture.fixture_id, + "fixtureClass": fixture.fixture_class, + "language": fixture.language, + "revisionValue": fixture.revision_value(), + "producerVersionSet": list(fixture.producer_version_set), + "constructsCovered": list(fixture.constructs_covered), + "expectedFactCount": len(fixture.expected), + "deterministic": fixture.deterministic, + } + for fixture in report.fixtures + ], + "goldenFixtureProvenance": _citation_dict(report.provenance), + "determinism": [ + { + "fixtureId": r.fixture_id, + "deterministic": r.deterministic, + "sealedHashA": r.sealed_hash_a, + "sealedHashB": r.sealed_hash_b, + "pureHashA": r.pure_hash_a, + "pureHashB": r.pure_hash_b, + } + for r in report.determinism + ], + "supportMatrixParity": { + "passed": report.parity.passed, + "supportedConstructs": report.parity.supported_constructs, + "coveredConstructs": report.parity.covered_constructs, + "uncoveredSupported": report.parity.uncovered_supported, + "uncoveredUnsupported": report.parity.uncovered_unsupported, + }, + "requiredDiagnostics": {"passed": report.diagnostics.passed, "missing": report.diagnostics.missing}, + "goldenRegression": { + "passed": report.golden_regression_passed, + "unexpectedFactCount": len(report.scoring.report.false_positives) if report.scoring.report else None, + "missingFactCount": len(report.scoring.report.false_negatives) if report.scoring.report else None, + }, + "scoring": scoring, + "failures": report.failures(), + } + + +def to_json(report: BenchmarkReport) -> str: + return json.dumps(to_json_dict(report), indent=2, sort_keys=True) + "\n" + + +def _table(header: list[str], rows: list[list[str]]) -> list[str]: + lines = ["| " + " | ".join(header) + " |", "| " + " | ".join("---" for _ in header) + " |"] + lines.extend("| " + " | ".join(row) + " |" for row in rows) + return lines + + +def _fact_label(item: LabeledFact) -> str: + fact = item.fact + identity = fact.subject or fact.referent or fact.object or fact.name or "(no subject)" + return f"`{item.fixture_id}` · `{fact.fact_type}:{fact.kind}` · `{identity}`" + + +def to_markdown(report: BenchmarkReport) -> str: + result = "✅ PASS" if report.passed else "❌ FAIL" + lines: list[str] = [ + "# Repository Intelligence Golden Benchmark", + "", + f"**Result:** {result} ", + f"**Benchmark data version:** `{report.data_version}` ", + f"**Corpus:** {report.corpus.total} fixtures " + f"({', '.join(f'{k}: {v}' for k, v in report.corpus.by_class.items())})", + "", + "## Thresholds", + "", + *_table( + ["Metric", "Threshold", "Enforced now?"], + [ + ["precision", _ratio(report.thresholds.precision), "yes"], + ["recall", _ratio(report.thresholds.recall), "yes"], + ["provenance validity", _ratio(report.thresholds.provenance_validity), "yes"], + ["determinism", _ratio(report.thresholds.determinism), "yes"], + ], + ), + "", + "## Golden fixture citation validity", + "", + f"{report.provenance.valid_count} / {report.provenance.total} citations valid " + f"(validity **{_ratio(report.provenance.validity)}**).", + ] + if report.provenance.invalid: + lines.append("") + lines.extend( + _table( + ["Fixture", "Subject", "Path", "Span", "Reason"], + [ + [c.fixture_id, c.subject, c.path, f"{c.start_line}..{c.end_line}", c.reason] + for c in report.provenance.invalid + ], + ) + ) + + lines += ["", "## Real-extraction determinism", ""] + if report.determinism: + lines.extend( + _table( + ["Fixture", "Deterministic", "Sealed hash A", "Sealed hash B"], + [ + [ + r.fixture_id, + "yes" if r.deterministic else "**NO**", + f"`{r.sealed_hash_a}`", + f"`{r.sealed_hash_b}`", + ] + for r in report.determinism + ], + ) + ) + else: + lines.append("_No deterministic fixtures declared._") + + lines += ["", "## Support-matrix parity", ""] + lines.append(f"Parity: {'✅' if report.parity.passed else '❌'} ") + lines.append( + f"Covered {len(report.parity.covered_constructs)} constructs; " + f"{len(report.parity.uncovered_supported)} supported uncovered; " + f"{len(report.parity.uncovered_unsupported)} unsupported unexercised." + ) + + lines += ["", "## Exact golden regression gate", ""] + if report.scoring.report is None: + lines.append("**Unavailable:** no real extraction comparison was produced.") + else: + lines.append( + f"Exact committed-golden comparison: **{'pass' if report.golden_regression_passed else 'fail'}** " + f"({len(report.scoring.report.false_positives)} unexpected, " + f"{len(report.scoring.report.false_negatives)} missing facts)." + ) + + lines += ["", "## Extraction quality (precision / recall)", ""] + if not report.scoring.available or report.scoring.report is None: + lines.append("**Unavailable:** the real extraction adapter did not produce measurements.") + else: + score_report = report.scoring.report + overall = score_report.overall + lines.extend( + _table( + ["Scope", "TP", "FP", "FN", "Precision", "Recall"], + [ + [ + "overall", + str(overall.true_positives), + str(overall.false_positives), + str(overall.false_negatives), + _ratio(overall.precision), + _ratio(overall.recall), + ] + ] + + [ + [ + f"lang:{lang}", + str(c.true_positives), + str(c.false_positives), + str(c.false_negatives), + _ratio(c.precision), + _ratio(c.recall), + ] + for lang, c in sorted(score_report.by_language.items()) + ] + + [ + [ + f"class:{fixture_class}", + str(c.true_positives), + str(c.false_positives), + str(c.false_negatives), + _ratio(c.precision), + _ratio(c.recall), + ] + for fixture_class, c in sorted(score_report.by_class.items()) + ], + ) + ) + actual_provenance = report.scoring.actual_provenance + lines += ["", "## Real extractor citation validity", ""] + if actual_provenance is None: + lines.append("**Unavailable:** no real-extractor citation validation result was produced.") + else: + lines.append( + f"{actual_provenance.valid_count} / {actual_provenance.total} citations valid " + f"(validity **{_ratio(actual_provenance.validity)}**)." + ) + if actual_provenance.invalid: + lines.extend( + [ + "", + *_table( + ["Fixture", "Subject", "Path", "Span", "Reason"], + [ + [c.fixture_id, c.subject, c.path, f"{c.start_line}..{c.end_line}", c.reason] + for c in actual_provenance.invalid + ], + ), + ] + ) + + lines += ["", "## False positives", ""] + if score_report.false_positives: + lines.extend(f"- {_fact_label(item)}" for item in score_report.false_positives) + else: + lines.append("_None._") + lines += ["", "## False negatives", ""] + if score_report.false_negatives: + lines.extend(f"- {_fact_label(item)}" for item in score_report.false_negatives) + else: + lines.append("_None._") + + failures = report.failures() + lines += ["", "## Failures", ""] + if failures: + lines.extend(f"- {reason}" for reason in failures) + else: + lines.append("_None._") + lines.append("") + return "\n".join(lines) + + +def to_step_summary(report: BenchmarkReport) -> str: + status = "PASS ✅" if report.passed else "FAIL ❌" + if report.scoring.report is None: + scoring = "unavailable" + else: + overall = report.scoring.report.overall + scoring = f"precision {_ratio(overall.precision)}, recall {_ratio(overall.recall)}" + actual_provenance = report.scoring.actual_provenance + actual_citations = ( + f"{_ratio(actual_provenance.validity)} ({actual_provenance.valid_count}/{actual_provenance.total})" + if actual_provenance is not None + else "unavailable" + ) + return ( + f"### RI Golden Benchmark: {status}\n\n" + f"- Fixtures: {report.corpus.total}\n" + f"- Golden fixture citation validity: {_ratio(report.provenance.validity)} " + f"({report.provenance.valid_count}/{report.provenance.total})\n" + f"- Real extractor citation validity: {actual_citations}\n" + f"- Real-extraction determinism: " + f"{sum(1 for r in report.determinism if r.deterministic)}/{len(report.determinism)} stable\n" + f"- Support-matrix parity: {'pass' if report.parity.passed else 'fail'}\n" + f"- Precision/recall: {scoring}\n" + ) + + +def to_console_summary(report: BenchmarkReport) -> str: + """Render the step summary with ASCII-only status markers for local consoles.""" + + status = "PASS" if report.passed else "FAIL" + if report.scoring.report is None: + scoring = "unavailable" + else: + overall = report.scoring.report.overall + scoring = f"precision {_ratio(overall.precision)}, recall {_ratio(overall.recall)}" + actual_provenance = report.scoring.actual_provenance + actual_citations = ( + f"{_ratio(actual_provenance.validity)} ({actual_provenance.valid_count}/{actual_provenance.total})" + if actual_provenance is not None + else "unavailable" + ) + return ( + f"RI Golden Benchmark: {status}\n" + f"- Fixtures: {report.corpus.total}\n" + f"- Golden fixture citation validity: {_ratio(report.provenance.validity)} " + f"({report.provenance.valid_count}/{report.provenance.total})\n" + f"- Real extractor citation validity: {actual_citations}\n" + f"- Real-extraction determinism: " + f"{sum(1 for r in report.determinism if r.deterministic)}/{len(report.determinism)} stable\n" + f"- Support-matrix parity: {'pass' if report.parity.passed else 'fail'}\n" + f"- Precision/recall: {scoring}\n" + ) + + +def write_reports(report: BenchmarkReport, directory: Path) -> tuple[Path, Path]: + directory.mkdir(parents=True, exist_ok=True) + json_path = directory / "benchmark.json" + markdown_path = directory / "benchmark.md" + json_path.write_text(to_json(report), encoding="utf-8") + markdown_path.write_text(to_markdown(report), encoding="utf-8") + return json_path, markdown_path diff --git a/apps/backend/tests/benchmark/run.py b/apps/backend/tests/benchmark/run.py new file mode 100644 index 00000000..f48adbdc --- /dev/null +++ b/apps/backend/tests/benchmark/run.py @@ -0,0 +1,61 @@ +"""Standalone entry point for the Repository Intelligence golden benchmark. + +Run from ``apps/backend`` (CI does exactly this):: + + python tests/benchmark/run.py --report-dir "$RUNNER_TEMP/ri-benchmark" + +It writes ``benchmark.json`` and ``benchmark.md`` to the report directory, +optionally appends a summary to ``$GITHUB_STEP_SUMMARY``, and exits non-zero when +any enforced gate fails. Reports are written to a caller-supplied (temporary / +git-ignored) directory and are never committed. + +This launcher fixes ``sys.path`` so it works whether or not pytest configured +it: it adds the backend root (for ``app``) and the tests root (for the +``benchmark`` package). +""" + +from __future__ import annotations + +import argparse +import os +import sys +from pathlib import Path + +_TESTS_ROOT = Path(__file__).resolve().parents[1] # apps/backend/tests +_BACKEND_ROOT = Path(__file__).resolve().parents[2] # apps/backend +for _entry in (str(_BACKEND_ROOT), str(_TESTS_ROOT)): + if _entry not in sys.path: + sys.path.insert(0, _entry) + +from benchmark import report as report_module # noqa: E402 +from benchmark import runner # noqa: E402 + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Run the Repository Intelligence golden benchmark.") + parser.add_argument("--report-dir", type=Path, default=None, help="Directory for benchmark.json / benchmark.md.") + parser.add_argument("--step-summary-file", type=Path, default=None, help="Optional file to append a summary to.") + args = parser.parse_args(argv) + + result = runner.run() + + if args.report_dir is not None: + json_path, markdown_path = report_module.write_reports(result, args.report_dir) + print(f"Wrote {json_path}") + print(f"Wrote {markdown_path}") + + summary_target = args.step_summary_file or ( + Path(os.environ["GITHUB_STEP_SUMMARY"]) if os.environ.get("GITHUB_STEP_SUMMARY") else None + ) + if summary_target is not None: + with summary_target.open("a", encoding="utf-8") as handle: + handle.write(report_module.to_step_summary(result)) + + print(report_module.to_console_summary(result)) + for reason in result.failures(): + print(f" - {reason}") + return result.exit_code + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/apps/backend/tests/benchmark/runner.py b/apps/backend/tests/benchmark/runner.py new file mode 100644 index 00000000..a9fdd5b1 --- /dev/null +++ b/apps/backend/tests/benchmark/runner.py @@ -0,0 +1,337 @@ +"""Benchmark orchestration, gating, and exit-code policy (Issue #94 §F). + +:func:`run` executes every stage over the committed corpus and returns a +:class:`BenchmarkReport` whose :meth:`~BenchmarkReport.exit_code` is non-zero +when any enforceable gate fails: + +- a manifest is invalid; +- provenance validity is below threshold; +- determinism fails; +- the fixture/capability-mapping parity check fails; +- an expected (required) diagnostic is missing for a declared blind spot; +- the real extraction adapter is unavailable; +- precision or recall is below threshold; or +- any citation emitted by the real extractors is invalid. +""" + +from __future__ import annotations + +import tempfile +from dataclasses import dataclass, field +from pathlib import Path + +from benchmark import paths +from benchmark.adapter import ExtractionAdapter, default_adapter +from benchmark.determinism import DeterminismResult, check_fixture +from benchmark.facts import Fact +from benchmark.loader import ( + LoadedFixture, + ManifestError, + SupportMatrix, + Thresholds, + load_corpus, + load_support_matrix, + load_thresholds, +) +from benchmark.provenance import ProvenanceResult, validate_expected, validate_fixture +from benchmark.scorer import LabeledFact, ScoreReport, score + +BENCHMARK_DATA_VERSION = "ri-benchmark.v1" + + +@dataclass +class CorpusSummary: + total: int + by_class: dict[str, int] + by_language: dict[str, int] + + +@dataclass +class ParityResult: + supported_constructs: list[str] + covered_constructs: list[str] + uncovered_supported: list[str] + uncovered_unsupported: list[str] + + @property + def passed(self) -> bool: + return not self.uncovered_supported and not self.uncovered_unsupported + + +@dataclass +class DiagnosticsCheck: + missing: list[str] = field(default_factory=list) + + @property + def passed(self) -> bool: + return not self.missing + + +@dataclass +class ScoringOutcome: + available: bool + report: ScoreReport | None = None + actual_provenance: ProvenanceResult | None = None + + +@dataclass +class BenchmarkReport: + data_version: str + thresholds: Thresholds + support_matrix_note: str + corpus: CorpusSummary + fixtures: list[LoadedFixture] + provenance: ProvenanceResult + determinism: list[DeterminismResult] + parity: ParityResult + diagnostics: DiagnosticsCheck + scoring: ScoringOutcome + load_error: str | None = None + + @property + def provenance_passed(self) -> bool: + return self.provenance.validity >= self.thresholds.provenance_validity + + @property + def determinism_passed(self) -> bool: + return all(result.deterministic for result in self.determinism) + + @property + def scoring_gate_passed(self) -> bool: + """Require real measurements and enforce every extraction-quality gate.""" + + if not self.scoring.available or self.scoring.report is None: + return False + overall = self.scoring.report.overall + provenance_ok = ( + self.scoring.actual_provenance is not None + and self.scoring.actual_provenance.validity >= self.thresholds.provenance_validity + ) + return ( + overall.precision >= self.thresholds.precision + and overall.recall >= self.thresholds.recall + and provenance_ok + ) + + @property + def golden_regression_passed(self) -> bool: + """Require an exact committed-golden match independent of thresholds. + + Thresholds remain useful quality summaries, but a small semantic drift + must not pass merely because aggregate precision/recall stays high. + An intentional change is therefore visible as a reviewed expectation + diff and a corresponding exact-golden baseline change. + """ + + if not self.scoring.available or self.scoring.report is None: + return False + return not self.scoring.report.false_positives and not self.scoring.report.false_negatives + + @property + def passed(self) -> bool: + if self.load_error is not None: + return False + return ( + self.provenance_passed + and self.determinism_passed + and self.parity.passed + and self.diagnostics.passed + and self.golden_regression_passed + and self.scoring_gate_passed + ) + + @property + def exit_code(self) -> int: + return 0 if self.passed else 1 + + def failures(self) -> list[str]: + reasons: list[str] = [] + if self.load_error is not None: + reasons.append(f"corpus failed to load: {self.load_error}") + return reasons + if not self.provenance_passed: + for check in self.provenance.invalid: + reasons.append(f"provenance: {check.fixture_id} {check.subject} {check.path} — {check.reason}") + if not self.determinism_passed: + for result in self.determinism: + if not result.deterministic: + reasons.append( + f"determinism: {result.fixture_id} sealed {result.sealed_hash_a} != {result.sealed_hash_b}" + ) + for construct in self.parity.uncovered_supported: + reasons.append(f"parity: supported construct {construct!r} is not covered by any fixture") + for construct in self.parity.uncovered_unsupported: + reasons.append(f"parity: unsupported construct {construct!r} is not exercised by any fixture") + reasons.extend(f"diagnostics: {item}" for item in self.diagnostics.missing) + if not self.golden_regression_passed and self.scoring.report is not None: + reasons.append( + "golden regression: exact committed expectations changed " + f"({len(self.scoring.report.false_positives)} unexpected, " + f"{len(self.scoring.report.false_negatives)} missing facts)" + ) + if not self.scoring.available or self.scoring.report is None: + reasons.append("scoring: the real extraction adapter did not produce measurements") + elif not self.scoring_gate_passed: + overall = self.scoring.report.overall + if overall.precision < self.thresholds.precision: + reasons.append( + f"scoring: precision {float(overall.precision):.4f} is below {float(self.thresholds.precision):.4f}" + ) + if overall.recall < self.thresholds.recall: + reasons.append( + f"scoring: recall {float(overall.recall):.4f} is below {float(self.thresholds.recall):.4f}" + ) + if self.scoring.actual_provenance is None: + reasons.append("real extractor provenance: no citation validation result was produced") + else: + for check in self.scoring.actual_provenance.invalid: + reasons.append( + f"real extractor provenance: {check.fixture_id} {check.subject} {check.path} — {check.reason}" + ) + return reasons + + +def _corpus_summary(fixtures: list[LoadedFixture]) -> CorpusSummary: + by_class: dict[str, int] = {} + by_language: dict[str, int] = {} + for fixture in fixtures: + by_class[fixture.fixture_class] = by_class.get(fixture.fixture_class, 0) + 1 + by_language[fixture.language] = by_language.get(fixture.language, 0) + 1 + return CorpusSummary( + total=len(fixtures), by_class=dict(sorted(by_class.items())), by_language=dict(sorted(by_language.items())) + ) + + +def _covered_constructs(fixtures: list[LoadedFixture]) -> set[str]: + covered: set[str] = set() + for fixture in fixtures: + covered.update(fixture.constructs_covered) + for expected in fixture.expected: + covered.update(expected.constructs) + return covered + + +def _parity(fixtures: list[LoadedFixture], support_matrix: SupportMatrix) -> ParityResult: + covered = _covered_constructs(fixtures) + supported = set(support_matrix.supported_ids()) + unsupported = {cid for cid, spec in support_matrix.constructs.items() if not spec.supported} + return ParityResult( + supported_constructs=sorted(supported), + covered_constructs=sorted(covered), + uncovered_supported=sorted(supported - covered), + uncovered_unsupported=sorted(unsupported - covered), + ) + + +def _diagnostics_check(fixtures: list[LoadedFixture], support_matrix: SupportMatrix) -> DiagnosticsCheck: + """Every unsupported construct with a required diagnostic must have a fixture + that declares that construct and emits a diagnostic with the required code.""" + + declared: set[tuple[str, str]] = set() + for fixture in fixtures: + for expected in fixture.expected: + if expected.group == "diagnostics": + for construct in expected.constructs: + declared.add((construct, expected.fact.kind)) + missing: list[str] = [] + for cid, spec in sorted(support_matrix.constructs.items()): + if spec.supported or spec.expected_diagnostic is None: + continue + if (cid, spec.expected_diagnostic) not in declared: + missing.append( + f"unsupported construct {cid!r} requires a {spec.expected_diagnostic} diagnostic in some fixture" + ) + return DiagnosticsCheck(missing=missing) + + +def _labeled(fixture: LoadedFixture, fact: Fact, constructs: tuple[str, ...] = ()) -> LabeledFact: + return LabeledFact( + fact=fact, + language=fixture.language, + fixture_class=fixture.fixture_class, + fixture_id=fixture.fixture_id, + constructs=constructs, + ) + + +def _run_scoring(fixtures: list[LoadedFixture], adapter: ExtractionAdapter) -> ScoringOutcome: + if not adapter.available: + return ScoringOutcome(available=False) + expected: list[LabeledFact] = [] + actual: list[LabeledFact] = [] + provenance = ProvenanceResult() + scored_fact_types = getattr(adapter, "scored_fact_types", None) + for fixture in fixtures: + for record in fixture.expected: + if scored_fact_types is not None and record.fact.fact_type not in scored_fact_types: + continue + expected.append(_labeled(fixture, record.fact, record.constructs)) + emitted = adapter.extract(fixture) + for fact in emitted: + actual.append(_labeled(fixture, fact)) + provenance.checks.extend(validate_fixture(fixture, emitted).checks) + return ScoringOutcome(available=True, report=score(expected, actual), actual_provenance=provenance) + + +def run( + *, + fixtures_dir: Path = paths.FIXTURES_DIR, + support_matrix_path: Path = paths.SUPPORT_MATRIX_PATH, + thresholds_path: Path = paths.THRESHOLDS_PATH, + adapter: ExtractionAdapter | None = None, + determinism_tmp: Path | None = None, +) -> BenchmarkReport: + """Run the full benchmark and return a gated :class:`BenchmarkReport`.""" + + adapter = adapter or default_adapter() + thresholds = load_thresholds(thresholds_path) + + try: + support_matrix = load_support_matrix(support_matrix_path) + fixtures = load_corpus(fixtures_dir, support_matrix) + except ManifestError as exc: + return BenchmarkReport( + data_version=BENCHMARK_DATA_VERSION, + thresholds=thresholds, + support_matrix_note="Support-matrix or corpus validation failed.", + corpus=CorpusSummary(0, {}, {}), + fixtures=[], + provenance=ProvenanceResult(), + determinism=[], + parity=ParityResult([], [], [], []), + diagnostics=DiagnosticsCheck(), + scoring=ScoringOutcome(available=False), + load_error=str(exc), + ) + + provenance = ProvenanceResult() + for fixture in fixtures: + provenance.checks.extend(validate_expected(fixture).checks) + + determinism: list[DeterminismResult] = [] + deterministic_fixtures = [fixture for fixture in fixtures if fixture.deterministic] + if deterministic_fixtures: + tmp_context = None + base = determinism_tmp + if base is None: + tmp_context = tempfile.TemporaryDirectory(prefix="ri-benchmark-det-") + base = Path(tmp_context.name) + try: + for index, fixture in enumerate(deterministic_fixtures): + determinism.append(check_fixture(fixture, base / f"det-{index}.db")) + finally: + if tmp_context is not None: + tmp_context.cleanup() + + return BenchmarkReport( + data_version=BENCHMARK_DATA_VERSION, + thresholds=thresholds, + support_matrix_note=support_matrix.note, + corpus=_corpus_summary(fixtures), + fixtures=fixtures, + provenance=provenance, + determinism=determinism, + parity=_parity(fixtures, support_matrix), + diagnostics=_diagnostics_check(fixtures, support_matrix), + scoring=_run_scoring(fixtures, adapter), + ) diff --git a/apps/backend/tests/benchmark/schema.py b/apps/backend/tests/benchmark/schema.py new file mode 100644 index 00000000..10f156be --- /dev/null +++ b/apps/backend/tests/benchmark/schema.py @@ -0,0 +1,53 @@ +"""Versioned schema constants for the benchmark fixture corpus. + +Everything version- or vocabulary-bound lives here so a schema bump is a single, +reviewable change and the loader never carries magic strings inline. +""" + +from __future__ import annotations + +# Fixture manifest schema. Bumping this is a deliberate, reviewed migration. +FIXTURE_SCHEMA_VERSION = "ri-benchmark.v1" +SUPPORT_MATRIX_SCHEMA_VERSION = "ri-benchmark-support-matrix.v2" +THRESHOLDS_SCHEMA_VERSION = "ri-benchmark-thresholds.v1" + +FIXTURE_CLASSES = ("minimal", "realistic", "adversarial") +LANGUAGES = ("python", "typescript", "mixed") + +# Only synthetic upload bundles are supported today; a Git revision identity +# would require a real committed revision (RFC-0001 §3.2). The corpus is +# content-addressed by the SHA-256 of its stored bytes. +REVISION_IDENTITY_METHODS = ("upload-sha256",) + +# ``ri.v1`` output kinds the manifest may declare under ``expected``. +FACT_GROUPS = ("nodes", "edges", "observations", "assertions", "diagnostics") + +# Optional UTF-8 source entries injected into a fixture's stored revision. This +# permits adversarial raw paths that a host filesystem cannot materialize (for +# example a Windows-invalid backslash path) without weakening source-policy +# coverage or making the repository un-checkout-able on that host. +SYNTHETIC_FILES_FIELD = "syntheticFiles" + +# RFC-0001 §8.2 diagnostic codes (the ``ri.v1`` baseline set). +DIAGNOSTIC_CODES = frozenset( + { + "RI-EXT-UNSUPPORTED", + "RI-RES-AMBIGUOUS", + "RI-RES-UNRESOLVED", + "RI-EXT-FAILURE", + "RI-SPAN-INVALID", + "RI-KEY-COLLISION", + "RI-KEY-DUP-SYMBOL", + "RI-SRC-BINARY", + "RI-SRC-MALFORMED", + "RI-LIMIT-SKIP", + "RI-SEC-PATH-ESCAPE", + "RI-INT-FAILURE", + } +) +DIAGNOSTIC_SEVERITIES = frozenset({"fatal", "error", "warning", "info"}) + +# A regeneration helper may validate/format hand-reviewed data, but golden truth +# must never be machine-blessed. A manifest carrying either of these truthy keys +# is rejected by the loader so an "update snapshots" workflow cannot slip in. +FORBIDDEN_BLESS_KEYS = ("blessed", "generated", "autoBlessed") diff --git a/apps/backend/tests/benchmark/scorer.py b/apps/backend/tests/benchmark/scorer.py new file mode 100644 index 00000000..8c0c059f --- /dev/null +++ b/apps/backend/tests/benchmark/scorer.py @@ -0,0 +1,129 @@ +"""Precision / recall scoring over the benchmark fact model. + +The scorer compares two collections of :class:`~benchmark.facts.Fact` — the +independently authored ``expected`` (golden) facts and the ``actual`` facts an +extractor emitted — by their exact semantic identity (:meth:`Fact.key`). It +reports true positives, false positives, false negatives, precision and recall, +aggregate and per-language / per-fixture-class (Issue #94 "Report both aggregate +and per-language / per-fixture-class metrics"). + +Design decisions that matter for correctness: + +- **Multiset matching.** Counting is multiset-aware, so a fact emitted twice + when it was expected once scores one true positive and one false positive + (a duplicate is a real defect, not a free pass). +- **Fixture-scoped matching.** Identity maps use ``(fixture_id, Fact.key())`` so + identical repository-relative paths in separate fixtures cannot cross-match. +- **Wrong span ⇒ miss.** Because the evidence span set is part of the identity, + a right-named fact with a wrong line span is one false negative (the expected + span is unmet) and one false positive (the wrong span was invented). +- **Zero denominators are explicit.** ``precision = 1`` when nothing was emitted + (``tp + fp == 0``) and ``recall = 1`` when nothing was expected + (``tp + fn == 0``). A fixture with *no* expected facts therefore cannot + manufacture a perfect precision if the extractor invents facts: those are + false positives, ``tp + fp > 0``, and precision drops. +- **Threshold comparisons are exact.** Gate checks use :class:`fractions.Fraction` + cross-multiplication, never lossy float ``>=``. +""" + +from __future__ import annotations + +from collections import defaultdict +from dataclasses import dataclass, field +from fractions import Fraction + +from benchmark.facts import Fact + + +@dataclass(frozen=True) +class LabeledFact: + """A fact tagged with the fixture dimensions used for per-dimension metrics.""" + + fact: Fact + language: str + fixture_class: str + fixture_id: str + constructs: tuple[str, ...] = () + + +@dataclass(frozen=True) +class Counts: + """True/false positive/negative counts with derived precision and recall.""" + + true_positives: int = 0 + false_positives: int = 0 + false_negatives: int = 0 + + @property + def precision(self) -> Fraction: + denominator = self.true_positives + self.false_positives + if denominator == 0: + return Fraction(1) + return Fraction(self.true_positives, denominator) + + @property + def recall(self) -> Fraction: + denominator = self.true_positives + self.false_negatives + if denominator == 0: + return Fraction(1) + return Fraction(self.true_positives, denominator) + + +@dataclass +class ScoreReport: + overall: Counts + by_language: dict[str, Counts] = field(default_factory=dict) + by_class: dict[str, Counts] = field(default_factory=dict) + true_positives: list[LabeledFact] = field(default_factory=list) + false_positives: list[LabeledFact] = field(default_factory=list) + false_negatives: list[LabeledFact] = field(default_factory=list) + + +def _counts_by(dimension, positives_expected, positives_actual, negatives_expected) -> dict[str, Counts]: + """Build per-dimension counts. TP/FN are attributed to the expected side, + FP to the actual side, so a dimension label always comes from a real fact.""" + + tp: dict[str, int] = defaultdict(int) + fp: dict[str, int] = defaultdict(int) + fn: dict[str, int] = defaultdict(int) + for labeled in positives_expected: + tp[dimension(labeled)] += 1 + for labeled in positives_actual: + fp[dimension(labeled)] += 1 + for labeled in negatives_expected: + fn[dimension(labeled)] += 1 + keys = sorted(set(tp) | set(fp) | set(fn)) + return {key: Counts(tp[key], fp[key], fn[key]) for key in keys} + + +def score(expected: list[LabeledFact], actual: list[LabeledFact]) -> ScoreReport: + """Score ``actual`` against ``expected`` and return a :class:`ScoreReport`.""" + + expected_by_key: dict[tuple, list[LabeledFact]] = defaultdict(list) + actual_by_key: dict[tuple, list[LabeledFact]] = defaultdict(list) + for labeled in expected: + expected_by_key[(labeled.fixture_id, labeled.fact.key())].append(labeled) + for labeled in actual: + actual_by_key[(labeled.fixture_id, labeled.fact.key())].append(labeled) + + true_positives: list[LabeledFact] = [] + false_negatives: list[LabeledFact] = [] + false_positives: list[LabeledFact] = [] + + for key in sorted(set(expected_by_key) | set(actual_by_key), key=repr): + exps = sorted(expected_by_key.get(key, []), key=lambda item: item.fixture_id) + acts = sorted(actual_by_key.get(key, []), key=lambda item: item.fixture_id) + matched = min(len(exps), len(acts)) + true_positives.extend(exps[:matched]) # attribute matches to the golden side + false_negatives.extend(exps[matched:]) # expected facts never produced + false_positives.extend(acts[matched:]) # surplus / invented actual facts + + overall = Counts(len(true_positives), len(false_positives), len(false_negatives)) + return ScoreReport( + overall=overall, + by_language=_counts_by(lambda item: item.language, true_positives, false_positives, false_negatives), + by_class=_counts_by(lambda item: item.fixture_class, true_positives, false_positives, false_negatives), + true_positives=true_positives, + false_positives=false_positives, + false_negatives=false_negatives, + ) diff --git a/apps/backend/tests/benchmark/sourcefiles.py b/apps/backend/tests/benchmark/sourcefiles.py new file mode 100644 index 00000000..1cdd20da --- /dev/null +++ b/apps/backend/tests/benchmark/sourcefiles.py @@ -0,0 +1,45 @@ +"""Byte- and line-level helpers over fixture source, per RFC-0001 §6.2. + +Line evidence in ``ri.v1`` is defined over a *strict UTF-8 decode* of the stored +file bytes, and ``logical_line_count = 1 + count(U+000A)``. These helpers are the +single implementation of that convention for the benchmark, so the loader, +provenance validator, and any future extractor adapter all agree byte-for-byte. +""" + +from __future__ import annotations + +from pathlib import Path + + +class SourceDecodeError(ValueError): + """Raised when fixture bytes are not valid strict UTF-8 (RI-SRC-MALFORMED).""" + + +def read_bytes(path: Path) -> bytes: + return path.read_bytes() + + +def is_binary(data: bytes) -> bool: + """A non-empty file containing a NUL byte is binary (RFC §6.2, RI-SRC-BINARY). + + A zero-byte file is text, not binary. + """ + + return b"\x00" in data + + +def decode_strict_utf8(data: bytes) -> str: + try: + return data.decode("utf-8") + except UnicodeDecodeError as exc: # pragma: no cover - message is exercised via loader + raise SourceDecodeError(str(exc)) from exc + + +def logical_line_count(text: str) -> int: + """``1 + count(U+000A)`` — an empty string is one logical (empty) line.""" + + return 1 + text.count("\n") + + +def logical_line_count_of_bytes(data: bytes) -> int: + return logical_line_count(decode_strict_utf8(data)) diff --git a/apps/backend/tests/benchmark/test_adapter.py b/apps/backend/tests/benchmark/test_adapter.py new file mode 100644 index 00000000..adb87040 --- /dev/null +++ b/apps/backend/tests/benchmark/test_adapter.py @@ -0,0 +1,129 @@ +"""Direct real-adapter coverage for Issue #94's extraction boundary.""" + +from __future__ import annotations + +from pathlib import Path + +from benchmark.adapter import RealExtractionAdapter +from benchmark.facts import EvidenceSpan, Fact +from benchmark.loader import LoadedFixture + + +def _fixture( + directory: Path, + *, + language: str = "mixed", + producers: tuple[str, ...] = ( + "python-ast@1.1.0", + "repository-inventory@1.1.0", + "typescript-ast@1.2.0", + ), + max_source_bytes: int = 512 * 1024, +) -> LoadedFixture: + return LoadedFixture( + fixture_id="adapter", + fixture_class="minimal", + language=language, + title="adapter", + description="direct adapter test", + directory=directory, + source_root=".", + revision_identity="upload-sha256", + producer_version_set=producers, + constructs_covered=(), + deterministic=False, + expected=(), + max_source_bytes=max_source_bytes, + ) + + +def _write(directory: Path, path: str, data: bytes) -> None: + target = directory / path + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(data) + + +def test_real_adapter_dispatches_python_typescript_and_tsx(tmp_path: Path): + _write(tmp_path, "src/a.py", b"def py_name():\n pass\n") + _write(tmp_path, "src/b.ts", b"export function tsName() {}\n") + _write(tmp_path, "src/c.tsx", b"export const view =
;\n") + + facts = RealExtractionAdapter().extract(_fixture(tmp_path)) + + subjects = {fact.subject for fact in facts if fact.fact_type == "node"} + assert "src/a.py::py_name" in subjects + assert "src/b.ts::tsName" in subjects + assert "src/c.tsx::view" in subjects + + +def test_unsupported_text_file_is_inventory_only(tmp_path: Path): + _write(tmp_path, "notes.txt", b"plain text\n") + + facts = RealExtractionAdapter().extract(_fixture(tmp_path)) + + note = next(fact for fact in facts if fact.subject == "file:notes.txt") + assert note.name == "notes.txt" + assert note.language == "" + assert note.evidence[0].extractor == "repository-inventory" + assert not [fact for fact in facts if fact.fact_type == "observation"] + + +def test_binary_and_malformed_supported_sources_use_real_extractors(tmp_path: Path): + _write(tmp_path, "src/binary.py", b"\x00binary") + _write(tmp_path, "src/bad.ts", b"export const broken = ;\n") + + facts = RealExtractionAdapter().extract(_fixture(tmp_path)) + + diagnostics = {(fact.kind, fact.producer) for fact in facts if fact.fact_type == "diagnostic"} + assert ("RI-SRC-BINARY", "python-ast@1.1.0") in diagnostics + assert ("RI-SRC-MALFORMED", "typescript-ast@1.2.0") in diagnostics + + +def test_duplicate_symbols_observations_properties_and_provenance_are_preserved(tmp_path: Path): + _write( + tmp_path, + "src/a.py", + b"@decorator\ndef repeated():\n pass\ndef repeated():\n pass\n", + ) + + facts = RealExtractionAdapter().extract(_fixture(tmp_path, language="python")) + + nodes = [fact for fact in facts if fact.fact_type == "node" and "repeated" in fact.subject] + assert [fact.subject for fact in nodes] == ["src/a.py::repeated", "src/a.py::repeated#2"] + assert nodes[0].name == "repeated" + assert nodes[0].language == "python" + assert nodes[0].value == '{"decorators":["decorator"]}' + definitions = [fact for fact in facts if fact.fact_type == "observation" and fact.kind == "definition"] + assert [fact.ordinal for fact in definitions] == [1, 1] + assert all(fact.evidence[0].extractor == "python-ast" for fact in definitions) + duplicate = next(fact for fact in facts if fact.kind == "RI-KEY-DUP-SYMBOL") + assert duplicate.subject == "src/a.py::repeated#2" + assert duplicate.producer == "python-ast@1.1.0" + + +def test_real_source_size_policy_emits_limit_diagnostic(tmp_path: Path): + _write(tmp_path, "src/large.py", b"x = 1\n" * 5) + + facts = RealExtractionAdapter().extract(_fixture(tmp_path, max_source_bytes=8)) + + diagnostic = next(fact for fact in facts if fact.kind == "RI-LIMIT-SKIP") + assert diagnostic.producer == "repository-inventory@1.1.0" + assert diagnostic.subject == "file:src/large.py" + assert '"budgetBytes":8' in diagnostic.value + assert not any(fact.subject == "src/large.py::x" for fact in facts) + + +def test_exact_duplicate_adapter_output_is_not_hidden(): + fact = Fact( + fact_type="node", + kind="symbol", + subject="src/a.py::f", + name="f", + language="python", + truth_class="observed", + evidence=(EvidenceSpan("src/a.py", 1, 1, "python-ast", "1.1.0", "span"),), + ) + + merged = RealExtractionAdapter._merge_compatible_nodes([fact, fact]) + + assert merged == [fact, fact] diff --git a/apps/backend/tests/benchmark/test_determinism.py b/apps/backend/tests/benchmark/test_determinism.py new file mode 100644 index 00000000..2d92196e --- /dev/null +++ b/apps/backend/tests/benchmark/test_determinism.py @@ -0,0 +1,34 @@ +"""Determinism harness tests: real SnapshotStore + canonical hash (Issue #94 §E).""" + +from __future__ import annotations + +from pathlib import Path + +from benchmark import determinism, paths +from benchmark.loader import load_corpus, load_support_matrix + +SUPPORT_MATRIX = load_support_matrix(paths.SUPPORT_MATRIX_PATH) + + +def _deterministic_fixtures(): + return [f for f in load_corpus(paths.FIXTURES_DIR, SUPPORT_MATRIX) if f.deterministic] + + +def test_deterministic_fixtures_seal_to_a_stable_hash(tmp_path: Path): + fixtures = _deterministic_fixtures() + assert fixtures, "expected at least one deterministic fixture in the corpus" + for index, fixture in enumerate(fixtures): + result = determinism.check_fixture(fixture, tmp_path / f"det-{index}.db") + assert result.deterministic, ( + f"{fixture.fixture_id}: sealed {result.sealed_hash_a} vs {result.sealed_hash_b}; " + f"pure {result.pure_hash_a} vs {result.pure_hash_b}" + ) + assert result.sealed_hash_a.startswith("sha256:") + + +def test_sealed_and_pure_hash_agree_for_the_same_graph(tmp_path: Path): + # The real SnapshotStore seal and the pure canonical hash are the same + # function over the same node graph, so they must produce identical digests. + fixture = _deterministic_fixtures()[0] + result = determinism.check_fixture(fixture, tmp_path / "agree.db") + assert result.sealed_hash_a == result.pure_hash_a diff --git a/apps/backend/tests/benchmark/test_golden_authentication_question.py b/apps/backend/tests/benchmark/test_golden_authentication_question.py new file mode 100644 index 00000000..ea39f5b3 --- /dev/null +++ b/apps/backend/tests/benchmark/test_golden_authentication_question.py @@ -0,0 +1,413 @@ +"""The #95 golden question, run against a dedicated, honest benchmark set. + +Issue #94's 23-fixture corpus scores raw node/observation/diagnostic +extraction accuracy; it has no notion of "ask a question, get a scored +answer," and its fixtures and thresholds are not touched here. This is a +*separate*, smaller, hand-authored set of 5 fixtures built specifically to +answer one fixed question against the real production chain end to end +(extraction -> resolution -> role classification -> sealing -> query). Do not +conflate the two counts: this module answers the golden *authentication* +question against these 5 fixtures; #94 proves raw extraction accuracy +against its own 23. + +Only Python/FastAPI-style ``Depends()`` dependency injection is a supported +authentication-detection path today (see app/intelligence/classification.py +and app/extraction/python.py's ``_DEPENDENCY_MARKERS``). TypeScript appears in +one fixture only as an inert sibling file that manufactures a genuine +cross-language import ambiguity; it is not a second supported authentication +language, and no TypeScript-native dependency-injection idiom is claimed. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from uuid import uuid4 + +import pytest +from sqlalchemy import create_engine +from sqlalchemy.orm import Session, sessionmaker + +from app.analysis.authentication import AuthenticationExplanationService +from app.core.database import register_sqlite_foreign_key_enforcement +from app.extraction.pipeline import ExtractionPipeline, ProducedExtraction +from app.extraction.python import PythonExtractor +from app.extraction.typescript import TypeScriptExtractor +from app.intelligence import canonical +from app.intelligence.classification import RoleClassifier +from app.intelligence.query_service import SnapshotQueryService +from app.intelligence.resolution import RelationshipResolver +from app.intelligence.snapshot_store import Evidence, Revision, SnapshotStore +from app.models import RepositoryRecord, User +from app.models.base import Base +from app.schemas.authentication import AuthenticationExplanationResponse + +GOLDEN_QUESTION = ( + "Explain how authentication works in this repository, citing exact " + "routes, middleware, services, models, dependencies, symbols, and line spans." +) + + +@dataclass(frozen=True) +class AuthQuestionFixture: + """One hand-authored authentication-question scenario and its exact expected answer.""" + + fixture_id: str + sources: dict[str, bytes] + expected_routes: frozenset[str] + expected_middleware: frozenset[str] + expected_services: frozenset[str] + expected_models: frozenset[str] + expected_relationship_pairs: frozenset[tuple[str, str, str]] + forbidden_names: frozenset[str] = field(default_factory=frozenset) + expected_diagnostic_codes: frozenset[str] = field(default_factory=frozenset) + + +_CONNECTED = AuthQuestionFixture( + fixture_id="connected_auth_path", + sources={ + "src/dependencies.py": ( + b"from src.services import UserService\n\n\n" + b"def get_current_user(token: str) -> dict:\n" + b" return UserService(token)\n" + ), + "src/services.py": ( + b"from src.models import UserModel\n\n\ndef UserService(token: str) -> dict:\n return UserModel(token)\n" + ), + "src/models.py": (b"def UserModel(token: str) -> dict:\n return {'token': token}\n"), + "src/routes.py": ( + b"from fastapi import FastAPI, Depends\n" + b"from src.dependencies import get_current_user\n\n" + b"app = FastAPI()\n\n\n" + b'@app.get("/me")\n' + b"def read_me(user=Depends(get_current_user)):\n" + b" return user\n" + ), + }, + expected_routes=frozenset({"/me"}), + expected_middleware=frozenset({"get_current_user"}), + expected_services=frozenset({"UserService"}), + expected_models=frozenset({"UserModel"}), + expected_relationship_pairs=frozenset( + { + ("/me", "routes_to", "read_me"), + ("read_me", "injects", "get_current_user"), + ("get_current_user", "calls", "UserService"), + ("UserService", "calls", "UserModel"), + } + ), +) + +_UNRELATED_NOISE = AuthQuestionFixture( + fixture_id="unrelated_noise_excluded", + sources={ + "src/dependencies.py": ( + b"from src.services import UserService\n\n\n" + b"def get_current_user(token: str) -> dict:\n" + b" return UserService(token)\n\n\n" + b"def get_database() -> str:\n" + b" return 'db-session'\n" + ), + "src/services.py": ( + b"from src.models import UserModel\n\n\n" + b"def UserService(token: str) -> dict:\n" + b" return UserModel(token)\n\n\n" + b"def PaymentService(amount: int) -> int:\n" + b" return amount\n" + ), + "src/models.py": ( + b"def UserModel(token: str) -> dict:\n" + b" return {'token': token}\n\n\n" + b"def AuditModel(event: str) -> dict:\n" + b" return {'event': event}\n" + ), + "src/routes.py": ( + b"from fastapi import FastAPI, Depends\n" + b"from src.dependencies import get_current_user, get_database\n\n" + b"app = FastAPI()\n\n\n" + b'@app.get("/me")\n' + b"def read_me(user=Depends(get_current_user)):\n" + b" return user\n\n\n" + b'@app.get("/health")\n' + b"def health_check(db=Depends(get_database)):\n" + b" return {'status': 'ok'}\n" + ), + }, + expected_routes=frozenset({"/me"}), + expected_middleware=frozenset({"get_current_user"}), + expected_services=frozenset({"UserService"}), + expected_models=frozenset({"UserModel"}), + expected_relationship_pairs=frozenset( + { + ("/me", "routes_to", "read_me"), + ("read_me", "injects", "get_current_user"), + ("get_current_user", "calls", "UserService"), + ("UserService", "calls", "UserModel"), + } + ), + forbidden_names=frozenset({"/health", "health_check", "get_database", "PaymentService", "AuditModel"}), +) + +_GENERIC_DEPENDENCY_ONLY = AuthQuestionFixture( + fixture_id="generic_dependency_not_auth", + sources={ + "src/dependencies.py": b"def get_database() -> str:\n return 'db-session'\n", + "src/routes.py": ( + b"from fastapi import FastAPI, Depends\n" + b"from src.dependencies import get_database\n\n" + b"app = FastAPI()\n\n\n" + b'@app.get("/health")\n' + b"def health_check(db=Depends(get_database)):\n" + b" return {'status': 'ok'}\n" + ), + }, + expected_routes=frozenset(), + expected_middleware=frozenset(), + expected_services=frozenset(), + expected_models=frozenset(), + expected_relationship_pairs=frozenset(), + forbidden_names=frozenset({"/health", "health_check", "get_database"}), +) + +_UNRESOLVED_AND_AMBIGUOUS = AuthQuestionFixture( + fixture_id="unresolved_and_ambiguous_gaps", + sources={ + "src/routes.py": ( + b"from fastapi import FastAPI, Depends\n" + b"from .shared import get_current_user\n" + b"from src.dependencies import missing_guard\n\n" + b"app = FastAPI()\n\n\n" + b'@app.get("/me")\n' + b"def read_me(user=Depends(get_current_user)):\n" + b" return user\n\n\n" + b'@app.get("/profile")\n' + b"def read_profile(user=Depends(missing_guard)):\n" + b" return user\n" + ), + # Both files define `get_current_user`; the relative import binding + # resolves to both candidate files, so the reference stays a + # deterministic RI-RES-AMBIGUOUS diagnostic rather than guessing. + "src/shared.py": b"def get_current_user(token: str) -> str:\n return token\n", + "src/shared.ts": (b"export function get_current_user(token: string): string {\n return token;\n}\n"), + }, + expected_routes=frozenset(), + expected_middleware=frozenset(), + expected_services=frozenset(), + expected_models=frozenset(), + expected_relationship_pairs=frozenset(), + forbidden_names=frozenset({"/me", "/profile", "read_me", "read_profile", "get_current_user", "missing_guard"}), + expected_diagnostic_codes=frozenset({"RI-RES-UNRESOLVED", "RI-RES-AMBIGUOUS"}), +) + +_NO_AUTH_CONSTRUCTS = AuthQuestionFixture( + fixture_id="no_auth_constructs", + sources={"src/plain.py": b"def add(a, b):\n return a + b\n"}, + expected_routes=frozenset(), + expected_middleware=frozenset(), + expected_services=frozenset(), + expected_models=frozenset(), + expected_relationship_pairs=frozenset(), +) + +FIXTURES = [ + _CONNECTED, + _UNRELATED_NOISE, + _GENERIC_DEPENDENCY_ONLY, + _UNRESOLVED_AND_AMBIGUOUS, + _NO_AUTH_CONSTRUCTS, +] +FIXTURE_IDS = [item.fixture_id for item in FIXTURES] + + +def _evidence(record, produced: ProducedExtraction) -> Evidence: + return Evidence( + path=record.path, + start_line=record.start_line, + end_line=record.end_line, + extractor=produced.producer_name, + extractor_version=produced.producer_version, + logical_line_count=record.logical_line_count, + granularity=record.granularity, + ) + + +def _seal_sources(db_path, sources: dict[str, bytes]) -> tuple[Session, str, str, str]: + """Run the real production chain and return (session, owner_id, repository_id, snapshot_id).""" + + runs = ExtractionPipeline((PythonExtractor(), TypeScriptExtractor())).run(sources) + + register_sqlite_foreign_key_enforcement() + engine = create_engine(f"sqlite:///{db_path}") + Base.metadata.create_all(engine) + session = sessionmaker(bind=engine)() + + owner = User(id=str(uuid4()), email=f"{uuid4().hex[:8]}@example.com", password_hash=None) + session.add(owner) + session.commit() + + digest_map = {path: canonical.sha256_hex(data) for path, data in sources.items()} + revision_value = canonical.sha256_prefixed(canonical.canonical_json_bytes(digest_map)) + repository = RepositoryRecord( + id=str(uuid4()), + owner_id=owner.id, + name="auth-question-fixture", + source="upload", + revision_kind="upload", + revision_value=revision_value, + revision_ref=None, + local_path="/stored/revision", + status="completed", + file_tree=[], + ) + session.add(repository) + session.commit() + + store = SnapshotStore(session) + snapshot = store.begin( + repository_id=repository.id, + revision=Revision("upload", revision_value), + producer_version_set=sorted( + {run.producer for run in runs} + | { + f"{RelationshipResolver.name}@{RelationshipResolver.version}", + f"{RoleClassifier.name}@{RoleClassifier.version}", + } + ), + ) + for produced in runs: + for node in produced.result.nodes: + store.add_node( + snapshot, + node_kind=node.node_kind, + stable_key=node.stable_key, + name=node.name, + language=node.language, + properties=node.properties, + set_array_keys=( + frozenset({"decorators"}) if node.properties and "decorators" in node.properties else frozenset() + ), + evidence=[_evidence(item, produced) for item in node.evidence], + ) + for observation in produced.result.observations: + store.add_observation( + snapshot, + observed_kind=observation.observed_kind, + subject_kind=observation.subject_kind, + subject_key=observation.subject_key, + referent_text=observation.referent_text, + ordinal=observation.ordinal, + evidence=_evidence(observation.evidence, produced), + ) + for diagnostic in produced.result.diagnostics: + store.add_diagnostic( + snapshot, + code=diagnostic.code, + category=diagnostic.category, + severity=diagnostic.severity, + message=diagnostic.message, + producer=produced.producer, + path=diagnostic.path, + span=diagnostic.span, + subject=diagnostic.subject, + details=diagnostic.details, + ) + + RelationshipResolver(store).resolve(snapshot) + RoleClassifier(store).classify(snapshot) + sealed = store.seal(snapshot) + + return session, owner.id, repository.id, sealed.snapshot_id + + +def _explain(session: Session, owner_id: str, repository_id: str) -> AuthenticationExplanationResponse: + record = session.get(RepositoryRecord, repository_id) + assert record is not None + query_service = SnapshotQueryService(session, owner_id) + service = AuthenticationExplanationService(query_service) + return service.explain(record) + + +@pytest.mark.parametrize("fixture", FIXTURES, ids=FIXTURE_IDS) +def test_golden_authentication_question(fixture: AuthQuestionFixture, tmp_path) -> None: + """The fixed golden question (#95), answered by the real production chain + against each of the 5 dedicated authentication-question fixtures.""" + + session, owner_id, repository_id, snapshot_id = _seal_sources( + tmp_path / f"{fixture.fixture_id}.db", fixture.sources + ) + try: + response = _explain(session, owner_id, repository_id) + + assert response.status == "ready", GOLDEN_QUESTION + assert response.snapshot_id == snapshot_id + + by_kind: dict[str, set[str]] = {} + for claim in response.claims: + by_kind.setdefault(claim.kind, set()).add(claim.name) + + assert by_kind.get("route", set()) == fixture.expected_routes + assert by_kind.get("middleware", set()) == fixture.expected_middleware + assert by_kind.get("service", set()) == fixture.expected_services + assert by_kind.get("model", set()) == fixture.expected_models + + relationship_pairs = {(item.subject, item.predicate, item.object) for item in response.relationships} + assert relationship_pairs == fixture.expected_relationship_pairs + + # No unsupported claim: nothing forbidden for this scenario leaked + # into a claim or a relationship endpoint. + all_names: set[str] = set() + for names in by_kind.values(): + all_names |= names + for relationship in response.relationships: + all_names.add(relationship.subject) + all_names.add(relationship.object) + leaked = all_names & fixture.forbidden_names + assert not leaked, f"{fixture.fixture_id}: forbidden names leaked into the answer: {leaked}" + + diagnostic_codes = {diagnostic.code for diagnostic in response.diagnostics} + assert fixture.expected_diagnostic_codes <= diagnostic_codes + + # Every citation resolves to the exact fixture revision, path, and span. + source_line_counts = { + path: len(content.decode("utf-8").splitlines()) for path, content in fixture.sources.items() + } + for claim in response.claims: + assert claim.evidence, f"claim {claim.name!r} has no evidence" + for citation in claim.evidence: + assert citation.snapshot_id == snapshot_id + assert citation.path in source_line_counts + assert 1 <= citation.start_line <= citation.end_line <= source_line_counts[citation.path] + for relationship in response.relationships: + assert relationship.evidence, f"{relationship.subject} -> {relationship.object} has no evidence" + for citation in relationship.evidence: + assert citation.snapshot_id == snapshot_id + assert citation.path in source_line_counts + assert 1 <= citation.start_line <= citation.end_line <= source_line_counts[citation.path] + finally: + session.close() + + +@pytest.mark.parametrize("fixture", FIXTURES, ids=FIXTURE_IDS) +def test_golden_authentication_question_is_deterministic(fixture: AuthQuestionFixture, tmp_path) -> None: + """Sealing the same sources twice, independently, must answer identically.""" + + first_session, first_owner, first_repo, _ = _seal_sources(tmp_path / "first.db", fixture.sources) + second_session, second_owner, second_repo, _ = _seal_sources(tmp_path / "second.db", fixture.sources) + try: + first = _explain(first_session, first_owner, first_repo) + second = _explain(second_session, second_owner, second_repo) + + def _shape(response: AuthenticationExplanationResponse): + return ( + response.status, + frozenset((claim.kind, claim.name, claim.confidence) for claim in response.claims), + frozenset((item.subject, item.predicate, item.object) for item in response.relationships), + frozenset( + (diagnostic.code, diagnostic.path, diagnostic.start_line, diagnostic.end_line) + for diagnostic in response.diagnostics + ), + ) + + assert _shape(first) == _shape(second) + finally: + first_session.close() + second_session.close() diff --git a/apps/backend/tests/benchmark/test_loader.py b/apps/backend/tests/benchmark/test_loader.py new file mode 100644 index 00000000..841eea43 --- /dev/null +++ b/apps/backend/tests/benchmark/test_loader.py @@ -0,0 +1,245 @@ +"""Strict-loader tests: the real corpus loads, and every failure mode fails. + +The happy-path assertions load the committed corpus through the real support +matrix. The failure-path assertions build deliberately broken manifests in a +temp directory and confirm the loader rejects each condition Issue #94 lists. +""" + +from __future__ import annotations + +import json +from fractions import Fraction +from pathlib import Path + +import pytest + +from app.extraction.support_matrix import CONSTRUCT_CAPABILITIES, MANIFEST_CAPABILITIES +from benchmark import paths +from benchmark.loader import ( + ManifestError, + load_corpus, + load_fixture, + load_support_matrix, + load_thresholds, +) + +SUPPORT_MATRIX = load_support_matrix(paths.SUPPORT_MATRIX_PATH) + + +def test_committed_corpus_loads_cleanly(): + fixtures = load_corpus(paths.FIXTURES_DIR, SUPPORT_MATRIX) + assert fixtures, "expected at least one committed fixture" + ids = [fixture.fixture_id for fixture in fixtures] + assert ids == sorted(ids), "fixtures must load in deterministic id order" + assert len(ids) == len(set(ids)), "fixture ids must be unique" + + +def test_benchmark_mapping_is_complete_and_registry_authoritative(): + for capability in CONSTRUCT_CAPABILITIES: + for benchmark_id in capability.benchmark_ids: + assert benchmark_id in SUPPORT_MATRIX.constructs + assert SUPPORT_MATRIX.constructs[benchmark_id].capability_id == capability.id + for capability in MANIFEST_CAPABILITIES: + assert capability.benchmark_disclosure + + +def test_thresholds_load_with_exact_fractions(): + thresholds = load_thresholds(paths.THRESHOLDS_PATH) + assert thresholds.provenance_validity == 1 + assert thresholds.determinism == 1 + assert 0 < thresholds.precision <= 1 + assert 0 < thresholds.recall <= 1 + + +def test_committed_recall_threshold_matches_precision_bar(): + """Issue #193: recall must not regress below the harmonized 0.95 bar. + + Recall started at a looser provisional 0.90 (Issue #94) while precision was + already enforced at 0.95. Real extraction now clears 0.95 recall on the full + corpus, so the acceptance bar is raised to match; this guards against a + future PR quietly lowering it back without the Issue #94 sign-off the + threshold file requires. + """ + + thresholds = load_thresholds(paths.THRESHOLDS_PATH) + assert thresholds.precision >= Fraction(95, 100) + assert thresholds.recall >= Fraction(95, 100) + + +def _base_manifest() -> dict: + return { + "schemaVersion": "ri-benchmark.v1", + "fixtureId": "tmp-fixture", + "fixtureClass": "minimal", + "language": "python", + "title": "t", + "description": "d", + "sourceRoot": ".", + "revisionIdentity": "upload-sha256", + "producerVersionSet": ["python-ast@1.0.0", "repository-inventory@1.1.0"], + "constructsCovered": ["py.function.def"], + "deterministic": False, + "expected": { + "nodes": [ + { + "nodeKind": "repository", + "stableKey": "repo:root", + "name": "repository", + "evidence": [ + { + "path": "README.md", + "startLine": 1, + "endLine": 1, + "extractor": "repository-inventory", + "extractorVersion": "1.1.0", + } + ], + }, + { + "nodeKind": "symbol", + "stableKey": "src/a.py::f", + "name": "f", + "language": "python", + "constructs": ["py.function.def"], + "evidence": [ + { + "path": "src/a.py", + "startLine": 1, + "endLine": 1, + "extractor": "python-ast", + "extractorVersion": "1.0.0", + } + ], + }, + ] + }, + } + + +def _write_fixture(tmp_path: Path, manifest: dict, *, sources: dict[str, str] | None = None) -> Path: + directory = tmp_path / "tmp-fixture" + directory.mkdir(parents=True, exist_ok=True) + (directory / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8") + files = {"README.md": "# readme\n", "src/a.py": "def f():\n return 1\n"} + files.update(sources or {}) + for relative, content in files.items(): + target = directory / relative + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(content, encoding="utf-8") + return directory + + +def _load_bad(tmp_path: Path, mutate) -> None: + manifest = _base_manifest() + mutate(manifest) + directory = _write_fixture(tmp_path, manifest) + load_fixture(directory, SUPPORT_MATRIX) + + +def test_sanity_base_manifest_loads(tmp_path: Path): + directory = _write_fixture(tmp_path, _base_manifest()) + fixture = load_fixture(directory, SUPPORT_MATRIX) + assert fixture.fixture_id == "tmp-fixture" + symbol = next(record.fact for record in fixture.expected if record.fact.subject == "src/a.py::f") + assert symbol.name == "f" + assert symbol.language == "python" + + +def test_source_policy_construct_cannot_bypass_fixture_language(tmp_path: Path): + manifest = _base_manifest() + manifest["constructsCovered"] = ["src.path_escape"] + directory = _write_fixture(tmp_path, manifest) + + with pytest.raises(ManifestError, match="language mismatch"): + load_fixture(directory, SUPPORT_MATRIX) + + +def test_manifest_synthetic_sources_are_deterministic_and_do_not_need_host_paths(tmp_path: Path): + manifest = _base_manifest() + manifest["syntheticFiles"] = { + "z-last.txt": "last\n", + "..\\escape.py": "def must_not_extract():\n pass\n", + } + directory = _write_fixture(tmp_path, manifest) + + first = load_fixture(directory, SUPPORT_MATRIX) + second = load_fixture(directory, SUPPORT_MATRIX) + + assert list(first.source_files()) == ["..\\escape.py", "README.md", "src/a.py", "z-last.txt"] + assert first.source_files()["..\\escape.py"] == b"def must_not_extract():\n pass\n" + assert first.revision_value() == second.revision_value() + + +@pytest.mark.parametrize( + "synthetic_files, message", + [ + ([], "syntheticFiles must be an object"), + ({"": "text"}, "synthetic source path must be a non-empty"), + ({"README.md": "duplicate"}, "duplicates a stored file"), + ({"src/synthetic.py": 7}, "must be UTF-8 text"), + ], +) +def test_loader_rejects_invalid_synthetic_sources(tmp_path: Path, synthetic_files, message: str): + manifest = _base_manifest() + manifest["syntheticFiles"] = synthetic_files + directory = _write_fixture(tmp_path, manifest) + + with pytest.raises(ManifestError, match=message): + load_fixture(directory, SUPPORT_MATRIX) + + +@pytest.mark.parametrize( + "mutate, message", + [ + (lambda m: m.update(schemaVersion="ri-benchmark.v999"), "unsupported fixture schema version"), + (lambda m: m.update(fixtureClass="weird"), "invalid fixtureClass"), + (lambda m: m.update(language="cobol"), "unsupported language"), + (lambda m: m.update(revisionIdentity="git-sha"), "unsupported revisionIdentity"), + (lambda m: m.update(blessed=True), "forbidden key"), + (lambda m: m.update(producerVersionSet=["badproducer"]), "must be 'name@version'"), + (lambda m: m.update(constructsCovered=["py.not_a_construct"]), "undeclared benchmark construct"), + (lambda m: m["expected"]["nodes"][1]["evidence"][0].update(path="/etc/passwd"), "absolute or escapes"), + (lambda m: m["expected"]["nodes"][1]["evidence"][0].update(path="../escape.py"), "absolute or escapes"), + (lambda m: m["expected"]["nodes"][1]["evidence"][0].update(startLine=0), "start_line must be >= 1"), + ( + lambda m: m["expected"]["nodes"][1]["evidence"][0].update(startLine=2, endLine=1), + "end_line must be >= start_line", + ), + (lambda m: m["expected"]["nodes"][1]["evidence"][0].update(endLine=999), "exceeds logical_line_count"), + (lambda m: m["expected"]["nodes"][1]["evidence"][0].update(extractor="ghost"), "not in producerVersionSet"), + (lambda m: m["expected"]["nodes"][1].__setitem__("evidence", []), "must carry >=1 evidence"), + (lambda m: m["expected"]["nodes"][1].__setitem__("nodeKind", ""), "missing 'nodeKind'"), + (lambda m: m["expected"]["nodes"].__setitem__(1, m["expected"]["nodes"][0]), "duplicate expected identity"), + ], +) +def test_loader_rejects_bad_manifests(tmp_path: Path, mutate, message: str): + with pytest.raises(ManifestError, match=message): + _load_bad(tmp_path, mutate) + + +def test_missing_source_file_is_rejected(tmp_path: Path): + manifest = _base_manifest() + manifest["expected"]["nodes"][1]["evidence"][0]["path"] = "src/missing.py" + directory = _write_fixture(tmp_path, manifest) + with pytest.raises(ManifestError, match="missing source file"): + load_fixture(directory, SUPPORT_MATRIX) + + +def test_duplicate_fixture_ids_across_corpus_are_rejected(tmp_path: Path): + _write_fixture(tmp_path / "a", _base_manifest()) + second = tmp_path / "b" / "tmp-fixture" + second.mkdir(parents=True) + (second / "manifest.json").write_text(json.dumps(_base_manifest()), encoding="utf-8") + (second / "README.md").write_text("# readme\n", encoding="utf-8") + (second / "src").mkdir() + (second / "src" / "a.py").write_text("def f():\n return 1\n", encoding="utf-8") + with pytest.raises(ManifestError, match="duplicate fixture id"): + load_corpus(tmp_path, SUPPORT_MATRIX) + + +def test_file_granularity_must_span_whole_file(tmp_path: Path): + manifest = _base_manifest() + manifest["expected"]["nodes"][1]["evidence"][0].update(granularity="file", startLine=1, endLine=1) + directory = _write_fixture(tmp_path, manifest) # src/a.py is 3 logical lines + with pytest.raises(ManifestError, match="file-granularity evidence must span"): + load_fixture(directory, SUPPORT_MATRIX) diff --git a/apps/backend/tests/benchmark/test_provenance.py b/apps/backend/tests/benchmark/test_provenance.py new file mode 100644 index 00000000..44633910 --- /dev/null +++ b/apps/backend/tests/benchmark/test_provenance.py @@ -0,0 +1,93 @@ +"""Provenance validator tests (RFC-0001 §6.2, Issue #94 §D).""" + +from __future__ import annotations + +from pathlib import Path + +from benchmark import paths +from benchmark.facts import EvidenceSpan, Fact +from benchmark.loader import LoadedFixture, load_corpus, load_support_matrix +from benchmark.provenance import validate_expected, validate_fixture + +SUPPORT_MATRIX = load_support_matrix(paths.SUPPORT_MATRIX_PATH) + + +def _fixture(directory: Path) -> LoadedFixture: + return LoadedFixture( + fixture_id="tmp", + fixture_class="minimal", + language="python", + title="t", + description="d", + directory=directory, + source_root=".", + revision_identity="upload-sha256", + producer_version_set=("python-ast@1.1.0",), + constructs_covered=(), + deterministic=False, + expected=(), + ) + + +def _node( + path: str, start: int, end: int, *, extractor: str = "python-ast", version: str = "1.1.0", granularity: str = "span" +) -> Fact: + return Fact( + fact_type="node", + kind="symbol", + subject=f"{path}::sym", + name="sym", + language="python", + truth_class="observed", + evidence=(EvidenceSpan(path, start, end, extractor, version, granularity),), + ) + + +def test_committed_corpus_provenance_is_fully_valid(): + for fixture in load_corpus(paths.FIXTURES_DIR, SUPPORT_MATRIX): + result = validate_expected(fixture) + assert result.validity == 1, f"{fixture.fixture_id}: {[c.reason for c in result.invalid]}" + + +def test_valid_citation_passes(tmp_path: Path): + (tmp_path / "a.py").write_text("x = 1\ny = 2\n", encoding="utf-8") # LLC = 3 + result = validate_fixture(_fixture(tmp_path), [_node("a.py", 1, 2)]) + assert result.validity == 1 + assert result.total == 1 + + +def test_out_of_range_span_is_invalid(tmp_path: Path): + (tmp_path / "a.py").write_text("x = 1\n", encoding="utf-8") # LLC = 2 + result = validate_fixture(_fixture(tmp_path), [_node("a.py", 1, 9)]) + assert result.validity == 0 + assert "outside 1..2" in result.invalid[0].reason + + +def test_missing_file_is_invalid(tmp_path: Path): + result = validate_fixture(_fixture(tmp_path), [_node("ghost.py", 1, 1)]) + assert result.validity == 0 + assert "does not exist" in result.invalid[0].reason + + +def test_path_escape_is_invalid(tmp_path: Path): + result = validate_fixture(_fixture(tmp_path), [_node("../secret.py", 1, 1)]) + assert result.validity == 0 + assert "escape" in result.invalid[0].reason + + +def test_binary_file_citation_is_invalid(tmp_path: Path): + (tmp_path / "blob.bin").write_bytes(b"\x00\x01\x02data") + result = validate_fixture(_fixture(tmp_path), [_node("blob.bin", 1, 1)]) + assert result.validity == 0 + assert "binary" in result.invalid[0].reason + + +def test_undeclared_producer_is_invalid(tmp_path: Path): + (tmp_path / "a.py").write_text("x = 1\n", encoding="utf-8") + result = validate_fixture(_fixture(tmp_path), [_node("a.py", 1, 1, extractor="ghost")]) + assert result.validity == 0 + assert "undeclared producer" in result.invalid[0].reason + + +def test_no_citations_is_vacuously_valid(tmp_path: Path): + assert validate_fixture(_fixture(tmp_path), []).validity == 1 diff --git a/apps/backend/tests/benchmark/test_regression_failpath.py b/apps/backend/tests/benchmark/test_regression_failpath.py new file mode 100644 index 00000000..155bc8a6 --- /dev/null +++ b/apps/backend/tests/benchmark/test_regression_failpath.py @@ -0,0 +1,174 @@ +"""The benchmark's failure path really fails (Issue #94 §F). + +A regression guard is worthless if it only proves the happy path. These tests +inject deliberately wrong extractor output, invalid citations, a broken manifest, +and a determinism break, and assert the runner reports a failure and a non-zero +exit code in every case. They also prove a *good* extractor passes — so the gate +is discriminating, not just always-red. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +from benchmark import runner +from benchmark.determinism import DeterminismResult +from benchmark.facts import EvidenceSpan, Fact +from benchmark.loader import LoadedFixture + + +class PerfectAdapter: + """A hypothetical extractor that emits exactly the golden facts.""" + + name = "perfect" + available = True + + def extract(self, fixture: LoadedFixture) -> list[Fact]: + return [record.fact for record in fixture.expected] + + +class SilentAdapter: + """An extractor that emits nothing: every expected fact becomes a miss.""" + + name = "silent" + available = True + + def extract(self, fixture: LoadedFixture) -> list[Fact]: + return [] + + +class InventingAdapter: + """An extractor that only invents facts: all false positives, zero recall.""" + + name = "inventing" + available = True + + def extract(self, fixture: LoadedFixture) -> list[Fact]: + ghosts: list[Fact] = [] + for record in fixture.expected: + if record.fact.evidence: + span = record.fact.evidence[0] + ghosts.append( + Fact( + fact_type="node", + kind="symbol", + subject=f"{span.path}::ghost", + name="ghost", + language=fixture.language if fixture.language != "mixed" else "", + truth_class="observed", + evidence=(span,), + ) + ) + return ghosts + + +class BadCitationAdapter: + """A near-perfect extractor whose one emitted citation is out of range.""" + + name = "bad-citation" + available = True + + def extract(self, fixture: LoadedFixture) -> list[Fact]: + facts = [record.fact for record in fixture.expected] + for index, fact in enumerate(facts): + if fact.evidence: + span = fact.evidence[0] + broken = EvidenceSpan( + span.path, span.start_line, 99999, span.extractor, span.extractor_version, span.granularity + ) + facts[index] = Fact( + fact_type=fact.fact_type, + kind=fact.kind, + subject=fact.subject, + object=fact.object, + predicate=fact.predicate, + name=fact.name, + language=fact.language, + referent=fact.referent, + ordinal=fact.ordinal, + truth_class=fact.truth_class, + value=fact.value, + severity=fact.severity, + category=fact.category, + message=fact.message, + producer=fact.producer, + evidence=(broken,), + details=fact.details, + ) + break + return facts + + +def test_a_perfect_extractor_passes_scoring(): + result = runner.run(adapter=PerfectAdapter()) + assert result.scoring.available + assert result.scoring.report is not None + assert result.scoring.report.overall.precision == 1 + assert result.scoring.report.overall.recall == 1 + assert result.passed and result.exit_code == 0 + + +def test_a_silent_extractor_fails_the_recall_gate(): + result = runner.run(adapter=SilentAdapter()) + assert result.scoring.report is not None + assert result.scoring.report.overall.recall < result.thresholds.recall + assert not result.passed + assert result.exit_code == 1 + assert not result.golden_regression_passed + + +def test_an_inventing_extractor_fails_the_precision_gate(): + result = runner.run(adapter=InventingAdapter()) + assert result.scoring.report is not None + assert result.scoring.report.overall.precision < result.thresholds.precision + assert not result.passed + assert result.exit_code == 1 + assert not result.golden_regression_passed + + +def test_an_invalid_emitted_citation_fails_even_if_scoring_is_high(): + result = runner.run(adapter=BadCitationAdapter()) + assert result.scoring.actual_provenance is not None + assert result.scoring.actual_provenance.validity < 1 + assert not result.passed + assert result.exit_code == 1 + + +def test_a_broken_manifest_fails_the_build(tmp_path: Path): + fixture_dir = tmp_path / "broken" + fixture_dir.mkdir() + (fixture_dir / "manifest.json").write_text(json.dumps({"schemaVersion": "ri-benchmark.v999"}), encoding="utf-8") + result = runner.run(fixtures_dir=tmp_path) + assert result.load_error is not None + assert not result.passed + assert result.exit_code == 1 + + +def test_a_support_matrix_mismatch_fails_the_build(tmp_path: Path): + source = runner.paths.SUPPORT_MATRIX_PATH + matrix = json.loads(source.read_text(encoding="utf-8")) + matrix["productionMappings"]["py.function.def"] = "not-a-real-capability" + matrix_path = tmp_path / "support-matrix.json" + matrix_path.write_text(json.dumps(matrix), encoding="utf-8") + + result = runner.run(support_matrix_path=matrix_path) + + assert result.load_error is not None + assert "unknown production capability" in result.load_error + assert not result.passed + assert result.exit_code == 1 + + +def test_a_determinism_break_fails_the_build(monkeypatch): + def fake_check(fixture, db_path): + return DeterminismResult( + fixture.fixture_id, "sha256:" + "a" * 64, "sha256:" + "b" * 64, "sha256:" + "a" * 64, "sha256:" + "a" * 64 + ) + + monkeypatch.setattr(runner, "check_fixture", fake_check) + result = runner.run() + assert not result.determinism_passed + assert not result.passed + assert result.exit_code == 1 + assert any("determinism" in reason for reason in result.failures()) diff --git a/apps/backend/tests/benchmark/test_runner.py b/apps/backend/tests/benchmark/test_runner.py new file mode 100644 index 00000000..2c621707 --- /dev/null +++ b/apps/backend/tests/benchmark/test_runner.py @@ -0,0 +1,88 @@ +"""End-to-end benchmark runner tests over the committed corpus (Issue #94).""" + +from __future__ import annotations + +import io +import json +from pathlib import Path + +from benchmark import report as report_module +from benchmark import run as run_module +from benchmark import runner + + +def test_full_benchmark_passes_and_reports_all_real_signals(): + result = runner.run() + assert result.load_error is None + assert result.passed + assert result.exit_code == 0 + # Real, enforced-now signals. + assert result.provenance.validity == 1 + assert result.provenance.total > 0 + assert result.determinism and all(r.deterministic for r in result.determinism) + assert result.parity.passed + assert result.diagnostics.passed + assert result.scoring.available + assert result.scoring.report is not None + assert result.scoring.report.overall.precision == 1 + assert result.scoring.report.overall.recall == 1 + assert result.scoring.actual_provenance is not None + assert result.scoring.actual_provenance.validity == 1 + assert result.golden_regression_passed + # All three fixture classes and both languages are represented. + assert set(result.corpus.by_class) == {"minimal", "realistic", "adversarial"} + assert {"python", "typescript"} <= set(result.corpus.by_language) + + +def test_precision_recall_is_measured_by_the_real_extractors(): + result = runner.run() + assert result.scoring.available + assert result.scoring.report is not None + assert result.scoring.report.overall.true_positives > 0 + assert result.scoring.report.false_positives == [] + assert result.scoring.report.false_negatives == [] + assert result.scoring_gate_passed + assert result.golden_regression_passed + + +def test_reports_are_deterministic_and_serialisable(): + first = report_module.to_json(runner.run()) + second = report_module.to_json(runner.run()) + assert first == second, "JSON report must be byte-stable across runs" + payload = json.loads(first) + assert payload["result"] == "pass" + assert payload["goldenFixtureProvenance"]["validity"] == "1.0000" + assert payload["scoring"]["status"] == "scored" + assert payload["scoring"]["realExtractorProvenance"]["validity"] == "1.0000" + assert "deferred" not in first.lower() + assert "unavailable" not in first.lower() + markdown = report_module.to_markdown(runner.run()) + assert "Repository Intelligence Golden Benchmark" in markdown + assert "lang:python" in markdown + assert "class:adversarial" in markdown + assert "False positives" in markdown + assert "False negatives" in markdown + assert "Exact golden regression gate" in markdown + assert "deferred" not in markdown.lower() + assert "unavailable" not in markdown.lower() + + +def test_write_reports_emits_both_files(tmp_path: Path): + json_path, markdown_path = report_module.write_reports(runner.run(), tmp_path / "out") + assert json_path.is_file() and markdown_path.is_file() + assert json.loads(json_path.read_text())["dataVersion"] == "ri-benchmark.v1" + + +def test_documented_command_writes_to_a_strict_cp1252_stream(tmp_path: Path, monkeypatch): + output = io.BytesIO() + stream = io.TextIOWrapper(output, encoding="cp1252", errors="strict") + report_dir = tmp_path / "reports" + monkeypatch.setattr(run_module.sys, "stdout", stream) + + exit_code = run_module.main(["--report-dir", str(report_dir)]) + stream.flush() + + assert exit_code == 0 + assert b"RI Golden Benchmark: PASS" in output.getvalue() + assert json.loads((report_dir / "benchmark.json").read_text(encoding="utf-8"))["result"] == "pass" + assert "✅" in (report_dir / "benchmark.md").read_text(encoding="utf-8") diff --git a/apps/backend/tests/benchmark/test_scorer.py b/apps/backend/tests/benchmark/test_scorer.py new file mode 100644 index 00000000..3b4bb037 --- /dev/null +++ b/apps/backend/tests/benchmark/test_scorer.py @@ -0,0 +1,207 @@ +"""Scorer unit tests with intentionally controlled TP/FP/FN inputs (Issue #94). + +These never touch a fixture or an extractor: they feed hand-built expected and +actual fact sets straight into the scorer so its arithmetic is proven in +isolation — including duplicates, wrong spans, wrong kinds, empty expected sets, +missing diagnostics, and both zero-denominator cases. +""" + +from __future__ import annotations + +from fractions import Fraction + +from benchmark.facts import EvidenceSpan, Fact +from benchmark.scorer import LabeledFact, score + + +def _span(path: str, start: int, end: int, granularity: str = "span") -> EvidenceSpan: + return EvidenceSpan(path, start, end, "python-ast", "1.0.0", granularity) + + +def _node( + stable_key: str, + start: int, + end: int, + *, + kind: str = "symbol", + name: str | None = None, + language: str = "python", +) -> Fact: + return Fact( + fact_type="node", + kind=kind, + subject=stable_key, + name=name if name is not None else stable_key.rsplit("::", 1)[-1], + language=language, + truth_class="observed", + evidence=(_span(stable_key.split("::")[0].removeprefix("file:"), start, end),), + ) + + +def _diagnostic(code: str, subject: str) -> Fact: + return Fact(fact_type="diagnostic", kind=code, subject=subject, severity="info", producer="python-ast@1.0.0") + + +def _labeled( + fact: Fact, *, language: str = "python", fixture_class: str = "minimal", fixture_id: str = "fx" +) -> LabeledFact: + return LabeledFact(fact=fact, language=language, fixture_class=fixture_class, fixture_id=fixture_id) + + +def test_perfect_match_scores_one_precision_and_recall(): + facts = [_labeled(_node("file:a.py::f", 1, 3)), _labeled(_node("file:a.py::g", 5, 9))] + report = score(facts, list(facts)) + assert report.overall.true_positives == 2 + assert report.overall.false_positives == 0 + assert report.overall.false_negatives == 0 + assert report.overall.precision == Fraction(1) + assert report.overall.recall == Fraction(1) + + +def test_extra_actual_fact_is_a_false_positive(): + expected = [_labeled(_node("file:a.py::f", 1, 3))] + actual = [_labeled(_node("file:a.py::f", 1, 3)), _labeled(_node("file:a.py::ghost", 10, 12))] + report = score(expected, actual) + assert report.overall.true_positives == 1 + assert report.overall.false_positives == 1 + assert report.overall.false_negatives == 0 + assert report.overall.precision == Fraction(1, 2) + assert report.overall.recall == Fraction(1) + + +def test_missing_expected_fact_is_a_false_negative(): + expected = [_labeled(_node("file:a.py::f", 1, 3)), _labeled(_node("file:a.py::g", 5, 9))] + actual = [_labeled(_node("file:a.py::f", 1, 3))] + report = score(expected, actual) + assert report.overall.true_positives == 1 + assert report.overall.false_negatives == 1 + assert report.overall.false_positives == 0 + assert report.overall.recall == Fraction(1, 2) + assert report.overall.precision == Fraction(1) + + +def test_right_name_wrong_span_is_both_a_miss_and_an_invention(): + expected = [_labeled(_node("file:a.py::f", 1, 3))] + actual = [_labeled(_node("file:a.py::f", 2, 4))] # same symbol, wrong span + report = score(expected, actual) + assert report.overall.true_positives == 0 + assert report.overall.false_negatives == 1 + assert report.overall.false_positives == 1 + assert report.overall.precision == Fraction(0) + assert report.overall.recall == Fraction(0) + + +def test_wrong_fact_kind_does_not_match(): + expected = [_labeled(_node("file:a.py::C", 1, 8, kind="symbol"))] + actual = [_labeled(_node("file:a.py::C", 1, 8, kind="module"))] + report = score(expected, actual) + assert report.overall.true_positives == 0 + assert report.overall.false_positives == 1 + assert report.overall.false_negatives == 1 + + +def test_wrong_node_name_is_both_a_miss_and_an_invention(): + expected = [_labeled(_node("file:a.py::f", 1, 3, name="expected_name"))] + actual = [_labeled(_node("file:a.py::f", 1, 3, name="wrong_name"))] + + report = score(expected, actual) + + assert report.overall.true_positives == 0 + assert report.overall.false_negatives == 1 + assert report.overall.false_positives == 1 + + +def test_wrong_node_language_is_both_a_miss_and_an_invention(): + expected = [_labeled(_node("file:a.py::f", 1, 3, language="python"))] + actual = [_labeled(_node("file:a.py::f", 1, 3, language="typescript"))] + + report = score(expected, actual) + + assert report.overall.true_positives == 0 + assert report.overall.false_negatives == 1 + assert report.overall.false_positives == 1 + + +def test_correct_node_name_and_language_are_a_true_positive(): + fact = _node("file:a.py::f", 1, 3, name="f", language="python") + + report = score([_labeled(fact)], [_labeled(fact)]) + + assert report.overall.true_positives == 1 + assert report.overall.false_negatives == 0 + assert report.overall.false_positives == 0 + + +def test_duplicate_actual_fact_counts_one_tp_and_one_fp(): + expected = [_labeled(_node("file:a.py::f", 1, 3))] + actual = [_labeled(_node("file:a.py::f", 1, 3)), _labeled(_node("file:a.py::f", 1, 3))] + report = score(expected, actual) + assert report.overall.true_positives == 1 + assert report.overall.false_positives == 1 + assert report.overall.false_negatives == 0 + assert report.overall.precision == Fraction(1, 2) + + +def test_identical_fact_keys_do_not_cross_match_between_fixtures(): + fact = _node("file:src/util.py::helper", 1, 3) + expected = [ + _labeled(fact, fixture_id="fixture-a"), + _labeled(fact, fixture_id="fixture-b"), + ] + actual = [ + _labeled(fact, fixture_id="fixture-b"), + _labeled(fact, fixture_id="fixture-b"), + ] + + report = score(expected, actual) + + assert report.overall.true_positives == 1 + assert report.overall.false_positives == 1 + assert report.overall.false_negatives == 1 + assert report.overall.precision == Fraction(1, 2) + assert report.overall.recall == Fraction(1, 2) + assert [item.fixture_id for item in report.true_positives] == ["fixture-b"] + assert [item.fixture_id for item in report.false_negatives] == ["fixture-a"] + + +def test_empty_expected_with_invented_facts_is_zero_precision_not_perfect(): + expected: list[LabeledFact] = [] + actual = [_labeled(_node("file:a.py::ghost", 1, 2))] + report = score(expected, actual) + assert report.overall.true_positives == 0 + assert report.overall.false_positives == 1 + assert report.overall.precision == Fraction(0) # NOT a manufactured 1.0 + assert report.overall.recall == Fraction(1) # nothing was expected + + +def test_empty_expected_and_empty_actual_is_defined_as_perfect(): + report = score([], []) + assert report.overall.precision == Fraction(1) + assert report.overall.recall == Fraction(1) + + +def test_missing_required_diagnostic_is_a_false_negative(): + expected = [_labeled(_diagnostic("RI-EXT-UNSUPPORTED", "file:a.py::dynamic"))] + actual: list[LabeledFact] = [] # extractor stayed silent instead of diagnosing + report = score(expected, actual) + assert report.overall.false_negatives == 1 + assert report.overall.recall == Fraction(0) + + +def test_per_language_and_per_class_breakdowns_are_attributed_correctly(): + expected = [ + _labeled(_node("file:a.py::f", 1, 3), language="python", fixture_class="minimal"), + _labeled(_node("file:a.ts::g", 1, 3), language="typescript", fixture_class="realistic"), + ] + actual = [ + _labeled(_node("file:a.py::f", 1, 3), language="python", fixture_class="minimal"), + _labeled(_node("file:a.ts::ghost", 9, 9), language="typescript", fixture_class="realistic"), + ] + report = score(expected, actual) + assert report.by_language["python"].true_positives == 1 + assert report.by_language["python"].false_positives == 0 + assert report.by_language["typescript"].true_positives == 0 + assert report.by_language["typescript"].false_negatives == 1 + assert report.by_language["typescript"].false_positives == 1 + assert report.by_class["realistic"].recall == Fraction(0) + assert report.by_class["minimal"].precision == Fraction(1) diff --git a/apps/backend/tests/conftest.py b/apps/backend/tests/conftest.py index 0f92dbb4..6021fe2e 100644 --- a/apps/backend/tests/conftest.py +++ b/apps/backend/tests/conftest.py @@ -1,10 +1,75 @@ import os -from collections.abc import Generator +from collections.abc import Callable, Generator from pathlib import Path +from uuid import uuid4 import pytest from fastapi.testclient import TestClient +DEFAULT_TEST_PASSWORD = "correct-horse-battery-staple" + + +def approve_email(email: str, note: str | None = None) -> None: + """Insert an ApprovedEmail row directly (#374) -- registration requires + the address to be on the allowlist, and tests need a real approval the + same way an operator running scripts/approve_email.py would produce one, + not a bypass around the check. A no-op if already approved, mirroring + the script's own idempotence. + """ + from sqlalchemy import select + + from app.core.database import SessionLocal + from app.models.approved_email import ApprovedEmail + + normalized = email.strip().lower() + with SessionLocal() as session: + existing = session.scalars(select(ApprovedEmail).where(ApprovedEmail.email == normalized)).first() + if existing is not None: + return + session.add(ApprovedEmail(id=str(uuid4()), email=normalized, note=note)) + session.commit() + + +def register_user( + client: TestClient, + email: str, + password: str = DEFAULT_TEST_PASSWORD, +) -> dict: + """Register a user through the real endpoint and return an auth bundle. + + Returns a dict with ``token`` (access token), ``user`` (the user payload) + and ``headers`` (a ready-to-use ``Authorization`` header), so tests can + exercise the owner-scoped routes as a genuine authenticated caller rather + than relying on any pre-auth fallback (removed in E1.3 / #63). Approves + the email first so every call site keeps working unchanged; a test + exercising allowlist behavior itself calls the real endpoint directly. + """ + approve_email(email, note=f"test:{email}") + response = client.post( + "/auth/register", + json={"email": email, "password": password}, + ) + assert response.status_code == 201, response.text + body = response.json() + token = body["accessToken"] + return {"token": token, "user": body["user"], "headers": {"Authorization": f"Bearer {token}"}} + + +@pytest.fixture(autouse=True) +def _no_frontend_dist_by_default(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Every test gets a FRONTEND_DIST_PATH guaranteed not to exist (#339), + regardless of which fixture (or none) it uses to build the app. + + Without this, the default relative frontend_dist_path resolves against + whatever the process's actual cwd is, so a developer who happens to have + a real apps/frontend/dist built locally would get different backend + test behavior than someone who doesn't -- and CI, which never builds the + frontend, than either of them. A test that specifically wants the SPA + mount (see test_frontend_hosting.py) overrides this after depending on + it, same as any other monkeypatch.setenv call. + """ + monkeypatch.setenv("FRONTEND_DIST_PATH", str(tmp_path / "no-frontend-dist-here")) + @pytest.fixture() def client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Generator[TestClient, None, None]: @@ -14,6 +79,15 @@ def client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Generator[TestCli monkeypatch.setenv("STORAGE_PATH", str(storage_path)) monkeypatch.setenv("AUTO_CREATE_TABLES", "true") monkeypatch.setenv("CORS_ORIGINS", "http://testserver") + # Deliberately "test", not the default "development": AuthService's + # dev-only allowlist bypass (#384) is scoped to app_env == "development" + # specifically so it never applies to the test suite -- without this, + # every test asserting an allowlist rejection would silently start + # passing for the wrong reason (auto-approval, not a real check). + monkeypatch.setenv("APP_ENV", "test") + # Tests drive AnalysisWorker.run_once() deterministically; the background + # daemon thread would otherwise race the queue non-deterministically (#93). + monkeypatch.setenv("ANALYSIS_WORKER_AUTOSTART", "false") from app.core import config @@ -25,13 +99,21 @@ def client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Generator[TestCli database.settings = settings database.engine.dispose() database.connect_args = {"check_same_thread": False} - database.engine = database.create_engine(settings.database_url, pool_pre_ping=True, connect_args=database.connect_args) + database.engine = database.create_engine( + settings.database_url, pool_pre_ping=True, connect_args=database.connect_args + ) database.SessionLocal.configure(bind=database.engine) + from app.core.schema_sync import stamp_head from app.main import create_app from app.models.base import Base Base.metadata.create_all(bind=database.engine) + # Mirrors what the app's own lifespan does for a genuinely fresh database + # (#166): without this, the lifespan's schema-drift check sees an + # unstamped database with every table already present and misreads it as + # drift instead of a fresh test database. + stamp_head(database.engine) with TestClient(create_app()) as test_client: yield test_client @@ -39,4 +121,34 @@ def client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Generator[TestCli os.environ.pop("STORAGE_PATH", None) os.environ.pop("AUTO_CREATE_TABLES", None) os.environ.pop("CORS_ORIGINS", None) + os.environ.pop("ANALYSIS_WORKER_AUTOSTART", None) config.get_settings.cache_clear() + + +@pytest.fixture() +def auth_client(client: TestClient) -> TestClient: + """A ``client`` pre-authenticated as a real registered user. + + A registered user's access token is attached as a default header, so every + request the test makes is authenticated and owner-scoped to that user — the + same posture the frontend has after sign-in. ``client.default_user`` exposes + the user payload for tests that need the id or email. + """ + auth = register_user(client, "primary@example.com") + client.headers.update(auth["headers"]) + client.default_user = auth["user"] # type: ignore[attr-defined] + return client + + +@pytest.fixture() +def make_auth_headers(client: TestClient) -> Callable[[str], dict]: + """Factory that registers an additional user and returns their auth bundle. + + Lets a single test act as two distinct owners behind the same client IP, + which is exactly what the cross-owner and per-user rate-limit tests need. + """ + + def _make(email: str) -> dict: + return register_user(client, email) + + return _make diff --git a/apps/backend/tests/extraction/__init__.py b/apps/backend/tests/extraction/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/backend/tests/extraction/test_base_evidence.py b/apps/backend/tests/extraction/test_base_evidence.py new file mode 100644 index 00000000..0c639ec3 --- /dev/null +++ b/apps/backend/tests/extraction/test_base_evidence.py @@ -0,0 +1,26 @@ +from app.extraction.base import build_evidence + + +def test_valid_span_builds_normalized_evidence(): + ev, diag = build_evidence("src/./auth/service.ts", 41, 58, 100, producer="typescript-ast@1.1.0") + assert diag is None + assert ev.path == "src/auth/service.ts" # normalized + assert (ev.start_line, ev.end_line, ev.logical_line_count) == (41, 58, 100) + + +def test_reversed_or_out_of_range_span_is_rejected(): + ev, diag = build_evidence("a.py", 10, 5, 100, producer="python-ast@1.0.0") + assert ev is None and diag.code == "RI-SPAN-INVALID" and diag.severity == "error" + + ev2, diag2 = build_evidence("a.py", 1, 200, 100, producer="python-ast@1.0.0") + assert ev2 is None and diag2.code == "RI-SPAN-INVALID" + + +def test_escaping_path_is_rejected(): + ev, diag = build_evidence("../secrets/.env", 1, 1, 1, producer="python-ast@1.0.0") + assert ev is None and diag.code == "RI-SEC-PATH-ESCAPE" + + +def test_file_granularity_is_carried_through(): + ev, diag = build_evidence("empty.py", 1, 1, 1, producer="python-ast@1.0.0", granularity="file") + assert diag is None and ev.granularity == "file" diff --git a/apps/backend/tests/extraction/test_base_model.py b/apps/backend/tests/extraction/test_base_model.py new file mode 100644 index 00000000..6f894a49 --- /dev/null +++ b/apps/backend/tests/extraction/test_base_model.py @@ -0,0 +1,44 @@ +import dataclasses + +import pytest + +from app.extraction.base import ( + ExtractedDiagnostic, + ExtractedEvidence, + ExtractedNode, + ExtractedObservation, + ExtractionResult, +) + + +def test_result_types_are_frozen_and_carry_expected_fields(): + ev = ExtractedEvidence(path="src/main.py", start_line=1, end_line=1, logical_line_count=1) + node = ExtractedNode( + node_kind="file", + stable_key="file:src/main.py", + name="main.py", + language="python", + evidence=(ev,), + ) + obs = ExtractedObservation( + observed_kind="import", + subject_kind="file", + subject_key="file:src/main.py", + referent_text="os", + ordinal=1, + evidence=ev, + ) + diag = ExtractedDiagnostic( + code="RI-EXT-UNSUPPORTED", + category="unsupported construct", + severity="info", + message="star import is unsupported", + ) + result = ExtractionResult(nodes=(node,), observations=(obs,), diagnostics=(diag,)) + + assert result.nodes[0].stable_key == "file:src/main.py" + assert result.observations[0].referent_text == "os" + assert result.diagnostics[0].severity == "info" + assert ev.granularity == "span" # default + with pytest.raises(dataclasses.FrozenInstanceError): + node.name = "other" # type: ignore[misc] diff --git a/apps/backend/tests/extraction/test_base_source.py b/apps/backend/tests/extraction/test_base_source.py new file mode 100644 index 00000000..2497733b --- /dev/null +++ b/apps/backend/tests/extraction/test_base_source.py @@ -0,0 +1,41 @@ +from app.extraction.base import decode_source, logical_line_count + + +def test_logical_line_count_matches_rfc_convention(): + assert logical_line_count("") == 1 # empty file = 1 logical line + assert logical_line_count("a") == 1 + assert logical_line_count("a\n") == 2 # trailing newline = final empty line + assert logical_line_count("a\r\nb") == 2 # \r\n counts once (only \n) + assert logical_line_count("a\nb\nc") == 3 + + +def test_decode_source_accepts_utf8_text(): + text, diag = decode_source("src/main.py", b"print('hi')\n", producer="python-ast@1.0.0") + assert text == "print('hi')\n" + assert diag is None + + +def test_decode_source_flags_binary_with_nul_byte(): + text, diag = decode_source("logo.png", b"\x89PNG\x00\x00", producer="python-ast@1.0.0") + assert text is None + assert diag is not None + assert diag.code == "RI-SRC-BINARY" + assert diag.severity == "info" + assert diag.path == "logo.png" + + +def test_decode_source_flags_malformed_utf8_as_error(): + text, diag = decode_source("bad.py", b"\xff\xfe\x00bad", producer="python-ast@1.0.0") + # \x00 present -> binary takes precedence per RFC (NUL => binary) + assert diag.code == "RI-SRC-BINARY" + + text2, diag2 = decode_source("bad2.py", b"\xff\xfeabc", producer="python-ast@1.0.0") + assert text2 is None + assert diag2.code == "RI-SRC-MALFORMED" + assert diag2.severity == "error" + + +def test_empty_file_decodes_to_text_not_binary(): + text, diag = decode_source("empty.py", b"", producer="python-ast@1.0.0") + assert text == "" + assert diag is None diff --git a/apps/backend/tests/extraction/test_capability_registry.py b/apps/backend/tests/extraction/test_capability_registry.py new file mode 100644 index 00000000..a08c9acb --- /dev/null +++ b/apps/backend/tests/extraction/test_capability_registry.py @@ -0,0 +1,74 @@ +"""Focused tests for the authoritative capability registry and README view.""" + +from __future__ import annotations + +from dataclasses import replace +from pathlib import Path + +import pytest + +from app.extraction.support_matrix import ( + CAPABILITY_REGISTRY, + PUBLIC_CAPABILITIES, + README_CAPABILITIES_END, + README_CAPABILITIES_START, + PublicStatus, + SupportStatus, + check_readme_capabilities, + render_readme_capabilities, + validate_registry, +) + + +def test_registry_is_typed_unique_and_deterministically_ordered(): + validate_registry() + ids = [item.id for item in CAPABILITY_REGISTRY] + assert ids == sorted(ids) + assert len(ids) == len(set(ids)) + assert all(isinstance(item.status, SupportStatus) for item in CAPABILITY_REGISTRY) + + +def test_registry_rejects_duplicate_ids_and_unknown_status(): + with pytest.raises(ValueError, match="duplicate capability ids"): + validate_registry((CAPABILITY_REGISTRY[0], CAPABILITY_REGISTRY[0])) + + broken = CAPABILITY_REGISTRY[0] + broken = broken.__class__( + id=broken.id, + language=broken.language, + construct=broken.construct, + status="unknown", # type: ignore[arg-type] + description=broken.description, + limitation=broken.limitation, + benchmark_ids=broken.benchmark_ids, + ) + with pytest.raises(ValueError, match="unsupported status"): + validate_registry((broken,)) + + +def test_public_claims_resolve_to_consistent_registry_capabilities(): + validate_registry() + assert all(isinstance(item.status, PublicStatus) for item in PUBLIC_CAPABILITIES) + + broken = replace(PUBLIC_CAPABILITIES[0], capability_ids=("product.missing",)) + with pytest.raises(ValueError, match="references unknown capability"): + validate_registry(public_capabilities=(broken,)) + + +def test_readme_capability_rendering_is_byte_stable_and_checked_in(): + assert render_readme_capabilities() == render_readme_capabilities() + readme = Path(__file__).parents[4] / "README.md" + check_readme_capabilities(readme) + block = readme.read_text(encoding="utf-8") + assert block.count(README_CAPABILITIES_START) == 1 + assert block.count(README_CAPABILITIES_END) == 1 + + +def test_stale_readme_capability_claims_fail_check(tmp_path: Path): + readme = tmp_path / "README.md" + content = (Path(__file__).parents[4] / "README.md").read_text(encoding="utf-8") + readme.write_text( + content.replace("Archive upload and public GitHub import", "Changed capability", 1), encoding="utf-8" + ) + with pytest.raises(ValueError, match="README capability registry block is stale"): + check_readme_capabilities(readme) diff --git a/apps/backend/tests/extraction/test_dependency_manifests.py b/apps/backend/tests/extraction/test_dependency_manifests.py new file mode 100644 index 00000000..9881ecba --- /dev/null +++ b/apps/backend/tests/extraction/test_dependency_manifests.py @@ -0,0 +1,172 @@ +import pytest + +from app.extraction.manifests import DependencyManifestExtractor + + +EXTRACTOR = DependencyManifestExtractor() + + +def _extract(path: str, source: str): + return EXTRACTOR.extract(path, source.encode("utf-8")) + + +def _lines_by_key(result): + return sorted((obs.subject_key, obs.evidence.start_line) for obs in result.observations) + + +def test_package_json_dependencies_are_observed_dependency_nodes(): + result = _extract( + "package.json", + '{"dependencies":{"react":"^18"},"devDependencies":{"vite":"^5"}}\n', + ) + assert {node.stable_key for node in result.nodes} == {"dep:npm:react", "dep:npm:vite"} + assert {(obs.subject_key, obs.referent_text) for obs in result.observations} == { + ("dep:npm:react", "react"), + ("dep:npm:vite", "vite"), + } + assert {obs.evidence.start_line for obs in result.observations} == {1} + + +def test_python_manifests_pep503_normalize_dependency_keys(): + pyproject = _extract( + "pyproject.toml", + '[project]\ndependencies = ["Fast_API>=1.0", "httpx[socks]>=0.1"]\n', + ) + requirements = _extract("requirements.txt", "Fast_API>=1.0\nhttpx[socks]>=0.1\n") + assert {node.stable_key for node in pyproject.nodes} == {"dep:pypi:fast-api", "dep:pypi:httpx"} + assert {node.stable_key for node in requirements.nodes} == {"dep:pypi:fast-api", "dep:pypi:httpx"} + + +def test_malformed_manifest_is_a_visible_diagnostic(): + result = _extract("package.json", "{") + assert result.nodes == () + assert [diagnostic.code for diagnostic in result.diagnostics] == ["RI-SRC-MALFORMED"] + + +# --- Finding 4: exact declaration spans ------------------------------------- + + +def test_npm_dependency_line_ignores_earlier_description_and_script_matches(): + # "react" appears in the package name, the description, and a script named + # "react" — all before the real dependency declaration on line 6. + source = """{ + "name": "react", + "description": "built with react", + "scripts": { "react": "react -w" }, + "dependencies": { + "react": "^18" + } +} +""" + result = _extract("package.json", source) + observations = [obs for obs in result.observations if obs.subject_key == "dep:npm:react"] + assert len(observations) == 1 + assert observations[0].evidence.start_line == 6 + + +def test_npm_dependency_in_multiple_sections_points_at_each_section_line(): + source = """{ + "dependencies": { + "react": "^18" + }, + "devDependencies": { + "react": "^17" + } +} +""" + result = _extract("package.json", source) + lines = sorted(obs.evidence.start_line for obs in result.observations if obs.subject_key == "dep:npm:react") + assert lines == [3, 6] + + +def test_pyproject_multiline_dependency_array_points_at_each_element(): + # A project named "flask" and a description mentioning "django" precede the + # array; brackets inside "httpx[socks]" must not shift element lines. + source = """[project] +name = "flask" +description = "uses django" +dependencies = [ + "django>=4", + "flask", + "httpx[socks]>=0.1", +] +""" + result = _extract("pyproject.toml", source) + assert _lines_by_key(result) == [ + ("dep:pypi:django", 5), + ("dep:pypi:flask", 6), + ("dep:pypi:httpx", 7), + ] + + +def test_requirements_declarations_stay_on_their_own_line(): + result = _extract("requirements.txt", "# comment\ndjango>=4\n-e .\nflask\n") + assert _lines_by_key(result) == [("dep:pypi:django", 2), ("dep:pypi:flask", 4)] + + +def test_manifest_declarations_retain_exact_version_type_and_workspace_provenance(): + result = _extract( + "apps/frontend/package.json", + """{ + "devDependencies": { + "vite": "^5" + } +} +""", + ) + + node = result.nodes[0] + assert node.properties == { + "ecosystem": "npm", + "version": "^5", + "dependency_type": "development", + "manifest_path": "apps/frontend/package.json", + "workspace_path": "apps/frontend", + } + assert node.evidence[0].start_line == node.evidence[0].end_line == 3 + assert EXTRACTOR.producer == "dependency-manifest@1.2.0" + + +def test_requirements_url_fragments_are_retained_in_direct_reference_specifiers(): + result = _extract( + "requirements.txt", + "example @ https://host.example/archive.whl#sha256=first\n" + "example @ https://host.example/archive.whl#sha256=second\n" + "other>=1 # an ordinary comment\n", + ) + + declarations = [node.properties["version"] for node in result.nodes if node.name == "example"] + assert declarations == [ + "@ https://host.example/archive.whl#sha256=first", + "@ https://host.example/archive.whl#sha256=second", + ] + assert next(node for node in result.nodes if node.name == "other").properties["version"] == ">=1" + + +# --- Finding 5: fail closed on structurally invalid manifests --------------- + + +@pytest.mark.parametrize( + "path,source", + [ + ("package.json", "[]"), + ("package.json", '"invalid"'), + ("package.json", "42"), + ("package.json", '{"dependencies": []}'), + ("package.json", '{"dependencies": {"react": 18}}'), + ("package.json", '{"peerDependencies": "react"}'), + ("pyproject.toml", 'project = "invalid"'), + ("pyproject.toml", "[project]\ndependencies = {}\n"), + ("pyproject.toml", '[project]\ndependencies = "django"\n'), + ("pyproject.toml", "[project]\ndependencies = [123]\n"), + ], +) +def test_structurally_invalid_manifest_fails_closed(path, source): + # No structurally invalid shape may escape as an AttributeError/TypeError or + # degrade to a silent empty dependency list; each is a visible diagnostic + # that names the normalized repository path. + result = _extract(path, source) + assert result.nodes == () + assert result.observations == () + assert [diagnostic.code for diagnostic in result.diagnostics] == ["RI-SRC-MALFORMED"] + assert result.diagnostics[0].path == path diff --git a/apps/backend/tests/extraction/test_dependency_resolution_merge.py b/apps/backend/tests/extraction/test_dependency_resolution_merge.py new file mode 100644 index 00000000..4d3e0408 --- /dev/null +++ b/apps/backend/tests/extraction/test_dependency_resolution_merge.py @@ -0,0 +1,301 @@ +"""Manifest declarations and lockfile resolutions on one identity (#209). + +A dependency is one node. A manifest says what was asked for, a lockfile says +what was installed, and both must land on that node without either standing in +for the other and without a second stable-key record appearing. +""" + +from __future__ import annotations + +from app.extraction.base import ExtractedEvidence, ExtractedNode, ExtractionResult +from app.extraction.dependencies import DEPENDENCY_SET_ARRAY_KEYS, merge_dependency_facts +from app.extraction.lockfiles import LockfileExtractor +from app.extraction.manifests import DependencyManifestExtractor +from app.extraction.pipeline import ProducedExtraction + +MANIFEST = (DependencyManifestExtractor.name, DependencyManifestExtractor.version) +LOCKFILE = (LockfileExtractor.name, LockfileExtractor.version) + + +def _declaration(stable_key: str, *, name: str, manifest_path: str, version: str, line: int = 1) -> ExtractedNode: + return ExtractedNode( + node_kind="dependency", + stable_key=stable_key, + name=name, + language=None, + evidence=( + ExtractedEvidence(path=manifest_path, start_line=line, end_line=line, logical_line_count=max(line, 1)), + ), + properties={ + "ecosystem": stable_key.split(":")[1], + "version": version, + "dependency_type": "production", + "manifest_path": manifest_path, + "workspace_path": manifest_path.rsplit("/", 1)[0] if "/" in manifest_path else ".", + }, + ) + + +def _resolution( + stable_key: str, *, name: str, lockfile_path: str, version: str, entry: str, line: int = 1 +) -> ExtractedNode: + return ExtractedNode( + node_kind="dependency", + stable_key=stable_key, + name=name, + language=None, + evidence=( + ExtractedEvidence(path=lockfile_path, start_line=line, end_line=line, logical_line_count=max(line, 1)), + ), + properties={ + "ecosystem": stable_key.split(":")[1], + "resolved_version": version, + "dependency_scope": "production", + "lockfile_path": lockfile_path, + "lockfile_format": "npm-package-lock", + "lockfile_entry": entry, + "workspace_path": lockfile_path.rsplit("/", 1)[0] if "/" in lockfile_path else ".", + }, + ) + + +def _produced(*nodes: ExtractedNode, producer: tuple[str, str]) -> ProducedExtraction: + return ProducedExtraction(producer[0], producer[1], ExtractionResult(nodes=tuple(nodes))) + + +def _merged(produced): + merged = merge_dependency_facts(produced) + by_key: dict[str, ExtractedNode] = {} + for item in merged: + for node in item.result.nodes: + if node.node_kind == "dependency": + by_key.setdefault(node.stable_key, node) + return merged, by_key + + +def test_a_declaration_and_a_resolution_share_one_node_without_replacing_each_other(): + produced = ( + _produced( + _declaration("dep:npm:react", name="react", manifest_path="package.json", version="^18.3.0", line=4), + producer=MANIFEST, + ), + _produced( + _resolution( + "dep:npm:react", + name="react", + lockfile_path="package-lock.json", + version="18.3.1", + entry="node_modules/react", + line=9, + ), + producer=LOCKFILE, + ), + ) + + _, by_key = _merged(produced) + + node = by_key["dep:npm:react"] + assert [item["version"] for item in node.properties["declarations"]] == ["^18.3.0"] + assert [item["resolved_version"] for item in node.properties["resolutions"]] == ["18.3.1"] + # A caret range is not an installed version; neither collection is allowed + # to stand in for the other. + assert "resolved_version" not in node.properties["declarations"][0] + assert "version" not in node.properties["resolutions"][0] + + +def test_a_dependency_with_no_lockfile_pin_reports_an_empty_resolution_list(): + """ "Not resolved" and "never looked at" must not be indistinguishable.""" + + produced = ( + _produced( + _declaration("dep:pypi:requests", name="requests", manifest_path="pyproject.toml", version="==2.31.0"), + producer=MANIFEST, + ), + ) + + _, by_key = _merged(produced) + + assert by_key["dep:pypi:requests"].properties["resolutions"] == [] + assert len(by_key["dep:pypi:requests"].properties["declarations"]) == 1 + + +def test_a_lockfile_only_dependency_reports_an_empty_declaration_list(): + produced = ( + _produced( + _resolution( + "dep:npm:tiny", + name="tiny", + lockfile_path="package-lock.json", + version="0.1.0", + entry="node_modules/a/node_modules/tiny", + ), + producer=LOCKFILE, + ), + ) + + _, by_key = _merged(produced) + + # A lockfile entry proves an installed version, not a direct dependency. + assert by_key["dep:npm:tiny"].properties["declarations"] == [] + assert len(by_key["dep:npm:tiny"].properties["resolutions"]) == 1 + + +def test_two_installed_versions_of_one_package_are_two_resolutions_of_one_node(): + produced = ( + _produced( + _resolution( + "dep:npm:left-pad", + name="left-pad", + lockfile_path="package-lock.json", + version="1.3.0", + entry="node_modules/left-pad", + line=8, + ), + _resolution( + "dep:npm:left-pad", + name="left-pad", + lockfile_path="package-lock.json", + version="1.2.0", + entry="node_modules/util/node_modules/left-pad", + line=15, + ), + producer=LOCKFILE, + ), + ) + + merged, by_key = _merged(produced) + + assert len(by_key) == 1 + assert [item["resolved_version"] for item in by_key["dep:npm:left-pad"].properties["resolutions"]] == [ + "1.3.0", + "1.2.0", + ] + emissions = [node for item in merged for node in item.result.nodes if node.node_kind == "dependency"] + assert len(emissions) == 1 + + +def test_each_producer_is_credited_only_with_the_evidence_it_read(): + produced = ( + _produced( + _declaration("dep:npm:react", name="react", manifest_path="package.json", version="^18.3.0", line=4), + producer=MANIFEST, + ), + _produced( + _resolution( + "dep:npm:react", + name="react", + lockfile_path="package-lock.json", + version="18.3.1", + entry="node_modules/react", + line=9, + ), + producer=LOCKFILE, + ), + ) + + merged = merge_dependency_facts(produced) + + evidence_by_producer = { + (item.producer_name, item.producer_version): [ + (record.path, record.start_line) + for node in item.result.nodes + if node.node_kind == "dependency" + for record in node.evidence + ] + for item in merged + if any(node.node_kind == "dependency" for node in item.result.nodes) + } + assert evidence_by_producer[MANIFEST] == [("package.json", 4)] + assert evidence_by_producer[LOCKFILE] == [("package-lock.json", 9)] + # Every emission of the node must agree on its content, or ``add_node`` + # would reject the second write as a conflicting record. + records = { + (node.name, node.language, str(node.properties)) + for item in merged + for node in item.result.nodes + if node.node_kind == "dependency" + } + assert len(records) == 1 + + +def test_a_declared_dependency_keeps_the_name_its_manifest_spelled(): + """The manifest spelling is what a reader of the repository recognizes.""" + + produced = ( + _produced( + _resolution( + "dep:pypi:requests-toolbelt", + name="Requests_Toolbelt", + lockfile_path="poetry.lock", + version="1.0.0", + entry="Requests_Toolbelt", + ), + producer=LOCKFILE, + ), + _produced( + _declaration( + "dep:pypi:requests-toolbelt", + name="requests-toolbelt", + manifest_path="pyproject.toml", + version=">=1.0", + ), + producer=MANIFEST, + ), + ) + + _, by_key = _merged(produced) + + assert by_key["dep:pypi:requests-toolbelt"].name == "requests-toolbelt" + + +def test_merge_output_does_not_depend_on_the_order_files_were_read_in(): + manifest_a = _produced( + _declaration("dep:npm:react", name="react", manifest_path="apps/admin/package.json", version="^18.3.0"), + producer=MANIFEST, + ) + manifest_b = _produced( + _declaration("dep:npm:react", name="react", manifest_path="apps/web/package.json", version="^18.2.0"), + producer=MANIFEST, + ) + lock = _produced( + _resolution( + "dep:npm:react", + name="react", + lockfile_path="package-lock.json", + version="18.3.1", + entry="node_modules/react", + ), + producer=LOCKFILE, + ) + + forward = _merged((manifest_a, manifest_b, lock))[1]["dep:npm:react"] + reverse = _merged((lock, manifest_b, manifest_a))[1]["dep:npm:react"] + + assert forward.properties == reverse.properties + assert [item["manifest_path"] for item in forward.properties["declarations"]] == [ + "apps/admin/package.json", + "apps/web/package.json", + ] + + +def test_both_merged_collections_are_declared_set_arrays(): + """Canonical serialization must sort them, or the graph hash would drift.""" + + assert DEPENDENCY_SET_ARRAY_KEYS == frozenset({"declarations", "resolutions"}) + + +def test_an_unknown_producer_claiming_a_dependency_key_is_left_to_fail(): + """A real identity disagreement must not be papered over by the merge.""" + + produced = ( + _produced( + _declaration("dep:npm:react", name="react", manifest_path="package.json", version="^18.3.0"), + producer=MANIFEST, + ), + _produced( + _declaration("dep:npm:react", name="react", manifest_path="other.json", version="^18.3.0"), + producer=("some-other-extractor", "9.9.9"), + ), + ) + + assert merge_dependency_facts(produced) == produced diff --git a/apps/backend/tests/extraction/test_diagnostic_content.py b/apps/backend/tests/extraction/test_diagnostic_content.py new file mode 100644 index 00000000..db1fe976 --- /dev/null +++ b/apps/backend/tests/extraction/test_diagnostic_content.py @@ -0,0 +1,62 @@ +"""RFC-0001 §13: diagnostic `message`/`details` must not embed source content. + +A diagnostic names *what* was unsupported and cites *where* via path + span. It +must never carry the source text itself, because that text can contain secrets +and diagnostics are stored and surfaced. The span is the pointer; the source +stays in the repository. +""" + +import pytest + +from app.extraction.python import PythonExtractor +from app.extraction.typescript import TypeScriptExtractor + +SECRET = "s3cr3t-token" + +# Sources that put a secret literal directly next to a construct that produces a +# diagnostic, in each place an extractor is tempted to quote the source back. +TYPESCRIPT_LEAK_CASES = [ + ("decorator argument", f'class A {{\n @sealed("{SECRET}")\n x = 1;\n}}\n'), + ("decorator on class", f'@Injectable({{ key: "{SECRET}" }})\nclass B {{}}\n'), + ("namespace body", f'namespace N {{ const k = "{SECRET}"; }}\n'), + ("require argument", f'const c = require("{SECRET}");\n'), + ("dynamic import argument", f'const m = import("./{SECRET}");\n'), +] + +PYTHON_LEAK_CASES = [ + ("star import", f"from {SECRET.replace('-', '_')} import *\n"), + ("metaclass", f"class A(metaclass={SECRET.replace('-', '_')}):\n pass\n"), + ("reflection argument", f'x = getattr(o, "{SECRET}", None)\n'), + ("dynamic import argument", f'import importlib\nm = importlib.import_module("{SECRET}")\n'), +] + + +@pytest.mark.parametrize("label,source", TYPESCRIPT_LEAK_CASES, ids=[c[0] for c in TYPESCRIPT_LEAK_CASES]) +def test_typescript_diagnostics_do_not_embed_source(label, source): + result = TypeScriptExtractor().extract("src/a.ts", source.encode("utf-8")) + assert result.diagnostics, f"{label}: expected a diagnostic to exist to make this test meaningful" + for diagnostic in result.diagnostics: + assert SECRET not in diagnostic.message, f"{label}: secret leaked into message" + assert SECRET not in str(diagnostic.details or ""), f"{label}: secret leaked into details" + + +@pytest.mark.parametrize("label,source", PYTHON_LEAK_CASES, ids=[c[0] for c in PYTHON_LEAK_CASES]) +def test_python_diagnostics_do_not_embed_source(label, source): + marker = SECRET.replace("-", "_") + result = PythonExtractor().extract("app/a.py", source.encode("utf-8")) + assert result.diagnostics, f"{label}: expected a diagnostic to exist to make this test meaningful" + for diagnostic in result.diagnostics: + assert marker not in diagnostic.message, f"{label}: source identifier leaked into message" + assert SECRET not in diagnostic.message, f"{label}: secret leaked into message" + assert SECRET not in str(diagnostic.details or ""), f"{label}: secret leaked into details" + + +def test_diagnostics_still_cite_a_span_for_context(): + # Dropping source text from the message is only acceptable because the span + # still says exactly where to look. + result = TypeScriptExtractor().extract("src/a.ts", b'class A {\n @sealed("x")\n y = 1;\n}\n') + unsupported = [d for d in result.diagnostics if d.code == "RI-EXT-UNSUPPORTED"] + assert unsupported + for diagnostic in unsupported: + assert diagnostic.path == "src/a.ts" + assert diagnostic.span is not None diff --git a/apps/backend/tests/extraction/test_iac.py b/apps/backend/tests/extraction/test_iac.py new file mode 100644 index 00000000..20ab0a71 --- /dev/null +++ b/apps/backend/tests/extraction/test_iac.py @@ -0,0 +1,221 @@ +"""Docker Compose resource extraction (#209). + +An IaC resource fact claims that a named piece of infrastructure is declared at +an exact place in an exact manifest. These tests pin that claim, and pin the +boundary where a value stops being observed and becomes a template. +""" + +from __future__ import annotations + +import pytest + +from app.extraction.base import ( + RI_EXT_UNSUPPORTED, + RI_SEC_PATH_ESCAPE, + RI_SRC_BINARY, + RI_SRC_MALFORMED, +) +from app.extraction.iac import COMPOSE_RESOURCE_SECTIONS, IacExtractor +from app.extraction.support_matrix import supported_iac_filenames + +COMPOSE = b"""services: + api: + image: python:3.13-slim + ports: + - "8000:8000" + worker: + image: ${WORKER_IMAGE} + bare: +volumes: + pgdata: +networks: + backend: + driver: bridge +""" + + +def _extract(path: str, source: bytes): + return IacExtractor().extract(path, source) + + +def _nodes(result): + return {node.stable_key: node for node in result.nodes} + + +# --- supports() ------------------------------------------------------------- + + +def test_only_the_documented_compose_filenames_are_read(): + extractor = IacExtractor() + + assert set(supported_iac_filenames()) == { + "compose.yaml", + "compose.yml", + "docker-compose.yaml", + "docker-compose.yml", + } + assert extractor.supports("deploy/docker-compose.yml") + assert extractor.supports("compose.yaml") + # Treating an arbitrary YAML file as infrastructure would turn any document + # with a ``services:`` key into a fabricated resource claim. + assert not extractor.supports("config/settings.yaml") + assert not extractor.supports("main.tf") + assert not extractor.supports("k8s/deployment.yaml") + assert not extractor.supports("Chart.yaml") + + +# --- resources -------------------------------------------------------------- + + +def test_declared_resources_carry_their_type_name_and_exact_line(): + result = _extract("deploy/docker-compose.yml", COMPOSE) + nodes = _nodes(result) + + assert set(COMPOSE_RESOURCE_SECTIONS.values()) == {"service", "volume", "network"} + assert nodes["iac:deploy/docker-compose.yml::service/api"].evidence[0].start_line == 2 + assert nodes["iac:deploy/docker-compose.yml::service/worker"].evidence[0].start_line == 6 + assert nodes["iac:deploy/docker-compose.yml::service/bare"].evidence[0].start_line == 8 + assert nodes["iac:deploy/docker-compose.yml::volume/pgdata"].evidence[0].start_line == 10 + assert nodes["iac:deploy/docker-compose.yml::network/backend"].evidence[0].start_line == 12 + for node in result.nodes: + assert node.node_kind == "iac_resource" + assert node.evidence[0].start_line == node.evidence[0].end_line + assert node.properties["manifest_path"] == "deploy/docker-compose.yml" + assert node.properties["manifest_format"] == "docker-compose" + + +def test_identity_includes_the_manifest_so_two_files_declare_two_resources(): + first = _extract("a/docker-compose.yml", b"services:\n db:\n image: postgres:17\n") + second = _extract("b/docker-compose.yml", b"services:\n db:\n image: postgres:17\n") + + assert first.nodes[0].stable_key == "iac:a/docker-compose.yml::service/db" + assert second.nodes[0].stable_key == "iac:b/docker-compose.yml::service/db" + + +def test_a_literal_image_is_recorded_and_a_templated_one_is_disclosed(): + result = _extract("deploy/docker-compose.yml", COMPOSE) + nodes = _nodes(result) + + assert nodes["iac:deploy/docker-compose.yml::service/api"].properties["image"] == "python:3.13-slim" + # The property is withheld rather than storing "${WORKER_IMAGE}" as if it + # were the image that actually runs. + assert "image" not in nodes["iac:deploy/docker-compose.yml::service/worker"].properties + templated = [ + diagnostic for diagnostic in result.diagnostics if diagnostic.message == "templated IaC value is unsupported" + ] + assert len(templated) == 1 + assert templated[0].code == RI_EXT_UNSUPPORTED + assert templated[0].span == (6, 6) + assert templated[0].subject == "iac:deploy/docker-compose.yml::service/worker" + + +@pytest.mark.parametrize("image", [b"${TAG}", b"registry/app:${TAG}", b"$TAG", b"${TAG:-latest}"]) +def test_every_interpolation_form_withholds_the_image(image): + result = _extract("docker-compose.yml", b"services:\n api:\n image: " + image + b"\n") + + assert "image" not in result.nodes[0].properties + assert any(diagnostic.code == RI_EXT_UNSUPPORTED for diagnostic in result.diagnostics) + + +def test_an_escaped_dollar_is_a_literal_not_a_template(): + result = _extract("docker-compose.yml", b"services:\n api:\n image: app:$$literal\n") + + assert result.nodes[0].properties["image"] == "app:$$literal" + assert result.diagnostics == () + + +def test_a_service_without_a_body_is_still_a_declared_resource(): + nodes = _nodes(_extract("deploy/docker-compose.yml", COMPOSE)) + + bare = nodes["iac:deploy/docker-compose.yml::service/bare"] + assert bare.name == "bare" + assert "image" not in bare.properties + + +def test_unsupported_compose_sections_contribute_no_resources(): + result = _extract( + "docker-compose.yml", + b"configs:\n app:\n file: ./app.conf\nsecrets:\n token:\n file: ./token\n", + ) + + assert result.nodes == () + assert result.diagnostics == () + + +# --- observations ----------------------------------------------------------- + + +def test_each_resource_emits_a_typed_observation_about_itself(): + result = _extract("docker-compose.yml", b"services:\n api:\n image: nginx:1.27\nvolumes:\n data:\n") + + assert [ + (item.observed_kind, item.subject_kind, item.subject_key, item.referent_text, item.ordinal) + for item in result.observations + ] == [ + ("iac_resource", "iac_resource", "iac:docker-compose.yml::service/api", "service/api", 1), + ("iac_resource", "iac_resource", "iac:docker-compose.yml::volume/data", "volume/data", 1), + ] + + +# --- failure paths ---------------------------------------------------------- + + +@pytest.mark.parametrize( + "source", + [ + b"services:\n api:\n image: x\n bad\n", + b"services:\n - api\n", + b"- just\n- a\n- list\n", + b"services:\n api: nginx:1.27\n", + b"services:\n ? [a, b]\n : value\n", + b"---\nservices:\n a:\n image: x\n---\nservices:\n b:\n image: y\n", + ], +) +def test_structurally_invalid_manifests_fail_closed_with_one_diagnostic(source): + result = _extract("docker-compose.yml", source) + + assert result.nodes == () + assert result.observations == () + assert [diagnostic.code for diagnostic in result.diagnostics] == [RI_SRC_MALFORMED] + + +def test_an_empty_manifest_claims_nothing_without_complaining(): + result = _extract("docker-compose.yml", b"") + + assert result.nodes == () + assert result.diagnostics == () + + +def test_a_binary_manifest_is_excluded_from_line_addressed_extraction(): + result = _extract("docker-compose.yml", b"services:\x00\n") + + assert result.nodes == () + assert [diagnostic.code for diagnostic in result.diagnostics] == [RI_SRC_BINARY] + + +def test_undecodable_bytes_are_reported_as_malformed_source(): + result = _extract("docker-compose.yml", b"\xff\xfeservices:") + + assert result.nodes == () + assert [diagnostic.code for diagnostic in result.diagnostics] == [RI_SRC_MALFORMED] + + +def test_a_path_escaping_the_repository_root_is_rejected_before_extraction(): + result = _extract("../../docker-compose.yml", COMPOSE) + + assert result.nodes == () + assert [diagnostic.code for diagnostic in result.diagnostics] == [RI_SEC_PATH_ESCAPE] + + +# --- determinism ------------------------------------------------------------ + + +def test_repeated_extraction_is_byte_identical_and_key_order_independent(): + ordered = b"services:\n api:\n image: a:1\n web:\n image: b:2\n" + assert _extract("docker-compose.yml", ordered) == _extract("docker-compose.yml", ordered) + + reordered = b"services:\n web:\n image: b:2\n api:\n image: a:1\n" + # Emission order is a function of identity, so only the spans differ. + assert [node.stable_key for node in _extract("docker-compose.yml", ordered).nodes] == [ + node.stable_key for node in _extract("docker-compose.yml", reordered).nodes + ] diff --git a/apps/backend/tests/extraction/test_lockfiles.py b/apps/backend/tests/extraction/test_lockfiles.py new file mode 100644 index 00000000..8fcb9042 --- /dev/null +++ b/apps/backend/tests/extraction/test_lockfiles.py @@ -0,0 +1,305 @@ +"""Resolved-version extraction from supported lockfiles (#209). + +A lockfile fact is only useful if it is *exact*: the right pin, attributed to +the right logical dependency, at the right line. These tests pin all three, and +pin equally hard the cases where nothing may be claimed at all. +""" + +from __future__ import annotations + +import pytest + +from app.extraction.base import ( + RI_EXT_UNSUPPORTED, + RI_SEC_PATH_ESCAPE, + RI_SRC_BINARY, + RI_SRC_MALFORMED, +) +from app.extraction.lockfiles import ( + SUPPORTED_NPM_LOCKFILE_VERSIONS, + SUPPORTED_POETRY_LOCK_MAJORS, + LockfileExtractor, +) +from app.extraction.support_matrix import supported_lockfile_filenames + +NPM_LOCK = b"""{ + "name": "web", + "lockfileVersion": 3, + "packages": { + "": { + "name": "web" + }, + "node_modules/@scope/pkg": { + "version": "2.0.1", + "dev": true + }, + "node_modules/left-pad": { + "version": "1.3.0" + }, + "node_modules/maybe": { + "version": "0.4.0", + "optional": true + }, + "node_modules/util/node_modules/left-pad": { + "version": "1.2.0" + }, + "node_modules/workspace-link": { + "resolved": "packages/app", + "link": true + }, + "packages/app": { + "name": "@web/app", + "version": "1.0.0" + } + } +} +""" + +POETRY_LOCK = b"""[[package]] +name = "Requests_Toolbelt" +version = "1.0.0" +optional = false + +[[package]] +name = "pytest" +version = "8.3.4" +category = "dev" +description = \"\"\" +A description that mentions [[package]] on purpose. +\"\"\" + +[metadata] +lock-version = "2.0" +""" + + +def _nodes(result): + """Index by stable key, keeping the first emission. + + One key can legitimately be emitted twice — a nested npm tree resolves the + same dependency to a second version — so this must not silently keep the + last one. Tests about multiple resolutions read ``result.nodes`` directly. + """ + + indexed: dict[str, object] = {} + for node in result.nodes: + indexed.setdefault(node.stable_key, node) + return indexed + + +def _extract(path: str, source: bytes): + return LockfileExtractor().extract(path, source) + + +# --- supports() ------------------------------------------------------------- + + +def test_supported_filenames_come_from_the_capability_registry(): + extractor = LockfileExtractor() + + assert set(supported_lockfile_filenames()) == {"package-lock.json", "poetry.lock"} + assert extractor.supports("package-lock.json") + assert extractor.supports("apps/web/poetry.lock") + # Formats the registry declares out of scope are never opened at all, so + # they can never produce a half-understood resolution. + assert not extractor.supports("yarn.lock") + assert not extractor.supports("pnpm-lock.yaml") + assert not extractor.supports("Pipfile.lock") + assert not extractor.supports("uv.lock") + + +# --- npm -------------------------------------------------------------------- + + +def test_npm_lockfile_pins_exact_versions_at_their_own_declaration_lines(): + result = _extract("package-lock.json", NPM_LOCK) + nodes = _nodes(result) + + assert nodes["dep:npm:@scope/pkg"].properties["resolved_version"] == "2.0.1" + assert nodes["dep:npm:left-pad"].properties["resolved_version"] == "1.3.0" + # The line is the entry's own key inside ``packages``, not a substring hit. + assert nodes["dep:npm:@scope/pkg"].evidence[0].start_line == 8 + assert nodes["dep:npm:left-pad"].evidence[0].start_line == 12 + for node in result.nodes: + evidence = node.evidence[0] + assert evidence.start_line == evidence.end_line + assert evidence.path == "package-lock.json" + + +def test_npm_tree_flags_map_onto_the_manifest_dependency_vocabulary(): + nodes = _nodes(_extract("package-lock.json", NPM_LOCK)) + + assert nodes["dep:npm:left-pad"].properties["dependency_scope"] == "production" + assert nodes["dep:npm:@scope/pkg"].properties["dependency_scope"] == "development" + assert nodes["dep:npm:maybe"].properties["dependency_scope"] == "optional" + + +def test_a_nested_tree_entry_is_a_second_resolution_of_one_dependency(): + """``node_modules/util/node_modules/left-pad`` is left-pad, not a new package.""" + + result = _extract("package-lock.json", NPM_LOCK) + left_pad = [node for node in result.nodes if node.stable_key == "dep:npm:left-pad"] + + assert len(left_pad) == 2 + assert {node.properties["resolved_version"] for node in left_pad} == {"1.3.0", "1.2.0"} + assert {node.properties["lockfile_entry"] for node in left_pad} == { + "node_modules/left-pad", + "node_modules/util/node_modules/left-pad", + } + + +def test_workspace_and_root_entries_are_not_resolved_registry_versions(): + nodes = _nodes(_extract("package-lock.json", NPM_LOCK)) + + # ``"link": true`` is a symlink into the repository, and ``packages/app`` is + # the workspace directory itself — neither is an installed registry version. + assert "dep:npm:workspace-link" not in nodes + assert "dep:npm:@web/app" not in nodes + assert "dep:npm:web" not in nodes + + +def test_an_entry_without_a_concrete_version_claims_nothing(): + result = _extract( + "package-lock.json", + b'{"lockfileVersion": 3, "packages": {"node_modules/ghost": {"resolved": "https://x/y"}}}', + ) + + assert result.nodes == () + assert result.observations == () + + +@pytest.mark.parametrize("lockfile_version", [1, 4, 0]) +def test_an_unsupported_npm_lockfile_version_is_disclosed_not_parsed(lockfile_version): + source = b'{"lockfileVersion": %d, "dependencies": {"left-pad": {"version": "1.3.0"}}}' % lockfile_version + + result = _extract("package-lock.json", source) + + assert result.nodes == () + assert [diagnostic.code for diagnostic in result.diagnostics] == [RI_EXT_UNSUPPORTED] + # Well-formed JSON in an unsupported revision is not malformed source, and + # saying so would be a false claim about the file. + assert str(list(SUPPORTED_NPM_LOCKFILE_VERSIONS)) in result.diagnostics[0].message + assert result.diagnostics[0].subject == "file:package-lock.json" + + +# --- poetry ----------------------------------------------------------------- + + +def test_poetry_lockfile_pins_versions_and_folds_names_per_pep_503(): + result = _extract("poetry.lock", POETRY_LOCK) + nodes = _nodes(result) + + # ``Requests_Toolbelt`` and a manifest's ``requests-toolbelt`` are one entity. + assert nodes["dep:pypi:requests-toolbelt"].properties["resolved_version"] == "1.0.0" + assert nodes["dep:pypi:requests-toolbelt"].name == "Requests_Toolbelt" + assert nodes["dep:pypi:pytest"].properties["resolved_version"] == "8.3.4" + + +def test_poetry_headers_are_located_through_strings_not_by_line_matching(): + """A ``[[package]]`` inside a description must not shift every later line.""" + + nodes = _nodes(_extract("poetry.lock", POETRY_LOCK)) + + assert nodes["dep:pypi:requests-toolbelt"].evidence[0].start_line == 1 + assert nodes["dep:pypi:pytest"].evidence[0].start_line == 6 + + +def test_poetry_reports_an_unknown_group_rather_than_guessing_production(): + nodes = _nodes(_extract("poetry.lock", POETRY_LOCK)) + + # lock-version 2 dropped ``category``; this entry genuinely does not say. + assert nodes["dep:pypi:requests-toolbelt"].properties["dependency_scope"] is None + assert nodes["dep:pypi:pytest"].properties["dependency_scope"] == "development" + + +def test_an_unsupported_poetry_lock_version_is_disclosed_not_parsed(): + source = b'[[package]]\nname = "a"\nversion = "1.0"\n\n[metadata]\nlock-version = "9.0"\n' + + result = _extract("poetry.lock", source) + + assert result.nodes == () + assert [diagnostic.code for diagnostic in result.diagnostics] == [RI_EXT_UNSUPPORTED] + assert str(list(SUPPORTED_POETRY_LOCK_MAJORS)) in result.diagnostics[0].message + + +# --- observations ----------------------------------------------------------- + + +def test_each_resolution_emits_an_observation_carrying_the_pinned_version(): + result = _extract("poetry.lock", POETRY_LOCK) + + observations = [ + (observation.observed_kind, observation.subject_key, observation.referent_text, observation.ordinal) + for observation in result.observations + ] + assert observations == [ + ("resolution", "dep:pypi:requests-toolbelt", "1.0.0", 1), + ("resolution", "dep:pypi:pytest", "8.3.4", 1), + ] + for observation in result.observations: + assert observation.subject_kind == "dependency" + + +# --- failure paths ---------------------------------------------------------- + + +@pytest.mark.parametrize( + ("path", "source"), + [ + ("package-lock.json", b"{not json"), + ("package-lock.json", b'{"lockfileVersion": 3}'), + ("package-lock.json", b'{"lockfileVersion": 3, "packages": []}'), + ("package-lock.json", b'{"lockfileVersion": "3", "packages": {}}'), + ("package-lock.json", b'[{"lockfileVersion": 3}]'), + ("package-lock.json", b'{"lockfileVersion": 3, "packages": {"node_modules/a": "1.0.0"}}'), + ("poetry.lock", b"[[package\n"), + ("poetry.lock", b'[[package]]\nname = "a"\n\n[metadata]\nlock-version = "2.0"\n'), + ("poetry.lock", b'[[package]]\nname = "a"\nversion = "1"\n'), + ("poetry.lock", b"[metadata]\nlock-version = 2\n"), + ], +) +def test_structurally_invalid_lockfiles_fail_closed_with_one_diagnostic(path, source): + result = _extract(path, source) + + assert result.nodes == () + assert result.observations == () + assert [diagnostic.code for diagnostic in result.diagnostics] == [RI_SRC_MALFORMED] + + +def test_an_empty_but_valid_lockfile_claims_nothing_without_complaining(): + result = _extract("poetry.lock", b'[metadata]\nlock-version = "2.0"\n') + + assert result.nodes == () + assert result.diagnostics == () + + +def test_a_binary_lockfile_is_excluded_from_line_addressed_extraction(): + result = _extract("package-lock.json", b'{"lockfileVersion": 3,\x00}') + + assert result.nodes == () + assert [diagnostic.code for diagnostic in result.diagnostics] == [RI_SRC_BINARY] + + +def test_undecodable_bytes_are_reported_as_malformed_source(): + result = _extract("package-lock.json", b"\xff\xfe{") + + assert result.nodes == () + assert [diagnostic.code for diagnostic in result.diagnostics] == [RI_SRC_MALFORMED] + + +def test_a_path_escaping_the_repository_root_is_rejected_before_extraction(): + result = _extract("../../package-lock.json", NPM_LOCK) + + assert result.nodes == () + assert [diagnostic.code for diagnostic in result.diagnostics] == [RI_SEC_PATH_ESCAPE] + + +# --- determinism ------------------------------------------------------------ + + +def test_repeated_extraction_of_one_lockfile_is_byte_identical(): + first = _extract("apps/web/package-lock.json", NPM_LOCK) + second = _extract("apps/web/package-lock.json", NPM_LOCK) + + assert first == second + assert {node.properties["workspace_path"] for node in first.nodes} == {"apps/web"} diff --git a/apps/backend/tests/extraction/test_naming.py b/apps/backend/tests/extraction/test_naming.py new file mode 100644 index 00000000..4c228a81 --- /dev/null +++ b/apps/backend/tests/extraction/test_naming.py @@ -0,0 +1,39 @@ +from app.extraction.naming import DiscriminatorAssigner, package_root, symbol_stable_key + + +def test_symbol_stable_key_builds_qualified_dotted_name(): + assert symbol_stable_key("src/auth/service.ts", [], "issueToken") == "src/auth/service.ts::issueToken" + assert ( + symbol_stable_key("src/auth/service.ts", ["AuthService"], "login") == "src/auth/service.ts::AuthService.login" + ) + assert symbol_stable_key("app/api/auth.py", ["outer"], "_inner") == "app/api/auth.py::outer._inner" + + +def test_package_root_reads_the_leading_python_module_from_a_dotted_specifier(): + assert package_root("django", "server/wsgi.py") == "django" + assert package_root("django.core.wsgi.get_wsgi_application", "server/wsgi.py") == "django" + assert package_root("os", "server/wsgi.py") == "os" + + +def test_package_root_reports_no_package_for_a_python_relative_import(): + # A relative import (from .models import User -> ".models.User") can + # never be an external package -- the leading dot splits to an empty + # root, which must never accidentally match a real dependency name. + assert package_root(".models.User", "app/views.py") == "" + + +def test_package_root_reads_the_leading_js_segment_including_scoped_packages(): + assert package_root("react", "src/app.tsx") == "react" + assert package_root("@scope/name/sub/path", "src/app.tsx") == "@scope/name" + assert package_root("./util", "src/app.tsx") == "." + assert package_root("../models/user", "src/app.tsx") == ".." + + +def test_discriminator_numbers_duplicates_in_source_order(): + assigner = DiscriminatorAssigner() + base = "a.ts::fmt" + assert assigner.key(base) == ("a.ts::fmt", False) + assert assigner.key(base) == ("a.ts::fmt#2", True) + assert assigner.key(base) == ("a.ts::fmt#3", True) + # a different key is independent + assert assigner.key("a.ts::other") == ("a.ts::other", False) diff --git a/apps/backend/tests/extraction/test_ordinals.py b/apps/backend/tests/extraction/test_ordinals.py new file mode 100644 index 00000000..9ba44949 --- /dev/null +++ b/apps/backend/tests/extraction/test_ordinals.py @@ -0,0 +1,100 @@ +"""RFC §6.4 ordinal conformance for both extractors. + +``ordinal`` is the one-based source order among observations whose *other* +identity fields are identical — it is not a file-wide sequence. These tests pin +that distinction and the resulting observation identities. +""" + +from app.extraction.python import PythonExtractor +from app.extraction.typescript import TypeScriptExtractor +from app.intelligence import canonical + +PY = PythonExtractor() +TS = TypeScriptExtractor() + +REVISION_KIND = "upload" +REVISION_VALUE = "sha256:" + "a" * 64 + + +def _imports(result): + return [o for o in result.observations if o.observed_kind == "import"] + + +def _observation_id(obs, extractor: str) -> str: + return canonical.compute_observation_id( + revision_kind=REVISION_KIND, + revision_value=REVISION_VALUE, + observed_kind=obs.observed_kind, + subject_kind=obs.subject_kind, + subject_key=obs.subject_key, + referent_text=obs.referent_text, + ordinal=obs.ordinal, + evidence={ + "path": obs.evidence.path, + "start_line": obs.evidence.start_line, + "end_line": obs.evidence.end_line, + "extractor": extractor, + "extractor_version": "1.0.0", + }, + ) + + +def test_python_distinct_imports_each_start_at_ordinal_one(): + # Different referent_text and different spans => different identity, so each + # is the first of its own group. A global 1,2,3 counter would be wrong. + result = PY.extract("app/api/auth.py", b"import os\nimport sys\nimport json\n") + assert [o.ordinal for o in _imports(result)] == [1, 1, 1] + + +def test_python_identical_imports_on_one_line_get_sequential_ordinals(): + # `import os, os` produces two observations with identical identity fields + # (same referent, same span) — precisely the case ordinal disambiguates. + result = PY.extract("app/api/auth.py", b"import os, os\n") + assert [o.ordinal for o in _imports(result)] == [1, 2] + + +def test_python_definitions_each_start_at_ordinal_one(): + result = PY.extract("app/api/auth.py", b"def a():\n pass\ndef b():\n pass\n") + defs = [o for o in result.observations if o.observed_kind == "definition"] + assert [o.ordinal for o in defs] == [1, 1] + + +def test_typescript_distinct_imports_each_start_at_ordinal_one(): + result = TS.extract( + "src/auth/service.ts", + b"import { a } from './x';\nimport { b } from './y';\n", + ) + assert [o.ordinal for o in _imports(result)] == [1, 1] + + +def test_typescript_identical_imports_on_one_line_get_sequential_ordinals(): + result = TS.extract( + "src/auth/service.ts", + b"import { a } from './x'; import { b } from './x';\n", + ) + assert [o.ordinal for o in _imports(result)] == [1, 2] + + +def test_identical_identity_fields_yield_distinct_observation_ids(): + # Ordinal is the only thing separating these two; identity must still differ. + result = PY.extract("app/api/auth.py", b"import os, os\n") + first, second = _imports(result) + assert _observation_id(first, "python-ast") != _observation_id(second, "python-ast") + + +def test_python_import_observation_id_vector(): + """Pin the identity of a known observation against silent drift. + + The hash is generated from this implementation, not derived independently, so + it proves stability rather than correctness: any change to the identity + document, the ordinal rule, or the module key changes it and must be a + deliberate `ri.v1` decision rather than an accident. + """ + + result = PY.extract("app/api/auth.py", b"import os\n") + obs = _imports(result)[0] + assert obs.subject_key == "mod:app/api" + assert obs.ordinal == 1 + assert _observation_id(obs, "python-ast") == ( + "obs:sha256:69232f1caf94a56a33653a1f570dad4b31765ed0a27c2e0d28955756dc2934e3" + ) diff --git a/apps/backend/tests/extraction/test_pipeline.py b/apps/backend/tests/extraction/test_pipeline.py new file mode 100644 index 00000000..5b58daec --- /dev/null +++ b/apps/backend/tests/extraction/test_pipeline.py @@ -0,0 +1,100 @@ +import pytest + +from app.intelligence import canonical +from app.extraction.pipeline import ExtractionPipeline +from app.extraction.manifests import DependencyManifestExtractor +from app.extraction.python import PythonExtractor +from app.extraction.typescript import TypeScriptExtractor + + +def _pipeline(*, max_source_bytes: int = 512 * 1024) -> ExtractionPipeline: + return ExtractionPipeline( + (DependencyManifestExtractor(), PythonExtractor(), TypeScriptExtractor()), + max_source_bytes=max_source_bytes, + ) + + +def test_pipeline_rejects_escaping_paths_before_dispatch(): + runs = _pipeline().run({"..\\escape.py": b"def leaked():\n pass\n"}) + diagnostics = [diagnostic for run in runs for diagnostic in run.result.diagnostics] + assert [diagnostic.code for diagnostic in diagnostics] == ["RI-SEC-PATH-ESCAPE"] + assert not [node for run in runs for node in run.result.nodes] + + +def test_pipeline_enforces_real_configured_size_budget(): + runs = _pipeline(max_source_bytes=4).run({"src/a.py": b"x = 1\n"}) + nodes = [node for run in runs for node in run.result.nodes] + diagnostics = [diagnostic for run in runs for diagnostic in run.result.diagnostics] + + assert [diagnostic.code for diagnostic in diagnostics] == ["RI-LIMIT-SKIP"] + assert diagnostics[0].details == {"budgetBytes": 4, "reportedBytes": 6} + assert [node.stable_key for node in nodes] == ["repo:root"] + + +def test_iter_run_consumes_only_one_source_at_a_time(): + consumed: list[str] = [] + + def sources(): + for path in ("a.txt", "b.txt"): + consumed.append(path) + yield path, b"content\n" + + results = _pipeline().iter_run(sources()) + + first = next(results) + assert consumed == ["a.txt"] + assert first.producer == "repository-inventory@1.1.0" + + next(results) + assert consumed == ["a.txt", "b.txt"] + + +def test_pipeline_emits_repository_root_for_diagnostics_only_text_source(): + runs = _pipeline().run({"src/broken.py": b"def broken(:\n return\n"}) + + nodes = [node for run in runs for node in run.result.nodes] + diagnostics = [diagnostic for run in runs for diagnostic in run.result.diagnostics] + + root = next(node for node in nodes if node.stable_key == "repo:root") + assert root.node_kind == "repository" + assert root.evidence[0].path == "src/broken.py" + assert root.evidence[0].start_line == root.evidence[0].end_line == 1 + assert [diagnostic.code for diagnostic in diagnostics] == ["RI-SRC-MALFORMED"] + + +def test_pipeline_uses_supports_and_inventory_for_unsupported_text(): + runs = _pipeline().run({"README.md": b"# title\n", "src/a.py": b"def f():\n pass\n"}) + producers = [run.producer for run in runs] + assert producers == ["repository-inventory@1.1.0", "python-ast@1.1.0"] + nodes = [node for run in runs for node in run.result.nodes] + readme = next(node for node in nodes if node.stable_key == "file:README.md") + assert readme.properties == {"content_sha256": canonical.sha256_prefixed(b"# title\n")} + assert any(node.stable_key == "src/a.py::f" for node in nodes) + + +def test_pipeline_checks_cancellation_between_source_work_units(monkeypatch): + extractor = PythonExtractor() + pipeline = ExtractionPipeline((extractor,)) + extracted: list[str] = [] + original_extract = extractor.extract + + def _extract(path, source): + extracted.append(path) + return original_extract(path, source) + + def _check_cancelled(): + if extracted: + raise RuntimeError("cancelled") + + monkeypatch.setattr(extractor, "extract", _extract) + + with pytest.raises(RuntimeError, match="cancelled"): + pipeline.run( + { + "src/a.py": b"def a():\n pass\n", + "src/b.py": b"def b():\n pass\n", + }, + check_cancelled=_check_cancelled, + ) + + assert extracted == ["src/a.py"] diff --git a/apps/backend/tests/extraction/test_python_diagnostics.py b/apps/backend/tests/extraction/test_python_diagnostics.py new file mode 100644 index 00000000..b2b17180 --- /dev/null +++ b/apps/backend/tests/extraction/test_python_diagnostics.py @@ -0,0 +1,141 @@ +from app.extraction.python import PythonExtractor + +EXTRACTOR = PythonExtractor() + + +def _codes(source: str): + result = EXTRACTOR.extract("app/api/auth.py", source.encode("utf-8")) + return [d.code for d in result.diagnostics], result + + +def test_star_import_is_flagged_unsupported(): + codes, result = _codes("from os import *\n") + assert "RI-EXT-UNSUPPORTED" in codes + # star import must not appear as a normal import observation + assert all(o.referent_text != "*" for o in result.observations) + + +def test_dynamic_import_is_flagged(): + codes, _ = _codes("import importlib\nm = importlib.import_module('os')\n") + assert "RI-EXT-UNSUPPORTED" in codes + + +def test_bare_imported_import_module_is_flagged(): + # `from importlib import import_module` then a bare call is the same blind + # spot as the attribute form; the matrix promises a diagnostic for both. + codes, _ = _codes("from importlib import import_module\nm = import_module('os')\n") + assert "RI-EXT-UNSUPPORTED" in codes + + +def test_bare_aliased_import_module_is_flagged(): + codes, _ = _codes("from importlib import import_module as load_module\nm = load_module('os')\n") + assert "RI-EXT-UNSUPPORTED" in codes + + +def test_importlib_module_alias_is_flagged(): + codes, _ = _codes("import importlib as imports\nm = imports.import_module('os')\n") + assert "RI-EXT-UNSUPPORTED" in codes + + +def test_bare_dunder_import_is_flagged(): + codes, _ = _codes("m = __import__('os')\n") + assert "RI-EXT-UNSUPPORTED" in codes + + +def test_reflection_is_flagged(): + codes, _ = _codes("x = getattr(object(), 'name', None)\n") + assert "RI-EXT-UNSUPPORTED" in codes + + +def test_monkeypatching_an_imported_module_is_flagged(): + codes, _ = _codes("import os\nos.sep = '\\\\'\n") + assert "RI-EXT-UNSUPPORTED" in codes + + +def test_monkeypatching_an_imported_class_is_flagged(): + codes, _ = _codes("from models import Thing\nThing.save = None\n") + assert "RI-EXT-UNSUPPORTED" in codes + + +def test_monkeypatching_an_aliased_import_is_flagged(): + codes, _ = _codes("import numpy as np\nnp.array = None\n") + assert "RI-EXT-UNSUPPORTED" in codes + + +def test_monkeypatching_a_nested_attribute_is_flagged(): + codes, _ = _codes("import os\nos.path.join = None\n") + assert "RI-EXT-UNSUPPORTED" in codes + + +def test_augmented_assignment_to_an_import_is_flagged(): + codes, _ = _codes("import config\nconfig.retries += 1\n") + assert "RI-EXT-UNSUPPORTED" in codes + + +def test_tuple_target_with_imported_attribute_is_flagged(): + codes, _ = _codes("import os\nos.sep, value = '/', 1\n") + assert "RI-EXT-UNSUPPORTED" in codes + + +# Negative cases: attribute assignment is routine. Only rebinding an attribute on +# a name this file imported is monkey-patching; flagging the rest would make the +# diagnostic noise and train people to ignore it. +def test_self_attribute_assignment_is_not_monkeypatching(): + codes, _ = _codes("class A:\n def __init__(self):\n self.x = 1\n") + assert "RI-EXT-UNSUPPORTED" not in codes + + +def test_local_object_attribute_assignment_is_not_monkeypatching(): + codes, _ = _codes("class C:\n pass\nc = C()\nc.x = 1\n") + assert "RI-EXT-UNSUPPORTED" not in codes + + +def test_rebinding_an_imported_name_itself_is_not_monkeypatching(): + # Rebinding the local name does not mutate the imported object. + codes, _ = _codes("import os\nos = None\n") + assert "RI-EXT-UNSUPPORTED" not in codes + + +def test_reading_an_imported_attribute_is_not_monkeypatching(): + codes, _ = _codes("import os\np = os.sep\n") + assert "RI-EXT-UNSUPPORTED" not in codes + + +def test_parameter_shadowing_import_is_not_monkeypatching(): + source = "import os\ndef configure(os):\n os.sep = '/'\n" + codes, _ = _codes(source) + assert "RI-EXT-UNSUPPORTED" not in codes + + +def test_local_rebinding_import_is_not_monkeypatching(): + source = "import os\nos = object()\nos.sep = '/'\n" + codes, _ = _codes(source) + assert "RI-EXT-UNSUPPORTED" not in codes + + +def test_local_import_module_definition_is_not_a_dynamic_import(): + source = "def import_module(name):\n return name\n\nimport_module('local')\n" + codes, _ = _codes(source) + assert "RI-EXT-UNSUPPORTED" not in codes + + +def test_import_module_parameter_and_rebinding_are_not_dynamic_imports(): + parameter_source = "def load(import_module):\n return import_module('local')\n" + rebound_source = "from importlib import import_module\nimport_module = lambda name: name\nimport_module('local')\n" + parameter_codes, _ = _codes(parameter_source) + rebound_codes, _ = _codes(rebound_source) + assert "RI-EXT-UNSUPPORTED" not in parameter_codes + assert "RI-EXT-UNSUPPORTED" not in rebound_codes + + +def test_unrelated_import_module_method_is_not_a_dynamic_import(): + codes, _ = _codes( + "class Loader:\n def import_module(self, name):\n return name\n\nLoader().import_module('local')\n" + ) + assert "RI-EXT-UNSUPPORTED" not in codes + + +def test_syntax_error_is_malformed_and_yields_no_symbols(): + result = EXTRACTOR.extract("bad.py", b"def broken(:\n") + assert [d.code for d in result.diagnostics] == ["RI-SRC-MALFORMED"] + assert result.nodes == () and result.observations == () diff --git a/apps/backend/tests/extraction/test_python_extractor.py b/apps/backend/tests/extraction/test_python_extractor.py new file mode 100644 index 00000000..d3ec49aa --- /dev/null +++ b/apps/backend/tests/extraction/test_python_extractor.py @@ -0,0 +1,119 @@ +from app.extraction.python import PythonExtractor + +EXTRACTOR = PythonExtractor() + + +def _extract(source: str): + return EXTRACTOR.extract("app/api/auth.py", source.encode("utf-8")) + + +def test_supports_only_python(): + assert EXTRACTOR.supports("a.py") is True + assert EXTRACTOR.supports("a.ts") is False + + +def test_module_node_has_whole_file_evidence(): + result = _extract("import os\n") + modules = [n for n in result.nodes if n.node_kind == "module"] + assert len(modules) == 1 + module = modules[0] + assert module.stable_key == "mod:app/api" + ev = module.evidence[0] + assert ev.granularity == "file" + assert (ev.start_line, ev.end_line) == (1, ev.logical_line_count) + + +def test_imports_become_observations_with_referent_text(): + result = _extract("import os\nfrom app.core import config\n") + imports = sorted((o.referent_text for o in result.observations if o.observed_kind == "import")) + assert imports == ["app.core.config", "os"] + for obs in result.observations: + if obs.observed_kind == "import": + assert obs.subject_key == "mod:app/api" + assert obs.evidence.start_line >= 1 + + +def test_escaping_source_path_yields_diagnostic_not_crash(): + result = EXTRACTOR.extract("../evil.py", b"import os\n") + assert result.nodes == () + assert [d.code for d in result.diagnostics] == ["RI-SEC-PATH-ESCAPE"] + + +def test_relative_imports_preserve_level_in_referent_text(): + result = _extract("from . import foo\nfrom .config import X\nimport os\n") + imports = [o.referent_text for o in result.observations if o.observed_kind == "import"] + assert ".foo" in imports + assert ".config.X" in imports + assert "os" in imports + + +def test_from_import_aliases_are_preserved_for_the_resolver(): + result = _extract("from .tokens import issue_token as mint\nmint()\n") + bindings = [ + observation.referent_text + for observation in result.observations + if observation.observed_kind == "import_binding" + ] + assert bindings == [".tokens|issue_token|mint"] + + +def test_direct_named_calls_become_resolver_observations(): + result = _extract("def caller():\n return target()\n") + calls = [ + (observation.subject_key, observation.referent_text) + for observation in result.observations + if observation.observed_kind == "call" + ] + assert calls == [("mod:app/api", "target")] + + +def test_parameter_shadowing_is_recorded_at_the_call_site(): + result = _extract("def target():\n return 1\ndef caller(target):\n return target()\n") + shadowed = [ + observation.referent_text for observation in result.observations if observation.observed_kind == "call_shadowed" + ] + assert shadowed == ["target"] + + +def test_bare_builtin_calls_produce_no_call_observation(): + """#392: a call to print/len/isinstance/... has no in-repo target and is + not a relationship worth a resolver diagnostic -- it must not even reach + the resolver as an observation, unlike a genuine unresolved call.""" + result = _extract( + "def caller(items):\n print(items)\n return len(items), isinstance(items, list), sorted(items)\n" + ) + calls = [ + observation.referent_text + for observation in result.observations + if observation.observed_kind in ("call", "call_shadowed") + ] + assert calls == [] + + +def test_genuinely_undefined_call_is_unaffected_by_the_builtin_skip(): + result = _extract("def caller():\n return someUndefinedThing()\n") + calls = [observation.referent_text for observation in result.observations if observation.observed_kind == "call"] + assert calls == ["someUndefinedThing"] + + +def test_module_level_shadow_of_a_builtin_still_yields_a_call_observation(): + """A user's own top-level ``def print(...)`` is a real, resolvable symbol + -- the builtin skip must not treat its name as an untracked builtin just + because the name also happens to be one.""" + result = _extract("def print(*args):\n pass\n\n\ndef caller():\n print('hi')\n") + calls = [observation.referent_text for observation in result.observations if observation.observed_kind == "call"] + assert calls == ["print"] + + +def test_function_local_import_is_not_exposed_as_a_file_wide_binding(): + result = _extract( + "def first():\n" + " from .tokens import issue_token\n" + " return issue_token()\n" + "def second():\n" + " return issue_token()\n" + ) + assert [observation for observation in result.observations if observation.observed_kind == "import_binding"] == [] + assert [ + observation.referent_text for observation in result.observations if observation.observed_kind == "call_shadowed" + ] == ["issue_token"] diff --git a/apps/backend/tests/extraction/test_python_routes.py b/apps/backend/tests/extraction/test_python_routes.py new file mode 100644 index 00000000..e19a3f1b --- /dev/null +++ b/apps/backend/tests/extraction/test_python_routes.py @@ -0,0 +1,52 @@ +from app.extraction.python import PythonExtractor + +EXTRACTOR = PythonExtractor() + + +def _extract(source: str): + return EXTRACTOR.extract("app/api/auth.py", source.encode("utf-8")) + + +def test_decorators_are_recorded_as_a_symbol_property(): + result = _extract("import functools\n@functools.cache\ndef compute():\n pass\n") + compute = next(n for n in result.nodes if n.stable_key == "app/api/auth.py::compute") + assert compute.properties is not None + assert "functools.cache" in compute.properties["decorators"] + + +def test_decorator_observation_carries_the_decorator_own_span(): + # The symbol's evidence starts at `def`, so a decorator on an earlier line is + # outside it. Each decorator needs provenance for its own source lines (#90). + result = _extract("@router.post('/login')\n@requires_auth\ndef login():\n pass\n") + decorators = [o for o in result.observations if o.observed_kind == "decorator"] + assert [(o.referent_text, o.evidence.start_line, o.evidence.end_line) for o in decorators] == [ + ("router.post", 1, 1), + ("requires_auth", 2, 2), + ] + assert all(o.subject_key == "app/api/auth.py::login" for o in decorators) + + +def test_decorated_symbol_evidence_still_starts_at_def(): + result = _extract("@requires_auth\ndef login():\n pass\n") + login = next(n for n in result.nodes if n.stable_key == "app/api/auth.py::login") + assert (login.evidence[0].start_line, login.evidence[0].end_line) == (2, 3) + + +def test_class_decorators_are_observed(): + result = _extract("@dataclass\nclass Session:\n pass\n") + decorators = [o for o in result.observations if o.observed_kind == "decorator"] + assert [o.referent_text for o in decorators] == ["dataclass"] + assert decorators[0].evidence.start_line == 1 + + +def test_fastapi_route_decorator_yields_literal_path_observation(): + result = _extract("router = APIRouter(prefix='/auth')\n@router.post('/login')\ndef login():\n pass\n") + routes = [o for o in result.observations if o.observed_kind == "route"] + assert len(routes) == 1 + # literal decorator string only; no /auth prefix joined (that is #91) + assert routes[0].referent_text == "/login" + assert routes[0].subject_key == "app/api/auth.py::(anonymous:route#1)" + handlers = [o for o in result.observations if o.observed_kind == "route_handler"] + assert [(o.subject_key, o.referent_text) for o in handlers] == [ + ("app/api/auth.py::(anonymous:route#1)", "app/api/auth.py::login") + ] diff --git a/apps/backend/tests/extraction/test_python_service_interactions.py b/apps/backend/tests/extraction/test_python_service_interactions.py new file mode 100644 index 00000000..9b81c38d --- /dev/null +++ b/apps/backend/tests/extraction/test_python_service_interactions.py @@ -0,0 +1,244 @@ +"""Python outbound HTTP call sites (#209). + +The contract these tests hold the extractor to: a destination fact exists only +when an import proves the client, a literal proves the method, and a literal +absolute URL proves the origin. Everything else is a disclosure. +""" + +from __future__ import annotations + +import pytest + +from app.extraction.base import RI_EXT_UNSUPPORTED +from app.extraction.python import PythonExtractor + + +def _extract(source: str, path: str = "app/client.py"): + return PythonExtractor().extract(path, source.encode("utf-8")) + + +def _http_calls(result): + return [ + (item.referent_text, item.evidence.start_line) + for item in result.observations + if item.observed_kind == "http_call" + ] + + +def _services(result): + return sorted({node.stable_key for node in result.nodes if node.node_kind == "service"}) + + +def _unsupported(result): + return [item.message for item in result.diagnostics if item.code == RI_EXT_UNSUPPORTED] + + +# --- proven destinations ---------------------------------------------------- + + +def test_a_requests_module_call_names_its_method_origin_and_path(): + result = _extract('import requests\n\nrequests.get("https://api.example.com/v1/users")\n') + + assert _http_calls(result) == [("GET|https://api.example.com|/v1/users", 3)] + assert _services(result) == ["svc:https://api.example.com"] + + +def test_an_aliased_module_import_still_proves_the_client(): + result = _extract('import httpx as hx\n\nhx.post("https://api.example.com/v1/users", json={})\n') + + assert _http_calls(result) == [("POST|https://api.example.com|/v1/users", 3)] + + +@pytest.mark.parametrize( + "setup", + [ + "import requests\n\nclient = requests.Session()\n", + "import httpx\n\nclient = httpx.Client()\n", + "import httpx\n\nclient = httpx.AsyncClient()\n", + "from requests import Session\n\nclient = Session()\n", + "from httpx import Client\n\nclient = Client()\n", + ], +) +def test_a_client_object_inherits_the_library_method_surface(setup): + result = _extract(f'{setup}\nclient.get("https://api.example.com/health")\n') + + assert _http_calls(result) == [("GET|https://api.example.com|/health", setup.count("\n") + 2)] + + +def test_a_request_call_reads_its_method_from_a_literal_argument(): + positional = _extract('import requests\n\nrequests.request("DELETE", "https://api.example.com/v1/1")\n') + keyword = _extract('import requests\n\nrequests.request(method="patch", url="https://api.example.com/v1/1")\n') + + assert _http_calls(positional) == [("DELETE|https://api.example.com|/v1/1", 3)] + # The method token is canonicalized; the destination is not. + assert _http_calls(keyword) == [("PATCH|https://api.example.com|/v1/1", 3)] + + +def test_a_url_keyword_argument_is_read_like_the_positional_one(): + result = _extract('import requests\n\nrequests.get(url="https://api.example.com/v1")\n') + + assert _http_calls(result) == [("GET|https://api.example.com|/v1", 3)] + + +def test_one_origin_is_one_service_however_many_call_sites_reach_it(): + result = _extract( + 'import requests\n\nrequests.get("https://api.example.com/a")\nrequests.post("https://api.example.com/b")\n' + ) + + assert _services(result) == ["svc:https://api.example.com"] + assert len([node for node in result.nodes if node.node_kind == "service"]) == 2 + # Both emissions describe one entity, so their records must be identical + # or the snapshot would refuse to seal them onto one key. + services = [node for node in result.nodes if node.node_kind == "service"] + assert services[0].properties == services[1].properties + assert services[0].language is None + + +# --- origin normalization --------------------------------------------------- + + +@pytest.mark.parametrize( + ("url", "expected"), + [ + ("https://API.Example.COM/v1", "GET|https://api.example.com|/v1"), + ("https://api.example.com:443/v1", "GET|https://api.example.com|/v1"), + ("http://api.example.com:80/v1", "GET|http://api.example.com|/v1"), + ("https://api.example.com:8443/v1", "GET|https://api.example.com:8443|/v1"), + ("https://api.example.com", "GET|https://api.example.com|/"), + ], +) +def test_origins_are_normalized_so_one_service_has_one_identity(url, expected): + result = _extract(f'import requests\n\nrequests.get("{url}")\n') + + assert _http_calls(result) == [(expected, 3)] + + +def test_query_strings_and_credentials_never_enter_a_stored_fact(): + result = _extract( + 'import requests\n\nrequests.get("https://user:pa55@api.example.com/v1/users?api_key=secret&x=1")\n' + ) + + referent, _ = _http_calls(result)[0] + assert referent == "GET|https://api.example.com|/v1/users" + assert "secret" not in referent and "pa55" not in referent + assert _services(result) == ["svc:https://api.example.com"] + + +# --- f-string origins (#408) ------------------------------------------------- + + +def test_an_fstring_url_with_a_literal_origin_and_dynamic_path_is_proven(): + result = _extract( + 'import requests\n\n\ndef f(user_id):\n return requests.get(f"https://api.example.com/users/{user_id}")\n' + ) + + assert _http_calls(result) == [("GET|https://api.example.com|/users/", 5)] + assert _services(result) == ["svc:https://api.example.com"] + assert _unsupported(result) == [] + + +def test_an_fstring_url_with_only_a_dynamic_path_suffix_is_proven(): + result = _extract('import requests\n\n\ndef f(path):\n return requests.get(f"https://api.example.com/{path}")\n') + + assert _http_calls(result) == [("GET|https://api.example.com|/", 5)] + + +# --- disclosures ------------------------------------------------------------ + + +@pytest.mark.parametrize( + ("call", "message"), + [ + ("requests.get(target)", "dynamic HTTP destination is unsupported"), + ('requests.get(f"https://{target}/v1")', "dynamic HTTP destination is unsupported"), + # The host itself isn't closed off within the literal prefix here -- + # a `target` that doesn't start with '/' would silently extend the + # hostname, not start a path -- so this must stay unsupported even + # though the string looks "mostly literal". + ('requests.get(f"https://api.example.com{target}")', "dynamic HTTP destination is unsupported"), + ('requests.get(f"{target}https://api.example.com/v1")', "dynamic HTTP destination is unsupported"), + ('requests.get("https://a.example.com/" + target)', "dynamic HTTP destination is unsupported"), + ("requests.get()", "dynamic HTTP destination is unsupported"), + ('requests.get("/v1/users")', "HTTP destination without an absolute http(s) URL is unsupported"), + ('requests.get("//api.example.com/v1")', "HTTP destination without an absolute http(s) URL is unsupported"), + ('requests.get("ftp://api.example.com/v1")', "HTTP destination without an absolute http(s) URL is unsupported"), + ('requests.request(target, "https://api.example.com/v1")', "computed HTTP method is unsupported"), + ], +) +def test_an_unproven_call_site_is_disclosed_and_claims_nothing(call, message): + result = _extract(f"import requests\n\n\ndef f(target):\n return {call}\n") + + assert _http_calls(result) == [] + assert _services(result) == [] + assert _unsupported(result) == [message] + + +def test_a_bare_imported_client_function_is_disclosed_not_counted_twice(): + """``get(url)`` is already a generic ``call``; a second fact would double-count it.""" + + result = _extract('from requests import get\n\nget("https://api.example.com/v1")\n') + + assert _http_calls(result) == [] + assert _unsupported(result) == ["bare imported HTTP client function is unsupported"] + assert [item.referent_text for item in result.observations if item.observed_kind == "call"] == ["get"] + + +def test_a_shadowed_client_name_is_disclosed_rather_than_trusted(): + result = _extract( + "import requests\n\n\n" + "def f():\n" + " requests = object()\n" + ' return requests.get("https://api.example.com/v1")\n' + ) + + assert _http_calls(result) == [] + assert _unsupported(result) == ["HTTP client name is shadowed by a local binding"] + + +def test_a_local_client_object_shadowed_by_a_parameter_is_disclosed(): + result = _extract( + "import httpx\n\n" + "client = httpx.Client()\n\n\n" + "def f(client):\n" + ' return client.get("https://api.example.com/v1")\n' + ) + + assert _http_calls(result) == [] + assert _unsupported(result) == ["HTTP client name is shadowed by a local binding"] + + +def test_an_unrelated_object_with_a_get_method_is_not_an_http_client(): + result = _extract('import os\n\ncache = dict()\ncache.get("https://api.example.com/v1")\n') + + assert _http_calls(result) == [] + assert _unsupported(result) == [] + + +def test_non_request_attributes_on_a_client_are_not_call_sites(): + result = _extract("import requests\n\nsession = requests.Session()\nsession.close()\nrequests.codes.ok\n") + + assert _http_calls(result) == [] + assert _unsupported(result) == [] + + +# --- interaction with the generic call pass --------------------------------- + + +def test_attribute_form_client_calls_never_produced_a_generic_call_observation(): + result = _extract('import requests\n\nrequests.get("https://api.example.com/v1")\n') + + assert [item for item in result.observations if item.observed_kind in ("call", "call_shadowed")] == [] + + +# --- determinism ------------------------------------------------------------ + + +def test_repeated_extraction_is_byte_identical(): + source = ( + "import requests\n\n" + "session = requests.Session()\n\n\n" + "def f():\n" + ' return session.request("PUT", "https://api.example.com/v1")\n' + ) + + assert _extract(source) == _extract(source) diff --git a/apps/backend/tests/extraction/test_python_snapshot_integration.py b/apps/backend/tests/extraction/test_python_snapshot_integration.py new file mode 100644 index 00000000..5fe2eb74 --- /dev/null +++ b/apps/backend/tests/extraction/test_python_snapshot_integration.py @@ -0,0 +1,262 @@ +from __future__ import annotations + +from uuid import uuid4 + +import pytest +from sqlalchemy import create_engine +from sqlalchemy.orm import Session, sessionmaker + +from app.core.database import register_sqlite_foreign_key_enforcement +from app.extraction.python import PythonExtractor +from app.intelligence.snapshot_store import Evidence, Revision, SnapshotStore +from app.models import RepositoryRecord, User +from app.models.base import Base + +UPLOAD_REVISION = "sha256:" + "a" * 64 + + +@pytest.fixture() +def session(tmp_path): + register_sqlite_foreign_key_enforcement() + engine = create_engine(f"sqlite:///{tmp_path / 'snap.db'}") + Base.metadata.create_all(engine) + with sessionmaker(bind=engine)() as db: + yield db + engine.dispose() + + +def _repository(session: Session) -> RepositoryRecord: + owner = User(id=str(uuid4()), email="o@example.com", password_hash=None) + session.add(owner) + session.commit() + record = RepositoryRecord( + id=str(uuid4()), + owner_id=owner.id, + name="repo", + source="upload", + revision_kind="upload", + revision_value=UPLOAD_REVISION, + local_path="/x", + status="completed", + file_tree=[], + ) + session.add(record) + session.commit() + return record + + +def _to_evidence(extracted) -> Evidence: + return Evidence( + path=extracted.path, + start_line=extracted.start_line, + end_line=extracted.end_line, + extractor="python-ast", + extractor_version="1.1.0", + logical_line_count=extracted.logical_line_count, + granularity=extracted.granularity, + ) + + +def test_python_extraction_result_seals_into_a_snapshot(session): + repository = _repository(session) + result = PythonExtractor().extract( + "app/api/auth.py", + b"import os\n\n\ndef get_current_user():\n return None\n", + ) + store = SnapshotStore(session) + snapshot = store.begin( + repository_id=repository.id, + revision=Revision("upload", UPLOAD_REVISION), + producer_version_set=["python-ast@1.1.0"], + ) + # a repo:root node is required for a coherent snapshot (RFC §11.2 rule 5) + root_ev = Evidence( + path="app/api/auth.py", + start_line=1, + end_line=1, + extractor="python-ast", + extractor_version="1.1.0", + logical_line_count=5, + granularity="file", + ) + store.add_node(snapshot, node_kind="repository", stable_key="repo:root", evidence=[root_ev]) + for node in result.nodes: + store.add_node( + snapshot, + node_kind=node.node_kind, + stable_key=node.stable_key, + name=node.name, + language=node.language, + properties=node.properties, + evidence=[_to_evidence(e) for e in node.evidence], + ) + for obs in result.observations: + store.add_observation( + snapshot, + observed_kind=obs.observed_kind, + subject_kind=obs.subject_kind, + subject_key=obs.subject_key, + referent_text=obs.referent_text, + ordinal=obs.ordinal, + evidence=_to_evidence(obs.evidence), + ) + for diag in result.diagnostics: + store.add_diagnostic( + snapshot, + code=diag.code, + category=diag.category, + severity=diag.severity, + message=diag.message, + producer="python-ast@1.1.0", + path=diag.path, + span=diag.span, + subject=diag.subject, + details=diag.details, + ) + + sealed = store.seal(snapshot) + assert sealed.state == "completed" + assert sealed.canonical_graph_hash.startswith("sha256:") + + +def _seal_files(session, files: dict[str, bytes]): + """Extract every file, write all facts into one snapshot, and seal it.""" + + repository = _repository(session) + store = SnapshotStore(session) + snapshot = store.begin( + repository_id=repository.id, + revision=Revision("upload", UPLOAD_REVISION), + producer_version_set=["python-ast@1.1.0"], + ) + first_path = next(iter(files)) + store.add_node( + snapshot, + node_kind="repository", + stable_key="repo:root", + evidence=[ + Evidence( + path=first_path, + start_line=1, + end_line=1, + extractor="python-ast", + extractor_version="1.1.0", + logical_line_count=1, + granularity="file", + ) + ], + ) + extractor = PythonExtractor() + for path, source in files.items(): + result = extractor.extract(path, source) + for node in result.nodes: + store.add_node( + snapshot, + node_kind=node.node_kind, + stable_key=node.stable_key, + name=node.name, + language=node.language, + properties=node.properties, + evidence=[_to_evidence(e) for e in node.evidence], + ) + for obs in result.observations: + store.add_observation( + snapshot, + observed_kind=obs.observed_kind, + subject_kind=obs.subject_kind, + subject_key=obs.subject_key, + referent_text=obs.referent_text, + ordinal=obs.ordinal, + evidence=_to_evidence(obs.evidence), + ) + return store.seal(snapshot) + + +def test_multiple_python_files_in_one_directory_seal(session): + # Every real repository has sibling modules in a directory. A directory-scoped + # module key must be identical for both files, name included, or add_node + # rejects the second as a conflicting record. + sealed = _seal_files( + session, + { + "app/api/auth.py": b"import os\n\n\ndef login():\n return None\n", + "app/api/users.py": b"import sys\n\n\ndef list_users():\n return []\n", + }, + ) + assert sealed.state == "completed" + assert sealed.canonical_graph_hash.startswith("sha256:") + + +def test_python_and_typescript_in_one_directory_seal(session): + # A directory holding both languages produces one `mod:src` record from each + # extractor. The module node is directory-scoped, so it must be + # language-neutral — otherwise the two records conflict on `language` and the + # snapshot refuses to seal, exactly as a differing `name` used to. + from app.extraction.typescript import TypeScriptExtractor + + repository = _repository(session) + store = SnapshotStore(session) + snapshot = store.begin( + repository_id=repository.id, + revision=Revision("upload", UPLOAD_REVISION), + producer_version_set=["python-ast@1.1.0", "typescript-ast@1.2.0"], + ) + store.add_node( + snapshot, + node_kind="repository", + stable_key="repo:root", + evidence=[ + Evidence( + path="src/app.py", + start_line=1, + end_line=1, + extractor="python-ast", + extractor_version="1.1.0", + logical_line_count=1, + granularity="file", + ) + ], + ) + work = [ + (PythonExtractor(), "src/app.py", b"def main():\n return 0\n", "python-ast"), + (TypeScriptExtractor(), "src/app.ts", b"export function main() {\n return 0;\n}\n", "typescript-ast"), + ] + for extractor, path, source, producer in work: + result = extractor.extract(path, source) + for node in result.nodes: + store.add_node( + snapshot, + node_kind=node.node_kind, + stable_key=node.stable_key, + name=node.name, + language=node.language, + properties=node.properties, + evidence=[ + Evidence( + path=e.path, + start_line=e.start_line, + end_line=e.end_line, + extractor=producer, + extractor_version=extractor.version, + logical_line_count=e.logical_line_count, + granularity=e.granularity, + ) + for e in node.evidence + ], + ) + + sealed = store.seal(snapshot) + assert sealed.state == "completed" + + +def test_python_files_across_directories_seal(session): + sealed = _seal_files( + session, + { + "app/api/auth.py": b"def login():\n return None\n", + "app/api/users.py": b"def list_users():\n return []\n", + "app/core/config.py": b"def settings():\n return {}\n", + "main.py": b"def main():\n return 0\n", + }, + ) + assert sealed.state == "completed" diff --git a/apps/backend/tests/extraction/test_python_symbols.py b/apps/backend/tests/extraction/test_python_symbols.py new file mode 100644 index 00000000..770ff52c --- /dev/null +++ b/apps/backend/tests/extraction/test_python_symbols.py @@ -0,0 +1,31 @@ +from app.extraction.python import PythonExtractor + +EXTRACTOR = PythonExtractor() + + +def _keys(source: str): + result = EXTRACTOR.extract("app/api/auth.py", source.encode("utf-8")) + return {n.stable_key for n in result.nodes if n.node_kind == "symbol"}, result + + +def test_class_method_and_nested_function_qualified_names(): + keys, _ = _keys( + "class AuthController:\n def login(self):\n pass\ndef outer():\n def _inner():\n pass\n" + ) + assert "app/api/auth.py::AuthController" in keys + assert "app/api/auth.py::AuthController.login" in keys + assert "app/api/auth.py::outer" in keys + assert "app/api/auth.py::outer._inner" in keys + + +def test_duplicate_defs_get_discriminator_and_diagnostic(): + keys, result = _keys("def handler():\n pass\ndef handler():\n pass\n") + assert "app/api/auth.py::handler" in keys + assert "app/api/auth.py::handler#2" in keys + assert any(d.code == "RI-KEY-DUP-SYMBOL" for d in result.diagnostics) + + +def test_each_symbol_has_a_definition_observation(): + _, result = _keys("def get_current_user():\n pass\n") + defs = [o for o in result.observations if o.observed_kind == "definition"] + assert any(o.subject_key == "app/api/auth.py::get_current_user" for o in defs) diff --git a/apps/backend/tests/extraction/test_support_matrix.py b/apps/backend/tests/extraction/test_support_matrix.py new file mode 100644 index 00000000..df59c5fc --- /dev/null +++ b/apps/backend/tests/extraction/test_support_matrix.py @@ -0,0 +1,112 @@ +"""The published support matrix, enforced behaviorally. + +Declaring a construct unsupported is a promise that it produces a diagnostic +rather than silence. Every entry in `unsupported` must therefore have a fixture +here that actually drives the extractor and proves the diagnostic fires — a label +with no implementation behind it fails these tests. +""" + +import pytest + +from app.extraction.base import RI_EXT_UNSUPPORTED +from app.extraction.python import PythonExtractor +from app.extraction.support_matrix import SUPPORT_MATRIX +from app.extraction.typescript import TypeScriptExtractor + +# One fixture per declared Python blind spot: construct -> (path, source) +PYTHON_BLIND_SPOTS = { + "star-import": ("app/a.py", "from os import *\n"), + "dynamic-import": ("app/a.py", "import importlib\nm = importlib.import_module('os')\n"), + "reflection": ("app/a.py", "x = getattr(object(), 'name', None)\n"), + "monkeypatch": ("app/a.py", "import os\nos.sep = '/'\n"), + "metaclass": ("app/a.py", "class A(metaclass=Meta):\n pass\n"), + "http-dynamic-destination": ("app/a.py", "import requests\n\n\ndef f(host):\n requests.get(host)\n"), +} + +# One fixture per declared TypeScript blind spot: construct -> (path, source) +TYPESCRIPT_BLIND_SPOTS = { + "dynamic-import": ("src/a.ts", "const m = import('./x');\n"), + "decorator": ("src/a.ts", "class A {\n @observable x = 1;\n}\n"), + "namespace": ("src/a.ts", "namespace N { export const a = 1; }\n"), + "commonjs-require": ("src/a.ts", "const fs = require('fs');\n"), + "ambient-module": ("src/a.ts", "declare module 'foo' { }\n"), + "http-dynamic-destination": ( + "src/a.ts", + "export function f(host: string) {\n return fetch(`https://${host}/v1`);\n}\n", + ), +} + + +def test_python_matrix_lists_supported_and_unsupported(): + python = SUPPORT_MATRIX["python"] + assert "module" in python.supported + assert "import" in python.supported + assert "function" in python.supported + assert "class" in python.supported + assert "decorator" in python.supported + assert "route" in python.supported + assert "star-import" in python.unsupported + assert "dynamic-import" in python.unsupported + assert "reflection" in python.unsupported + # nothing appears on both sides + assert set(python.supported).isdisjoint(python.unsupported) + + +def test_typescript_matrix_lists_supported_and_unsupported(): + ts = SUPPORT_MATRIX["typescript"] + for entry in ("file", "import", "export", "function", "class", "interface", "type", "enum", "route"): + assert entry in ts.supported + for entry in ("dynamic-import", "decorator", "namespace", "commonjs-require"): + assert entry in ts.unsupported + assert set(ts.supported).isdisjoint(ts.unsupported) + + +def test_every_python_blind_spot_has_a_fixture(): + assert set(PYTHON_BLIND_SPOTS) == set(SUPPORT_MATRIX["python"].unsupported) + + +def test_every_typescript_blind_spot_has_a_fixture(): + assert set(TYPESCRIPT_BLIND_SPOTS) == set(SUPPORT_MATRIX["typescript"].unsupported) + + +@pytest.mark.parametrize("construct", sorted(PYTHON_BLIND_SPOTS)) +def test_python_blind_spot_emits_a_diagnostic(construct): + path, source = PYTHON_BLIND_SPOTS[construct] + result = PythonExtractor().extract(path, source.encode("utf-8")) + assert any(d.code == RI_EXT_UNSUPPORTED for d in result.diagnostics), ( + f"{construct!r} is declared unsupported but emits no diagnostic" + ) + + +def test_unsupported_reflection_does_not_become_resolver_input(): + result = PythonExtractor().extract("app/a.py", b"x = getattr(object(), 'name', None)\n") + + assert not [ + observation + for observation in result.observations + if observation.observed_kind == "call" and observation.referent_text == "getattr" + ] + + +def test_unsupported_commonjs_require_does_not_become_resolver_input(): + result = TypeScriptExtractor().extract("src/a.ts", b"const fs = require('fs');\n") + + assert not [observation for observation in result.observations if observation.observed_kind == "call"] + + +@pytest.mark.parametrize("construct", sorted(TYPESCRIPT_BLIND_SPOTS)) +def test_typescript_blind_spot_emits_a_diagnostic(construct): + path, source = TYPESCRIPT_BLIND_SPOTS[construct] + result = TypeScriptExtractor().extract(path, source.encode("utf-8")) + assert any(d.code == RI_EXT_UNSUPPORTED for d in result.diagnostics), ( + f"{construct!r} is declared unsupported but emits no diagnostic" + ) + + +@pytest.mark.parametrize("construct", sorted(PYTHON_BLIND_SPOTS)) +def test_python_blind_spot_diagnostics_are_non_fatal_and_named(construct): + path, source = PYTHON_BLIND_SPOTS[construct] + result = PythonExtractor().extract(path, source.encode("utf-8")) + for diagnostic in result.diagnostics: + assert diagnostic.severity in ("info", "warning", "error") + assert diagnostic.message diff --git a/apps/backend/tests/extraction/test_typescript_diagnostics.py b/apps/backend/tests/extraction/test_typescript_diagnostics.py new file mode 100644 index 00000000..7a95be82 --- /dev/null +++ b/apps/backend/tests/extraction/test_typescript_diagnostics.py @@ -0,0 +1,28 @@ +from app.extraction.typescript import TypeScriptExtractor + +EXTRACTOR = TypeScriptExtractor() + + +def _codes(source: str, path: str = "src/x.ts"): + result = EXTRACTOR.extract(path, source.encode("utf-8")) + return [d.code for d in result.diagnostics] + + +def test_dynamic_import_flagged(): + assert "RI-EXT-UNSUPPORTED" in _codes("const m = import('./x');\n") + + +def test_namespace_flagged(): + assert "RI-EXT-UNSUPPORTED" in _codes("namespace N { export const a = 1; }\n") + + +def test_commonjs_require_flagged(): + assert "RI-EXT-UNSUPPORTED" in _codes("const fs = require('fs');\n") + + +def test_parse_error_is_malformed(): + result = EXTRACTOR.extract("src/x.ts", b"class {{{ broken\n") + + assert [diagnostic.code for diagnostic in result.diagnostics] == ["RI-SRC-MALFORMED"] + assert result.nodes == () + assert result.observations == () diff --git a/apps/backend/tests/extraction/test_typescript_extractor.py b/apps/backend/tests/extraction/test_typescript_extractor.py new file mode 100644 index 00000000..65e3e47e --- /dev/null +++ b/apps/backend/tests/extraction/test_typescript_extractor.py @@ -0,0 +1,107 @@ +from app.extraction.typescript import TypeScriptExtractor + +EXTRACTOR = TypeScriptExtractor() + + +def _extract(path: str, source: str): + return EXTRACTOR.extract(path, source.encode("utf-8")) + + +def test_supports_ts_and_tsx_only(): + assert EXTRACTOR.supports("a.ts") is True + assert EXTRACTOR.supports("a.tsx") is True + assert EXTRACTOR.supports("a.js") is False + assert EXTRACTOR.supports("a.py") is False + + +def test_file_node_has_whole_file_evidence(): + result = _extract("src/main.ts", "const x = 1;\n") + files = [n for n in result.nodes if n.node_kind == "file"] + assert len(files) == 1 + assert files[0].stable_key == "file:src/main.ts" + ev = files[0].evidence[0] + assert ev.granularity == "file" + assert (ev.start_line, ev.end_line) == (1, ev.logical_line_count) + + +def test_binary_file_is_flagged_not_parsed(): + result = _extract("src/blob.ts", "\x00\x00") + assert [d.code for d in result.diagnostics] == ["RI-SRC-BINARY"] + assert result.nodes == () + + +def test_escaping_path_is_flagged_not_raised(): + result = _extract("../../etc/passwd.ts", "const x = 1;\n") + assert [d.code for d in result.diagnostics] == ["RI-SEC-PATH-ESCAPE"] + assert result.nodes == () + + +def test_module_node_is_emitted_with_directory_scoped_key(): + # #89 requires module nodes alongside file nodes. + result = _extract("src/auth/service.ts", "const x = 1;\n") + modules = [n for n in result.nodes if n.node_kind == "module"] + assert len(modules) == 1 + assert modules[0].stable_key == "mod:src/auth" + assert modules[0].name == "auth" + ev = modules[0].evidence[0] + assert ev.granularity == "file" + assert (ev.start_line, ev.end_line) == (1, ev.logical_line_count) + + +def test_sibling_typescript_files_share_one_module_record(): + # Same directory => byte-identical module record, or the snapshot cannot seal. + a = _extract("src/auth/service.ts", "const a = 1;\n") + b = _extract("src/auth/tokens.ts", "const b = 1;\n") + mod_a = next(n for n in a.nodes if n.node_kind == "module") + mod_b = next(n for n in b.nodes if n.node_kind == "module") + assert (mod_a.stable_key, mod_a.name) == (mod_b.stable_key, mod_b.name) + + +def test_root_level_file_module_has_no_short_name(): + result = _extract("main.ts", "const x = 1;\n") + module = next(n for n in result.nodes if n.node_kind == "module") + assert module.stable_key == "mod:" + assert module.name is None + + +def test_bare_global_calls_produce_no_call_observation(): + """#392: a call to an ECMAScript/host global has no in-repo target and is + not a relationship worth a resolver diagnostic -- it must not even reach + the resolver as an observation, unlike a genuine unresolved call.""" + result = _extract( + "src/util.ts", + "function caller(value: string) {\n" + " parseInt(value);\n" + " structuredClone(value);\n" + " setTimeout(() => {}, 0);\n" + " return String(value);\n" + "}\n", + ) + calls = [ + observation.referent_text + for observation in result.observations + if observation.observed_kind in ("call", "call_shadowed") + ] + assert calls == [] + + +def test_genuinely_undefined_call_is_unaffected_by_the_global_skip(): + result = _extract("src/util.ts", "function caller() {\n return someUndefinedThing();\n}\n") + calls = [observation.referent_text for observation in result.observations if observation.observed_kind == "call"] + assert calls == ["someUndefinedThing"] + + +def test_function_scoped_shadow_of_a_global_still_yields_a_call_observation(): + """A local function that shadows a global name (e.g. a parameter or inner + declaration named ``String``) is a real, resolvable local call -- the + global skip must not swallow it just because the name is also a global.""" + result = _extract( + "src/util.ts", + "function caller() {\n function String(value: unknown) { return value; }\n return String('hi');\n}\n", + ) + calls = [ + observation.referent_text + for observation in result.observations + if observation.observed_kind in ("call", "call_shadowed") + ] + assert "String" in calls diff --git a/apps/backend/tests/extraction/test_typescript_imports.py b/apps/backend/tests/extraction/test_typescript_imports.py new file mode 100644 index 00000000..aa6e4b20 --- /dev/null +++ b/apps/backend/tests/extraction/test_typescript_imports.py @@ -0,0 +1,23 @@ +from app.extraction.typescript import TypeScriptExtractor + +EXTRACTOR = TypeScriptExtractor() + + +def _extract(source: str): + return EXTRACTOR.extract("src/auth/service.ts", source.encode("utf-8")) + + +def test_imports_become_observations(): + result = _extract("import { issueToken } from './tokens';\nexport { refresh } from './session';\n") + specifiers = sorted(o.referent_text for o in result.observations if o.observed_kind == "import") + assert specifiers == ["./session", "./tokens"] + + +def test_named_import_aliases_are_preserved_for_the_resolver(): + result = _extract("import { issueToken as mint } from './tokens';\nmint();\n") + bindings = [ + observation.referent_text + for observation in result.observations + if observation.observed_kind == "import_binding" + ] + assert bindings == ["./tokens|issueToken|mint"] diff --git a/apps/backend/tests/extraction/test_typescript_routes.py b/apps/backend/tests/extraction/test_typescript_routes.py new file mode 100644 index 00000000..bdb43f0f --- /dev/null +++ b/apps/backend/tests/extraction/test_typescript_routes.py @@ -0,0 +1,110 @@ +from app.extraction.typescript import TypeScriptExtractor + +EXTRACTOR = TypeScriptExtractor() + + +def test_create_browser_router_paths_become_route_observations(): + source = ( + "import { createBrowserRouter } from 'react-router-dom';\n" + "export const router = createBrowserRouter([\n" + " { path: '/login', element: null },\n" + " { path: '/dashboard', element: null },\n" + "]);\n" + ) + result = EXTRACTOR.extract("src/app/routes/router.ts", source.encode("utf-8")) + paths = sorted(o.referent_text for o in result.observations if o.observed_kind == "route") + assert paths == ["/dashboard", "/login"] + + +def test_jsx_route_path_becomes_route_observation(): + source = "const x = ;\n" + result = EXTRACTOR.extract("src/app/routes/tree.tsx", source.encode("utf-8")) + paths = [o.referent_text for o in result.observations if o.observed_kind == "route"] + assert paths == ["/settings"] + + +def test_dynamic_jsx_route_path_is_diagnostic_not_a_literal_route(): + source = "const routePath = '/settings';\nconst x = } />;\n" + result = EXTRACTOR.extract("src/app/routes/tree.tsx", source.encode("utf-8")) + + assert [ + observation for observation in result.observations if observation.observed_kind in {"route", "route_handler"} + ] == [] + assert [node for node in result.nodes if node.name == "route"] == [] + assert [ + (diagnostic.code, diagnostic.message) + for diagnostic in result.diagnostics + if diagnostic.message == "dynamic route path is unsupported" + ] == [("RI-EXT-UNSUPPORTED", "dynamic route path is unsupported")] + + +def test_static_component_route_records_a_handler_binding(): + source = ( + "function Dashboard() { return null; }\n" + "const router = createBrowserRouter([{ path: '/', Component: Dashboard }]);\n" + ) + result = EXTRACTOR.extract("src/app/routes/router.ts", source.encode("utf-8")) + route = next(observation for observation in result.observations if observation.observed_kind == "route") + handlers = [ + (observation.subject_key, observation.referent_text) + for observation in result.observations + if observation.observed_kind == "route_handler" + ] + assert handlers == [(route.subject_key, "Dashboard")] + + +def _routes(path: str, source: str): + result = EXTRACTOR.extract(path, source.encode("utf-8")) + return [o.referent_text for o in result.observations if o.observed_kind == "route"] + + +def test_plain_object_path_property_is_not_a_route(): + # An ordinary object that happens to have a `path` key is not a router entry. + assert _routes("src/config.ts", "const cfg = { path: '/tmp/cache', size: 10 };\n") == [] + + +def test_non_route_jsx_component_path_attribute_is_not_a_route(): + # `path` on a non-Route component is not a react-router route. + assert _routes("src/f.tsx", "const x = ;\n") == [] + + +def test_nested_router_children_are_routes(): + source = "export const router = createBrowserRouter([\n { path: '/', children: [{ path: '/nested' }] },\n]);\n" + assert sorted(_routes("src/app/routes/router.ts", source)) == ["/", "/nested"] + + +def test_router_ignores_paths_in_non_route_entry_properties(): + source = ( + "export const router = createBrowserRouter([\n" + " {\n" + " path: '/',\n" + " handle: { path: '/metadata' },\n" + " element: { path: '/element-prop' },\n" + " metadata: { nested: { path: '/arbitrary' } },\n" + " children: [{ path: '/nested', handle: { path: '/nested-metadata' } }],\n" + " },\n" + "]);\n" + ) + assert sorted(_routes("src/app/routes/router.ts", source)) == ["/", "/nested"] + + +def test_router_options_argument_is_not_a_route_table(): + # createBrowserRouter(routes, opts) — only the first argument is the route + # table; `path` in the options object is not a route. + source = "const router = createBrowserRouter(routes, { path: '/not-a-route' });\n" + assert _routes("src/app/routes/router.ts", source) == [] + + +def test_router_options_argument_ignored_while_route_table_still_read(): + source = ( + "const router = createBrowserRouter(\n [{ path: '/login' }],\n { path: '/not-a-route', future: {} },\n);\n" + ) + assert _routes("src/app/routes/router.ts", source) == ["/login"] + + +def test_route_inside_createbrowserrouter_variable_is_not_matched_elsewhere(): + # A `path` key in an unrelated object in the same file stays unmatched. + source = ( + "export const router = createBrowserRouter([{ path: '/login' }]);\nconst opts = { path: '/not-a-route' };\n" + ) + assert _routes("src/app/routes/router.ts", source) == ["/login"] diff --git a/apps/backend/tests/extraction/test_typescript_service_interactions.py b/apps/backend/tests/extraction/test_typescript_service_interactions.py new file mode 100644 index 00000000..5b2e24bd --- /dev/null +++ b/apps/backend/tests/extraction/test_typescript_service_interactions.py @@ -0,0 +1,221 @@ +"""TypeScript/JavaScript outbound HTTP call sites (#209). + +``fetch`` and axios differ from the Python clients in one important way: they are +plain identifiers, so the generic ``call`` pass already sees them. These tests +pin both halves of that — the proven sites become one service-interaction fact +instead of a generic call, and the unproven ones keep the existing behaviour. +""" + +from __future__ import annotations + +import pytest + +from app.extraction.base import RI_EXT_UNSUPPORTED +from app.extraction.typescript import TypeScriptExtractor + + +def _extract(source: str, path: str = "src/api.ts"): + return TypeScriptExtractor().extract(path, source.encode("utf-8")) + + +def _http_calls(result): + return [ + (item.referent_text, item.evidence.start_line) + for item in result.observations + if item.observed_kind == "http_call" + ] + + +def _generic_calls(result): + return [ + (item.observed_kind, item.referent_text, item.evidence.start_line) + for item in result.observations + if item.observed_kind in ("call", "call_shadowed") + ] + + +def _services(result): + return sorted({node.stable_key for node in result.nodes if node.node_kind == "service"}) + + +def _unsupported(result): + return [item.message for item in result.diagnostics if item.code == RI_EXT_UNSUPPORTED] + + +# --- proven destinations ---------------------------------------------------- + + +def test_fetch_without_an_init_object_is_a_specified_get(): + result = _extract('const r = fetch("https://api.example.com/v1/users");\n') + + assert _http_calls(result) == [("GET|https://api.example.com|/v1/users", 1)] + assert _services(result) == ["svc:https://api.example.com"] + + +def test_a_literal_method_in_an_inline_init_overrides_the_get_default(): + result = _extract('const r = fetch("https://api.example.com/v1", { method: "post" });\n') + + assert _http_calls(result) == [("POST|https://api.example.com|/v1", 1)] + + +def test_a_backtick_literal_without_substitutions_is_still_a_literal(): + result = _extract("const r = fetch(`https://api.example.com/v1`);\n") + + assert _http_calls(result) == [("GET|https://api.example.com|/v1", 1)] + + +@pytest.mark.parametrize("local", ["axios", "http", "apiClient"]) +def test_axios_is_proven_by_its_default_import_under_any_local_name(local): + result = _extract(f'import {local} from "axios";\n\n{local}.delete("https://api.example.com/v1/1");\n') + + assert _http_calls(result) == [("DELETE|https://api.example.com|/v1/1", 3)] + + +def test_an_axios_named_object_from_another_module_is_not_the_client(): + result = _extract('import axios from "./local-axios";\n\naxios.get("https://api.example.com/v1");\n') + + assert _http_calls(result) == [] + assert _unsupported(result) == [] + + +def test_one_origin_is_one_service_with_a_language_neutral_record(): + result = _extract('fetch("https://api.example.com/a");\nfetch("https://api.example.com/b");\n') + + services = [node for node in result.nodes if node.node_kind == "service"] + assert _services(result) == ["svc:https://api.example.com"] + assert services[0].properties == services[1].properties == {"origin": "https://api.example.com"} + assert services[0].language is None + + +def test_query_strings_never_enter_a_stored_fact(): + result = _extract('fetch("https://api.example.com/v1?token=secret");\n') + + referent, _ = _http_calls(result)[0] + assert referent == "GET|https://api.example.com|/v1" + assert "secret" not in referent + + +# --- template-literal origins (#408) ----------------------------------------- + + +def test_a_template_literal_url_with_a_literal_origin_and_dynamic_path_is_proven(): + result = _extract( + "export function f(userId: string) {\n return fetch(`https://api.example.com/users/${userId}`);\n}\n" + ) + + assert _http_calls(result) == [("GET|https://api.example.com|/users/", 2)] + assert _services(result) == ["svc:https://api.example.com"] + assert _unsupported(result) == [] + + +def test_a_template_literal_url_with_only_a_dynamic_path_suffix_is_proven(): + result = _extract("export function f(path: string) {\n return fetch(`https://api.example.com/${path}`);\n}\n") + + assert _http_calls(result) == [("GET|https://api.example.com|/", 2)] + + +# --- the generic call pass -------------------------------------------------- + + +def test_a_proven_fetch_site_replaces_its_generic_call_rather_than_adding_to_it(): + result = _extract('fetch("https://api.example.com/v1");\n') + + assert _http_calls(result) == [("GET|https://api.example.com|/v1", 1)] + assert _generic_calls(result) == [] + + +def test_an_unproven_fetch_site_keeps_the_existing_generic_call_behaviour(): + result = _extract("export function f(u: string) {\n return fetch(u);\n}\n") + + assert _http_calls(result) == [] + assert _generic_calls(result) == [("call", "fetch", 2)] + + +def test_an_unrelated_identifier_call_is_untouched(): + result = _extract("function helper() {}\nhelper();\n") + + assert _generic_calls(result) == [("call", "helper", 2)] + + +# --- disclosures ------------------------------------------------------------ + + +@pytest.mark.parametrize( + ("call", "message"), + [ + ("fetch(target)", "dynamic HTTP destination is unsupported"), + ("fetch(`https://${target}/v1`)", "dynamic HTTP destination is unsupported"), + # The host itself isn't closed off within the literal prefix here -- + # a `target` that doesn't start with '/' would silently extend the + # hostname, not start a path -- so this must stay unsupported even + # though the string looks "mostly literal". + ("fetch(`https://api.example.com${target}`)", "dynamic HTTP destination is unsupported"), + ("fetch(`${target}https://api.example.com/v1`)", "dynamic HTTP destination is unsupported"), + ("fetch()", "dynamic HTTP destination is unsupported"), + ('fetch("/v1/users")', "HTTP destination without an absolute http(s) URL is unsupported"), + ('fetch("ws://api.example.com/v1")', "HTTP destination without an absolute http(s) URL is unsupported"), + ('fetch("https://api.example.com/v1", init)', "computed fetch init is unsupported"), + ('fetch("https://api.example.com/v1", { ...init })', "computed fetch init is unsupported"), + ('fetch("https://api.example.com/v1", { method: verb })', "computed fetch init is unsupported"), + ], +) +def test_an_unproven_call_site_is_disclosed_and_claims_nothing(call, message): + result = _extract(f"export function f(target: string, init: RequestInit, verb: string) {{\n return {call};\n}}\n") + + assert _http_calls(result) == [] + assert _services(result) == [] + assert _unsupported(result) == [message] + + +def test_a_shadowed_fetch_binding_is_disclosed_rather_than_trusted(): + result = _extract( + 'export function f() {\n const fetch = (u: string) => u;\n return fetch("https://api.example.com/v1");\n}\n' + ) + + assert _http_calls(result) == [] + assert _unsupported(result) == ["HTTP client name is shadowed by a local binding"] + + +def test_a_shadowed_axios_binding_is_disclosed_rather_than_trusted(): + result = _extract( + 'import axios from "axios";\n\n' + "export function f(axios: { get(u: string): void }) {\n" + ' return axios.get("https://api.example.com/v1");\n' + "}\n" + ) + + assert _http_calls(result) == [] + assert _unsupported(result) == ["HTTP client name is shadowed by a local binding"] + + +@pytest.mark.parametrize("call", ['axios({ url: "https://api.example.com/v1" })', "axios.request(config)"]) +def test_axios_call_forms_this_extractor_does_not_interpret_are_disclosed(call): + result = _extract(f'import axios from "axios";\n\nexport function f(config: object) {{\n return {call};\n}}\n') + + assert _http_calls(result) == [] + assert _unsupported(result) == ["unsupported HTTP client call form"] + + +def test_axios_create_is_not_mistaken_for_a_request(): + result = _extract( + 'import axios from "axios";\n\nconst client = axios.create({ baseURL: "https://a.example.com" });\n' + ) + + assert _http_calls(result) == [] + # A base URL is configuration, not a proven call destination. + assert _services(result) == [] + + +# --- determinism ------------------------------------------------------------ + + +def test_repeated_extraction_is_byte_identical(): + source = ( + 'import client from "axios";\n\n' + "export async function load() {\n" + ' await fetch("https://api.example.com/v1", { method: "PUT" });\n' + ' return client.get("https://api.example.com/v2");\n' + "}\n" + ) + + assert _extract(source) == _extract(source) diff --git a/apps/backend/tests/extraction/test_typescript_snapshot_integration.py b/apps/backend/tests/extraction/test_typescript_snapshot_integration.py new file mode 100644 index 00000000..35ad5578 --- /dev/null +++ b/apps/backend/tests/extraction/test_typescript_snapshot_integration.py @@ -0,0 +1,177 @@ +from __future__ import annotations + +from uuid import uuid4 + +import pytest +from sqlalchemy import create_engine +from sqlalchemy.orm import Session, sessionmaker + +from app.core.database import register_sqlite_foreign_key_enforcement +from app.extraction.typescript import TypeScriptExtractor +from app.intelligence.snapshot_store import Evidence, Revision, SnapshotStore +from app.models import RepositoryRecord, User +from app.models.base import Base + +UPLOAD_REVISION = "sha256:" + "a" * 64 + + +@pytest.fixture() +def session(tmp_path): + register_sqlite_foreign_key_enforcement() + engine = create_engine(f"sqlite:///{tmp_path / 'snap.db'}") + Base.metadata.create_all(engine) + with sessionmaker(bind=engine)() as db: + yield db + engine.dispose() + + +def _repository(session: Session) -> RepositoryRecord: + owner = User(id=str(uuid4()), email="o@example.com", password_hash=None) + session.add(owner) + session.commit() + record = RepositoryRecord( + id=str(uuid4()), + owner_id=owner.id, + name="repo", + source="upload", + revision_kind="upload", + revision_value=UPLOAD_REVISION, + local_path="/x", + status="completed", + file_tree=[], + ) + session.add(record) + session.commit() + return record + + +def _to_evidence(extracted) -> Evidence: + return Evidence( + path=extracted.path, + start_line=extracted.start_line, + end_line=extracted.end_line, + extractor="typescript-ast", + extractor_version="1.2.0", + logical_line_count=extracted.logical_line_count, + granularity=extracted.granularity, + ) + + +def test_typescript_extraction_result_seals_into_a_snapshot(session): + repository = _repository(session) + result = TypeScriptExtractor().extract( + "src/auth/service.ts", + b"export function issueToken() {\n return 1;\n}\n", + ) + store = SnapshotStore(session) + snapshot = store.begin( + repository_id=repository.id, + revision=Revision("upload", UPLOAD_REVISION), + producer_version_set=["typescript-ast@1.2.0"], + ) + # a repo:root node is required for a coherent snapshot (RFC §11.2 rule 5) + root_ev = Evidence( + path="src/auth/service.ts", + start_line=1, + end_line=1, + extractor="typescript-ast", + extractor_version="1.2.0", + logical_line_count=1, + granularity="file", + ) + store.add_node(snapshot, node_kind="repository", stable_key="repo:root", evidence=[root_ev]) + for node in result.nodes: + store.add_node( + snapshot, + node_kind=node.node_kind, + stable_key=node.stable_key, + name=node.name, + language=node.language, + properties=node.properties, + evidence=[_to_evidence(e) for e in node.evidence], + ) + for obs in result.observations: + store.add_observation( + snapshot, + observed_kind=obs.observed_kind, + subject_kind=obs.subject_kind, + subject_key=obs.subject_key, + referent_text=obs.referent_text, + ordinal=obs.ordinal, + evidence=_to_evidence(obs.evidence), + ) + for diag in result.diagnostics: + store.add_diagnostic( + snapshot, + code=diag.code, + category=diag.category, + severity=diag.severity, + message=diag.message, + producer="typescript-ast@1.2.0", + path=diag.path, + span=diag.span, + subject=diag.subject, + details=diag.details, + ) + + sealed = store.seal(snapshot) + assert sealed.state == "completed" + assert sealed.canonical_graph_hash.startswith("sha256:") + + +def test_sibling_typescript_files_seal_into_one_snapshot(session): + # The module node is directory-scoped, so sibling files must produce an + # identical module record rather than a conflicting one. + repository = _repository(session) + store = SnapshotStore(session) + snapshot = store.begin( + repository_id=repository.id, + revision=Revision("upload", UPLOAD_REVISION), + producer_version_set=["typescript-ast@1.2.0"], + ) + store.add_node( + snapshot, + node_kind="repository", + stable_key="repo:root", + evidence=[ + Evidence( + path="src/auth/service.ts", + start_line=1, + end_line=1, + extractor="typescript-ast", + extractor_version="1.2.0", + logical_line_count=1, + granularity="file", + ) + ], + ) + extractor = TypeScriptExtractor() + files = { + "src/auth/service.ts": b"export function issueToken() {\n return 1;\n}\n", + "src/auth/tokens.ts": b"import { issueToken } from './service';\nexport const t = 1;\n", + } + for path, source in files.items(): + result = extractor.extract(path, source) + for node in result.nodes: + store.add_node( + snapshot, + node_kind=node.node_kind, + stable_key=node.stable_key, + name=node.name, + language=node.language, + properties=node.properties, + evidence=[_to_evidence(e) for e in node.evidence], + ) + for obs in result.observations: + store.add_observation( + snapshot, + observed_kind=obs.observed_kind, + subject_kind=obs.subject_kind, + subject_key=obs.subject_key, + referent_text=obs.referent_text, + ordinal=obs.ordinal, + evidence=_to_evidence(obs.evidence), + ) + + sealed = store.seal(snapshot) + assert sealed.state == "completed" diff --git a/apps/backend/tests/extraction/test_typescript_symbols.py b/apps/backend/tests/extraction/test_typescript_symbols.py new file mode 100644 index 00000000..8be61c15 --- /dev/null +++ b/apps/backend/tests/extraction/test_typescript_symbols.py @@ -0,0 +1,95 @@ +from app.extraction.typescript import TypeScriptExtractor + +EXTRACTOR = TypeScriptExtractor() + + +def _keys(source: str): + result = EXTRACTOR.extract("src/auth/service.ts", source.encode("utf-8")) + return {n.stable_key for n in result.nodes if n.node_kind == "symbol"}, result + + +def test_class_methods_and_function_qualified_names(): + # Two methods prove the traversal reaches every method_definition inside + # class_body, not just the class declaration itself. + keys, _ = _keys("export class AuthService {\n login() {}\n logout() {}\n}\nexport function issueToken() {}\n") + assert "src/auth/service.ts::AuthService" in keys + assert "src/auth/service.ts::AuthService.login" in keys + assert "src/auth/service.ts::AuthService.logout" in keys + assert "src/auth/service.ts::issueToken" in keys + + +def test_interface_type_enum_are_symbols(): + keys, _ = _keys("export interface Session {}\nexport type Id = string;\nexport enum Role { Admin }\n") + assert "src/auth/service.ts::Session" in keys + assert "src/auth/service.ts::Id" in keys + assert "src/auth/service.ts::Role" in keys + + +def test_duplicate_overloads_get_discriminator(): + keys, result = _keys( + "export function fmt(x: number): string;\n" + "export function fmt(x: string): string;\n" + "export function fmt(x: any): string { return String(x); }\n" + ) + assert "src/auth/service.ts::fmt" in keys + assert "src/auth/service.ts::fmt#2" in keys + assert any(d.code == "RI-KEY-DUP-SYMBOL" for d in result.diagnostics) + + +def test_top_level_const_becomes_symbol_with_exported_flag(): + keys, result = _keys("export const router = createBrowserRouter([]);\nconst helper = 1;\n") + assert "src/auth/service.ts::router" in keys + assert "src/auth/service.ts::helper" in keys + router = next(n for n in result.nodes if n.stable_key == "src/auth/service.ts::router") + assert router.properties is not None and router.properties.get("exported") is True + helper = next(n for n in result.nodes if n.stable_key == "src/auth/service.ts::helper") + assert helper.properties is None or helper.properties.get("exported") is not True + + +def test_exported_function_carries_exported_property(): + _, result = _keys("export function issueToken() {}\n") + token = next(n for n in result.nodes if n.stable_key == "src/auth/service.ts::issueToken") + assert token.properties is not None and token.properties.get("exported") is True + + +def test_default_export_identity_is_preserved_for_declarations_and_aliases(): + _, declaration_result = _keys("export default function Primary() {}\n") + _, alias_result = _keys("const Secondary = () => null;\nexport default Secondary;\n") + default_exports = { + node.stable_key + for result in (declaration_result, alias_result) + for node in result.nodes + if node.properties is not None and node.properties.get("default_export") is True + } + assert default_exports == { + "src/auth/service.ts::Primary", + "src/auth/service.ts::Secondary", + } + + +def test_direct_implements_clause_becomes_a_resolver_observation(): + _, result = _keys("interface Worker {}\nclass Runner implements Worker {}\n") + observations = [ + (observation.subject_key, observation.referent_text) + for observation in result.observations + if observation.observed_kind == "implements" + ] + assert observations == [("src/auth/service.ts::Runner", "Worker")] + + +def test_abstract_generic_implements_records_the_base_reference(): + _, result = _keys("interface Worker {}\nabstract class Runner implements Worker {}\n") + observations = [ + (observation.subject_key, observation.referent_text) + for observation in result.observations + if observation.observed_kind == "implements" + ] + assert observations == [("src/auth/service.ts::Runner", "Worker")] + + +def test_parameter_shadowing_is_recorded_at_the_call_site(): + _, result = _keys("function target() { return 1; }\nfunction caller(target: () => number) { return target(); }\n") + shadowed = [ + observation.referent_text for observation in result.observations if observation.observed_kind == "call_shadowed" + ] + assert shadowed == ["target"] diff --git a/apps/backend/tests/intelligence/fixtures/resolution/ambiguous-call/src/first.ts b/apps/backend/tests/intelligence/fixtures/resolution/ambiguous-call/src/first.ts new file mode 100644 index 00000000..2ceeb941 --- /dev/null +++ b/apps/backend/tests/intelligence/fixtures/resolution/ambiguous-call/src/first.ts @@ -0,0 +1,3 @@ +export function shared() { + return 1; +} diff --git a/apps/backend/tests/intelligence/fixtures/resolution/ambiguous-call/src/second.ts b/apps/backend/tests/intelligence/fixtures/resolution/ambiguous-call/src/second.ts new file mode 100644 index 00000000..e9ad9992 --- /dev/null +++ b/apps/backend/tests/intelligence/fixtures/resolution/ambiguous-call/src/second.ts @@ -0,0 +1,3 @@ +export function shared() { + return 2; +} diff --git a/apps/backend/tests/intelligence/fixtures/resolution/ambiguous-call/src/source.ts b/apps/backend/tests/intelligence/fixtures/resolution/ambiguous-call/src/source.ts new file mode 100644 index 00000000..1051a4c7 --- /dev/null +++ b/apps/backend/tests/intelligence/fixtures/resolution/ambiguous-call/src/source.ts @@ -0,0 +1,3 @@ +export function caller() { + return shared(); +} diff --git a/apps/backend/tests/intelligence/test_resolution.py b/apps/backend/tests/intelligence/test_resolution.py new file mode 100644 index 00000000..6051b710 --- /dev/null +++ b/apps/backend/tests/intelligence/test_resolution.py @@ -0,0 +1,902 @@ +from __future__ import annotations + +from pathlib import Path +from uuid import uuid4 + +import pytest +from sqlalchemy import create_engine, select +from sqlalchemy.orm import sessionmaker + +from app.core.database import register_sqlite_foreign_key_enforcement +from app.extraction.python import PythonExtractor +from app.extraction.typescript import TypeScriptExtractor +from app.intelligence.resolution import RelationshipResolver +from app.intelligence.snapshot_store import Evidence, Revision, SnapshotStore +from app.models import RepositoryRecord, User +from app.models.base import Base +from app.models.snapshot import RiDiagnostic, RiEdge + + +REVISION = "sha256:" + "b" * 64 +SOURCE_PRODUCER = "fixture-extractor" +SOURCE_VERSION = "1.0.0" +RESOLVER_PRODUCER = "relationship-resolver@1.1.0" +FIXTURE_ROOT = Path(__file__).parent / "fixtures" / "resolution" + + +@pytest.fixture() +def session(tmp_path): + register_sqlite_foreign_key_enforcement() + engine = create_engine(f"sqlite:///{tmp_path / 'resolution.db'}") + Base.metadata.create_all(engine) + with sessionmaker(bind=engine)() as db: + yield db + engine.dispose() + + +def _store(session, producer_version_set: list[str] | None = None): + owner = User(id=str(uuid4()), email="resolver@example.com", password_hash=None) + session.add(owner) + session.commit() + repository = RepositoryRecord( + id=str(uuid4()), + owner_id=owner.id, + name="resolver", + source="upload", + revision_kind="upload", + revision_value=REVISION, + local_path="/x", + status="completed", + file_tree=[], + ) + session.add(repository) + session.commit() + store = SnapshotStore(session) + snapshot = store.begin( + repository_id=repository.id, + revision=Revision("upload", REVISION), + producer_version_set=producer_version_set or [f"{SOURCE_PRODUCER}@{SOURCE_VERSION}", RESOLVER_PRODUCER], + ) + return store, snapshot + + +def _evidence(path: str, line: int = 1) -> Evidence: + return Evidence( + path=path, + start_line=line, + end_line=line, + extractor=SOURCE_PRODUCER, + extractor_version=SOURCE_VERSION, + logical_line_count=30, + ) + + +def _node(store, snapshot, kind: str, key: str, *, name: str | None = None, path: str = "src/source.ts", line: int = 1): + return store.add_node(snapshot, node_kind=kind, stable_key=key, name=name, evidence=[_evidence(path, line)]) + + +def _observation( + store, snapshot, *, kind: str, subject_kind: str, subject: str, referent: str | None, path: str, line: int +): + return store.add_observation( + snapshot, + observed_kind=kind, + subject_kind=subject_kind, + subject_key=subject, + referent_text=referent, + ordinal=1, + evidence=_evidence(path, line), + ) + + +def _edge_triples(session, snapshot): + return { + (edge.subject_key, edge.predicate, edge.object_key) + for edge in session.scalars(select(RiEdge).where(RiEdge.snapshot_id == snapshot.snapshot_id)) + } + + +def test_resolver_persists_all_supported_relationship_kinds(session): + store, snapshot = _store(session) + _node(store, snapshot, "repository", "repo:root") + _node(store, snapshot, "module", "mod:src", path="src/source.ts") + _node(store, snapshot, "file", "file:src/source.ts") + _node(store, snapshot, "file", "file:src/target.ts", path="src/target.ts") + caller = _node(store, snapshot, "symbol", "src/source.ts::caller", name="caller", line=2) + callee = _node(store, snapshot, "symbol", "src/target.ts::callee", name="callee", path="src/target.ts", line=2) + interface = _node(store, snapshot, "symbol", "src/target.ts::Worker", name="Worker", path="src/target.ts", line=4) + implementation = _node(store, snapshot, "symbol", "src/source.ts::Runner", name="Runner", line=7) + route = _node(store, snapshot, "symbol", "src/source.ts::(anonymous:route#1)", name="route", line=12) + dependency = _node(store, snapshot, "dependency", "dep:npm:react", name="react", path="package.json", line=2) + + _observation( + store, + snapshot, + kind="definition", + subject_kind="symbol", + subject=caller.stable_key, + referent=None, + path="src/source.ts", + line=2, + ) + _observation( + store, + snapshot, + kind="definition", + subject_kind="symbol", + subject=callee.stable_key, + referent=None, + path="src/target.ts", + line=2, + ) + _observation( + store, + snapshot, + kind="definition", + subject_kind="symbol", + subject=interface.stable_key, + referent=None, + path="src/target.ts", + line=4, + ) + _observation( + store, + snapshot, + kind="definition", + subject_kind="symbol", + subject=implementation.stable_key, + referent=None, + path="src/source.ts", + line=7, + ) + _observation( + store, + snapshot, + kind="import", + subject_kind="file", + subject="file:src/source.ts", + referent="./target", + path="src/source.ts", + line=1, + ) + _observation( + store, + snapshot, + kind="call", + subject_kind="symbol", + subject=caller.stable_key, + referent=callee.stable_key, + path="src/source.ts", + line=3, + ) + _observation( + store, + snapshot, + kind="implements", + subject_kind="symbol", + subject=implementation.stable_key, + referent=interface.stable_key, + path="src/source.ts", + line=7, + ) + _observation( + store, + snapshot, + kind="route", + subject_kind="symbol", + subject=route.stable_key, + referent="/work", + path="src/source.ts", + line=12, + ) + _observation( + store, + snapshot, + kind="route_handler", + subject_kind="symbol", + subject=route.stable_key, + referent=caller.stable_key, + path="src/source.ts", + line=12, + ) + _observation( + store, + snapshot, + kind="dependency", + subject_kind="dependency", + subject=dependency.stable_key, + referent="react", + path="package.json", + line=2, + ) + + result = RelationshipResolver(store).resolve(snapshot) + assert result.diagnostics_added == 0 + assert result.edges_added == 13 + assert _edge_triples(session, snapshot) == { + ("file:src/source.ts", "contains", caller.stable_key), + ("file:src/source.ts", "defines", caller.stable_key), + ("file:src/target.ts", "contains", callee.stable_key), + ("file:src/target.ts", "defines", callee.stable_key), + ("file:src/target.ts", "contains", interface.stable_key), + ("file:src/target.ts", "defines", interface.stable_key), + ("file:src/source.ts", "contains", implementation.stable_key), + ("file:src/source.ts", "defines", implementation.stable_key), + ("file:src/source.ts", "imports", "file:src/target.ts"), + (caller.stable_key, "calls", callee.stable_key), + (implementation.stable_key, "implements", interface.stable_key), + (route.stable_key, "routes_to", caller.stable_key), + ("repo:root", "depends_on", dependency.stable_key), + } + + # Definitions yield two structural edges, so the end-to-end count above is + # deliberately checked through the stable triples instead of row order. + assert store.seal(snapshot).state == "completed" + + +def test_ambiguous_reference_is_a_warning_without_a_guessed_edge(session): + # A single import binding whose module layout resolves to two files (a real + # `shared.ts` and `shared.tsx`) is genuine, binding-backed ambiguity — the + # only way a name reaches more than one candidate now that repository-wide + # same-name fallback is gone. + store, snapshot = _store(session) + _node(store, snapshot, "repository", "repo:root") + _node(store, snapshot, "file", "file:src/source.ts") + _node(store, snapshot, "file", "file:src/shared.ts", path="src/shared.ts") + _node(store, snapshot, "file", "file:src/shared.tsx", path="src/shared.tsx") + caller = _node(store, snapshot, "symbol", "src/source.ts::caller", name="caller", line=2) + _node(store, snapshot, "symbol", "src/shared.ts::shared", name="shared", path="src/shared.ts") + _node(store, snapshot, "symbol", "src/shared.tsx::shared", name="shared", path="src/shared.tsx") + _observation( + store, + snapshot, + kind="import_binding", + subject_kind="file", + subject="file:src/source.ts", + referent="./shared|shared|shared", + path="src/source.ts", + line=1, + ) + _observation( + store, + snapshot, + kind="call", + subject_kind="symbol", + subject=caller.stable_key, + referent="shared", + path="src/source.ts", + line=3, + ) + + result = RelationshipResolver(store).resolve(snapshot) + assert result.edges_added == 0 + diagnostic = session.scalar(select(RiDiagnostic).where(RiDiagnostic.snapshot_id == snapshot.snapshot_id)) + assert diagnostic is not None + assert diagnostic.code == "RI-RES-AMBIGUOUS" + assert diagnostic.details == { + "observation_id": diagnostic.details["observation_id"], + "candidates": ["src/shared.ts::shared", "src/shared.tsx::shared"], + } + assert _edge_triples(session, snapshot) == set() + assert store.seal(snapshot).state == "completed" + + +def test_call_without_binding_does_not_borrow_a_repository_wide_name(session): + # One same-named symbol exists in another file, but nothing binds it here. + # A unique repository-wide name is not proof, so the call stays unresolved. + store, snapshot = _store(session) + _node(store, snapshot, "repository", "repo:root") + _node(store, snapshot, "file", "file:src/source.ts") + caller = _node(store, snapshot, "symbol", "src/source.ts::caller", name="caller", line=2) + _node(store, snapshot, "symbol", "src/other.ts::helper", name="helper", path="src/other.ts") + _observation( + store, + snapshot, + kind="call", + subject_kind="symbol", + subject=caller.stable_key, + referent="helper", + path="src/source.ts", + line=3, + ) + + result = RelationshipResolver(store).resolve(snapshot) + assert result.edges_added == 0 + diagnostic = session.scalar(select(RiDiagnostic).where(RiDiagnostic.snapshot_id == snapshot.snapshot_id)) + assert diagnostic is not None and diagnostic.code == "RI-RES-UNRESOLVED" + assert not [edge for edge in _edge_triples(session, snapshot) if edge[1] == "calls"] + assert store.seal(snapshot).state == "completed" + + +def test_broken_binding_does_not_fall_back_to_a_same_named_symbol(session): + # The binding names a module that resolves to no file node. Even with an + # unrelated `run` defined elsewhere, a failed binding stays unresolved + # rather than borrowing that symbol. + store, snapshot = _store(session) + _node(store, snapshot, "repository", "repo:root") + _node(store, snapshot, "file", "file:src/source.ts") + caller = _node(store, snapshot, "symbol", "src/source.ts::caller", name="caller", line=2) + _node(store, snapshot, "symbol", "src/other.ts::run", name="run", path="src/other.ts") + _observation( + store, + snapshot, + kind="import_binding", + subject_kind="file", + subject="file:src/source.ts", + referent="./missing|run|run", + path="src/source.ts", + line=1, + ) + _observation( + store, + snapshot, + kind="call", + subject_kind="symbol", + subject=caller.stable_key, + referent="run", + path="src/source.ts", + line=3, + ) + + result = RelationshipResolver(store).resolve(snapshot) + assert result.edges_added == 0 + codes = { + diagnostic.code + for diagnostic in session.scalars(select(RiDiagnostic).where(RiDiagnostic.snapshot_id == snapshot.snapshot_id)) + } + assert codes == {"RI-RES-UNRESOLVED"} + assert not [edge for edge in _edge_triples(session, snapshot) if edge[1] == "calls"] + assert store.seal(snapshot).state == "completed" + + +def test_imported_route_handler_without_resolvable_binding_stays_unresolved(session): + # routes_to follows the same no-fallback rule as calls/implements: a handler + # referent whose binding does not resolve is unresolved, never matched to an + # unrelated same-named component elsewhere. + store, snapshot = _store(session) + _node(store, snapshot, "repository", "repo:root") + _node(store, snapshot, "file", "file:src/routes.ts", path="src/routes.ts") + route = _node( + store, snapshot, "symbol", "src/routes.ts::(anonymous:route#1)", name="route", path="src/routes.ts", line=2 + ) + _node(store, snapshot, "symbol", "src/other.ts::Handler", name="Handler", path="src/other.ts") + _observation( + store, + snapshot, + kind="route", + subject_kind="symbol", + subject=route.stable_key, + referent="/x", + path="src/routes.ts", + line=2, + ) + _observation( + store, + snapshot, + kind="route_handler", + subject_kind="symbol", + subject=route.stable_key, + referent="Handler", + path="src/routes.ts", + line=2, + ) + _observation( + store, + snapshot, + kind="import_binding", + subject_kind="file", + subject="file:src/routes.ts", + referent="./missing|Handler|Handler", + path="src/routes.ts", + line=1, + ) + + result = RelationshipResolver(store).resolve(snapshot) + assert result.edges_added == 0 + assert not [edge for edge in _edge_triples(session, snapshot) if edge[1] == "routes_to"] + codes = { + diagnostic.code + for diagnostic in session.scalars(select(RiDiagnostic).where(RiDiagnostic.snapshot_id == snapshot.snapshot_id)) + } + assert codes == {"RI-RES-UNRESOLVED"} + assert store.seal(snapshot).state == "completed" + + +def test_implemented_interface_without_evidence_stays_unresolved(session): + # `implements Contract` with no same-file definition and no import binding is + # unresolved even though exactly one `Contract` exists elsewhere. + store, snapshot = _store(session) + _node(store, snapshot, "repository", "repo:root") + _node(store, snapshot, "file", "file:src/impl.ts", path="src/impl.ts") + impl = _node(store, snapshot, "symbol", "src/impl.ts::Service", name="Service", path="src/impl.ts", line=2) + _node(store, snapshot, "symbol", "src/other.ts::Contract", name="Contract", path="src/other.ts") + _observation( + store, + snapshot, + kind="implements", + subject_kind="symbol", + subject=impl.stable_key, + referent="Contract", + path="src/impl.ts", + line=2, + ) + + result = RelationshipResolver(store).resolve(snapshot) + assert result.edges_added == 0 + assert not [edge for edge in _edge_triples(session, snapshot) if edge[1] == "implements"] + diagnostic = session.scalar(select(RiDiagnostic).where(RiDiagnostic.snapshot_id == snapshot.snapshot_id)) + assert diagnostic is not None and diagnostic.code == "RI-RES-UNRESOLVED" + assert store.seal(snapshot).state == "completed" + + +def test_unresolved_import_is_preserved_as_a_warning(session): + store, snapshot = _store(session) + _node(store, snapshot, "repository", "repo:root") + _node(store, snapshot, "file", "file:src/source.ts") + _observation( + store, + snapshot, + kind="import", + subject_kind="file", + subject="file:src/source.ts", + referent="./missing", + path="src/source.ts", + line=1, + ) + + result = RelationshipResolver(store).resolve(snapshot) + assert result.edges_added == 0 + diagnostic = session.scalar(select(RiDiagnostic).where(RiDiagnostic.snapshot_id == snapshot.snapshot_id)) + assert diagnostic is not None and diagnostic.code == "RI-RES-UNRESOLVED" + assert diagnostic.details["observation_id"].startswith("obs:sha256:") + assert store.seal(snapshot).state == "completed" + + +def _persist_extraction(store, snapshot, result, producer: str): + producer_version = { + "python-ast": PythonExtractor.version, + "typescript-ast": TypeScriptExtractor.version, + }[producer] + for node in result.nodes: + store.add_node( + snapshot, + node_kind=node.node_kind, + stable_key=node.stable_key, + name=node.name, + language=node.language, + properties=node.properties, + ordered_array_keys=frozenset({"decorators"}) + if node.properties and "decorators" in node.properties + else frozenset(), + evidence=[ + Evidence( + path=evidence.path, + start_line=evidence.start_line, + end_line=evidence.end_line, + extractor=producer, + extractor_version=producer_version, + logical_line_count=evidence.logical_line_count, + granularity=evidence.granularity, + ) + for evidence in node.evidence + ], + ) + for observation in result.observations: + store.add_observation( + snapshot, + observed_kind=observation.observed_kind, + subject_kind=observation.subject_kind, + subject_key=observation.subject_key, + referent_text=observation.referent_text, + ordinal=observation.ordinal, + evidence=Evidence( + path=observation.evidence.path, + start_line=observation.evidence.start_line, + end_line=observation.evidence.end_line, + extractor=producer, + extractor_version=producer_version, + logical_line_count=observation.evidence.logical_line_count, + granularity=observation.evidence.granularity, + ), + ) + + +def test_typescript_extractor_inputs_resolve_import_and_aliased_call(session): + store, snapshot = _store(session, ["typescript-ast@1.2.0", RESOLVER_PRODUCER]) + store.add_node( + snapshot, + node_kind="repository", + stable_key="repo:root", + evidence=[Evidence("src/source.ts", 1, 1, "typescript-ast", "1.2.0", 3)], + ) + extractor = TypeScriptExtractor() + _persist_extraction( + store, + snapshot, + extractor.extract("src/target.ts", b"export function target() { return 1; }\n"), + "typescript-ast", + ) + _persist_extraction( + store, + snapshot, + extractor.extract( + "src/source.ts", + b"import { target as invoke } from './target';\nexport function caller() { return invoke(); }\n", + ), + "typescript-ast", + ) + + result = RelationshipResolver(store).resolve(snapshot) + assert result.diagnostics_added == 0 + assert ("file:src/source.ts", "imports", "file:src/target.ts") in _edge_triples(session, snapshot) + assert ("src/source.ts::caller", "calls", "src/target.ts::target") in _edge_triples(session, snapshot) + assert store.seal(snapshot).state == "completed" + + +def test_python_extractor_route_inputs_resolve_to_the_decorated_handler(session): + store, snapshot = _store(session, ["python-ast@1.1.0", RESOLVER_PRODUCER]) + store.add_node( + snapshot, + node_kind="repository", + stable_key="repo:root", + evidence=[Evidence("app/routes.py", 1, 1, "python-ast", "1.1.0", 3)], + ) + store.add_node( + snapshot, + node_kind="file", + stable_key="file:app/routes.py", + evidence=[Evidence("app/routes.py", 1, 3, "python-ast", "1.1.0", 3, "file")], + ) + _persist_extraction( + store, + snapshot, + PythonExtractor().extract("app/routes.py", b"@router.get('/health')\ndef health():\n return None\n"), + "python-ast", + ) + + result = RelationshipResolver(store).resolve(snapshot) + assert result.diagnostics_added == 0 + assert ( + "app/routes.py::(anonymous:route#1)", + "routes_to", + "app/routes.py::health", + ) in _edge_triples(session, snapshot) + assert store.seal(snapshot).state == "completed" + + +def test_golden_ambiguous_call_fixture_emits_a_diagnostic_not_an_edge(session): + # The fixture calls `shared()` with no import binding while two files export + # a `shared` symbol. Without lexical or import evidence the resolver proves + # nothing, so the honest outcome is a single unresolved diagnostic and no + # `calls` edge — never a guess at one of the same-named repository symbols. + fixture = FIXTURE_ROOT / "ambiguous-call" + store, snapshot = _store(session, ["typescript-ast@1.2.0", RESOLVER_PRODUCER]) + store.add_node( + snapshot, + node_kind="repository", + stable_key="repo:root", + evidence=[Evidence("src/source.ts", 1, 1, "typescript-ast", "1.2.0", 3)], + ) + extractor = TypeScriptExtractor() + for source_path in sorted(fixture.rglob("*.ts")): + relative = source_path.relative_to(fixture).as_posix() + _persist_extraction(store, snapshot, extractor.extract(relative, source_path.read_bytes()), "typescript-ast") + + result = RelationshipResolver(store).resolve(snapshot) + assert result.edges_added == 6 # three definitions, each with contains + defines + diagnostics = list(session.scalars(select(RiDiagnostic).where(RiDiagnostic.snapshot_id == snapshot.snapshot_id))) + assert [diagnostic.code for diagnostic in diagnostics] == ["RI-RES-UNRESOLVED"] + assert not [edge for edge in _edge_triples(session, snapshot) if edge[1] == "calls"] + assert store.seal(snapshot).state == "completed" + + +def _python_import_snapshot(session, source: bytes, *, files: list[str], dependencies: list[str] = ()): + """Persist a single Python source plus explicit file/dependency nodes. + + File nodes stand in for the inventory extractor that runs alongside the + Python extractor in production; the resolver reads only stored nodes. + """ + + store, snapshot = _store(session, ["python-ast@1.1.0", RESOLVER_PRODUCER]) + store.add_node( + snapshot, + node_kind="repository", + stable_key="repo:root", + evidence=[Evidence("app/main.py", 1, 1, "python-ast", "1.1.0", 3)], + ) + for path in files: + store.add_node( + snapshot, + node_kind="file", + stable_key=f"file:{path}", + evidence=[Evidence(path, 1, 1, "python-ast", "1.1.0", 1, "file")], + ) + for name in dependencies: + store.add_node( + snapshot, + node_kind="dependency", + stable_key=f"dep:pypi:{name}", + name=name, + evidence=[Evidence("pyproject.toml", 1, 1, "python-ast", "1.1.0", 1)], + ) + _persist_extraction(store, snapshot, PythonExtractor().extract("app/main.py", source), "python-ast") + return store, snapshot + + +def test_python_absolute_from_import_resolves_to_the_module_file(session): + # `from pkg.service import run` is stored as referent `pkg.service.run`; the + # binding proves the module is `pkg.service`, so the import edge resolves to + # the module file `pkg/service.py`, not only `pkg/service/run.py`. + store, snapshot = _python_import_snapshot( + session, b"from pkg.service import run\n", files=["app/main.py", "pkg/service.py"] + ) + result = RelationshipResolver(store).resolve(snapshot) + assert result.diagnostics_added == 0 + assert ("file:app/main.py", "imports", "file:pkg/service.py") in _edge_triples(session, snapshot) + assert store.seal(snapshot).state == "completed" + + +def test_python_absolute_from_import_prefers_local_module_over_dependency(session): + # A `dep:pypi:pkg` node with the same package root must not replace the local + # module once a local file candidate exists. + store, snapshot = _python_import_snapshot( + session, + b"from pkg.service import run\n", + files=["app/main.py", "pkg/service.py"], + dependencies=["pkg"], + ) + result = RelationshipResolver(store).resolve(snapshot) + assert result.diagnostics_added == 0 + triples = _edge_triples(session, snapshot) + assert ("file:app/main.py", "imports", "file:pkg/service.py") in triples + assert ("file:app/main.py", "imports", "dep:pypi:pkg") not in triples + assert store.seal(snapshot).state == "completed" + + +def test_python_absolute_from_import_without_module_or_dependency_is_unresolved(session): + store, snapshot = _python_import_snapshot(session, b"from pkg.service import run\n", files=["app/main.py"]) + result = RelationshipResolver(store).resolve(snapshot) + assert result.edges_added == 0 + assert not [edge for edge in _edge_triples(session, snapshot) if edge[1] == "imports"] + diagnostic = session.scalar(select(RiDiagnostic).where(RiDiagnostic.snapshot_id == snapshot.snapshot_id)) + assert diagnostic is not None and diagnostic.code == "RI-RES-UNRESOLVED" + assert store.seal(snapshot).state == "completed" + + +def test_python_absolute_from_import_with_two_module_candidates_is_ambiguous(session): + # Both interpretations exist as files: `run` as a member of `pkg/service.py` + # and `run` as the submodule `pkg/service/run.py`. Deterministically ambiguous. + store, snapshot = _python_import_snapshot( + session, + b"from pkg.service import run\n", + files=["app/main.py", "pkg/service.py", "pkg/service/run.py"], + ) + result = RelationshipResolver(store).resolve(snapshot) + assert result.edges_added == 0 + assert not [edge for edge in _edge_triples(session, snapshot) if edge[1] == "imports"] + diagnostic = session.scalar(select(RiDiagnostic).where(RiDiagnostic.snapshot_id == snapshot.snapshot_id)) + assert diagnostic is not None and diagnostic.code == "RI-RES-AMBIGUOUS" + assert diagnostic.details["candidates"] == ["file:pkg/service.py", "file:pkg/service/run.py"] + assert store.seal(snapshot).state == "completed" + + +def test_shadowed_typescript_parameter_does_not_resolve_to_same_file_global(session): + store, snapshot = _store(session, ["typescript-ast@1.2.0", RESOLVER_PRODUCER]) + store.add_node( + snapshot, + node_kind="repository", + stable_key="repo:root", + evidence=[Evidence("src/main.ts", 1, 1, "typescript-ast", "1.2.0", 2)], + ) + _persist_extraction( + store, + snapshot, + TypeScriptExtractor().extract( + "src/main.ts", + b"export function target() { return 1; }\n" + b"export function caller(target: () => number) { return target(); }\n", + ), + "typescript-ast", + ) + + result = RelationshipResolver(store).resolve(snapshot) + + assert result.diagnostics_added == 1 + assert ( + "src/main.ts::caller", + "calls", + "src/main.ts::target", + ) not in _edge_triples(session, snapshot) + diagnostic = session.scalar( + select(RiDiagnostic).where( + RiDiagnostic.snapshot_id == snapshot.snapshot_id, + RiDiagnostic.code == "RI-RES-UNRESOLVED", + ) + ) + assert diagnostic is not None + assert diagnostic.message == "calls target is shadowed by a local binding" + + +def test_shadowed_python_parameter_does_not_resolve_to_same_file_global(session): + store, snapshot = _store(session, ["python-ast@1.1.0", RESOLVER_PRODUCER]) + store.add_node( + snapshot, + node_kind="repository", + stable_key="repo:root", + evidence=[Evidence("app/main.py", 1, 1, "python-ast", "1.1.0", 4)], + ) + store.add_node( + snapshot, + node_kind="file", + stable_key="file:app/main.py", + evidence=[Evidence("app/main.py", 1, 4, "python-ast", "1.1.0", 4, "file")], + ) + _persist_extraction( + store, + snapshot, + PythonExtractor().extract( + "app/main.py", + b"def target():\n return 1\ndef caller(target):\n return target()\n", + ), + "python-ast", + ) + + result = RelationshipResolver(store).resolve(snapshot) + + assert result.diagnostics_added == 1 + assert ( + "app/main.py::caller", + "calls", + "app/main.py::target", + ) not in _edge_triples(session, snapshot) + + +def test_default_imported_react_route_handler_resolves(session): + store, snapshot = _store(session, ["typescript-ast@1.2.0", RESOLVER_PRODUCER]) + store.add_node( + snapshot, + node_kind="repository", + stable_key="repo:root", + evidence=[Evidence("src/routes.tsx", 1, 1, "typescript-ast", "1.2.0", 2)], + ) + extractor = TypeScriptExtractor() + _persist_extraction( + store, + snapshot, + extractor.extract( + "src/Home.tsx", + b"const Home = () => null;\nexport default Home;\n", + ), + "typescript-ast", + ) + _persist_extraction( + store, + snapshot, + extractor.extract( + "src/routes.tsx", + b"import Home from './Home';\nconst routes = createBrowserRouter([{ path: '/', Component: Home }]);\n", + ), + "typescript-ast", + ) + + RelationshipResolver(store).resolve(snapshot) + + assert ( + "src/routes.tsx::(anonymous:route#1)", + "routes_to", + "src/Home.tsx::Home", + ) in _edge_triples(session, snapshot) + + +def test_top_level_and_recursive_calls_resolve_without_dropping_self_edges(session): + store, snapshot = _store(session, ["typescript-ast@1.2.0", RESOLVER_PRODUCER]) + store.add_node( + snapshot, + node_kind="repository", + stable_key="repo:root", + evidence=[Evidence("src/main.ts", 1, 1, "typescript-ast", "1.2.0", 4)], + ) + _persist_extraction( + store, + snapshot, + TypeScriptExtractor().extract( + "src/main.ts", + b"export function recurse(value: number): number {\n" + b" return value > 0 ? recurse(value - 1) : value;\n" + b"}\n" + b"recurse(2);\n", + ), + "typescript-ast", + ) + + result = RelationshipResolver(store).resolve(snapshot) + + assert result.diagnostics_added == 0 + triples = _edge_triples(session, snapshot) + assert ( + "src/main.ts::recurse", + "calls", + "src/main.ts::recurse", + ) in triples + assert ( + "file:src/main.ts", + "calls", + "src/main.ts::recurse", + ) in triples + + +def test_abstract_generic_implements_resolves_to_base_interface(session): + store, snapshot = _store(session, ["typescript-ast@1.2.0", RESOLVER_PRODUCER]) + store.add_node( + snapshot, + node_kind="repository", + stable_key="repo:root", + evidence=[Evidence("src/worker.ts", 1, 1, "typescript-ast", "1.2.0", 2)], + ) + _persist_extraction( + store, + snapshot, + TypeScriptExtractor().extract( + "src/worker.ts", + b"export interface Worker { run(value: T): void; }\n" + b"export abstract class Runner implements Worker { abstract run(value: string): void; }\n", + ), + "typescript-ast", + ) + + result = RelationshipResolver(store).resolve(snapshot) + + assert result.diagnostics_added == 0 + assert ( + "src/worker.ts::Runner", + "implements", + "src/worker.ts::Worker", + ) in _edge_triples(session, snapshot) + + +def test_python_relative_import_still_resolves_to_the_sibling_module(session): + store, snapshot = _python_import_snapshot( + session, b"from .service import run\n", files=["app/main.py", "app/service.py"] + ) + result = RelationshipResolver(store).resolve(snapshot) + assert result.diagnostics_added == 0 + assert ("file:app/main.py", "imports", "file:app/service.py") in _edge_triples(session, snapshot) + assert store.seal(snapshot).state == "completed" + + +def test_resolver_checks_cancellation_between_observations(session, monkeypatch): + store, snapshot = _store(session) + _node(store, snapshot, "file", "file:src/source.ts") + first = _node(store, snapshot, "symbol", "src/source.ts::first", name="first") + second = _node(store, snapshot, "symbol", "src/source.ts::second", name="second", line=2) + for line, symbol in enumerate((first, second), start=1): + _observation( + store, + snapshot, + kind="definition", + subject_kind="symbol", + subject=symbol.stable_key, + referent=None, + path="src/source.ts", + line=line, + ) + + resolver = RelationshipResolver(store) + resolved: list[str] = [] + original_resolve_definition = resolver._resolve_definition + + def _resolve_definition(input_, nodes_by_key): + resolved.append(input_.observation.subject_key) + return original_resolve_definition(input_, nodes_by_key) + + def _check_cancelled(): + if resolved: + raise RuntimeError("cancelled") + + monkeypatch.setattr(resolver, "_resolve_definition", _resolve_definition) + + with pytest.raises(RuntimeError, match="cancelled"): + resolver.resolve(snapshot, check_cancelled=_check_cancelled) + + assert len(resolved) == 1 + assert resolved[0] in {first.stable_key, second.stable_key} diff --git a/apps/backend/tests/intelligence/test_service_and_iac_resolution.py b/apps/backend/tests/intelligence/test_service_and_iac_resolution.py new file mode 100644 index 00000000..574ae88f --- /dev/null +++ b/apps/backend/tests/intelligence/test_service_and_iac_resolution.py @@ -0,0 +1,401 @@ +"""Resolution of service-interaction and IaC observations (#209). + +These resolvers are only allowed to connect facts the extractors already +proved. The tests below pin what they emit, what they refuse to emit, and the +truth class and provenance every emitted edge carries. +""" + +from __future__ import annotations + +from uuid import uuid4 + +import pytest +from sqlalchemy import create_engine, select +from sqlalchemy.orm import sessionmaker + +from app.core.database import register_sqlite_foreign_key_enforcement +from app.intelligence.resolution import RI_RES_UNRESOLVED, RelationshipResolver +from app.intelligence.snapshot_store import Evidence, Revision, SnapshotStore +from app.models import RepositoryRecord, User +from app.models.base import Base +from app.models.snapshot import RiDerivation, RiDiagnostic, RiEdge, RiEvidence + +REVISION = "sha256:" + "c" * 64 +SOURCE_PRODUCER = "fixture-extractor" +SOURCE_VERSION = "1.0.0" +RESOLVER_PRODUCER = f"{RelationshipResolver.name}@{RelationshipResolver.version}" + + +@pytest.fixture() +def session(tmp_path): + register_sqlite_foreign_key_enforcement() + engine = create_engine(f"sqlite:///{tmp_path / 'service-resolution.db'}") + Base.metadata.create_all(engine) + with sessionmaker(bind=engine)() as db: + yield db + engine.dispose() + + +def _store(session): + owner = User(id=str(uuid4()), email=f"{uuid4().hex[:8]}@example.com", password_hash=None) + session.add(owner) + session.commit() + repository = RepositoryRecord( + id=str(uuid4()), + owner_id=owner.id, + name="resolver", + source="upload", + revision_kind="upload", + revision_value=REVISION, + local_path="/x", + status="completed", + file_tree=[], + ) + session.add(repository) + session.commit() + store = SnapshotStore(session) + snapshot = store.begin( + repository_id=repository.id, + revision=Revision("upload", REVISION), + producer_version_set=[f"{SOURCE_PRODUCER}@{SOURCE_VERSION}", RESOLVER_PRODUCER], + ) + return store, snapshot + + +def _evidence(path: str, line: int = 1) -> Evidence: + return Evidence( + path=path, + start_line=line, + end_line=line, + extractor=SOURCE_PRODUCER, + extractor_version=SOURCE_VERSION, + logical_line_count=30, + ) + + +def _node(store, snapshot, kind: str, key: str, *, name=None, path="app/client.py", line=1, properties=None): + return store.add_node( + snapshot, + node_kind=kind, + stable_key=key, + name=name, + properties=properties, + evidence=[_evidence(path, line)], + ) + + +def _observation(store, snapshot, *, kind, subject_kind, subject, referent, path="app/client.py", line=1): + return store.add_observation( + snapshot, + observed_kind=kind, + subject_kind=subject_kind, + subject_key=subject, + referent_text=referent, + ordinal=1, + evidence=_evidence(path, line), + ) + + +def _triples(session, snapshot): + return { + (edge.subject_key, edge.predicate, edge.object_key) + for edge in session.scalars(select(RiEdge).where(RiEdge.snapshot_id == snapshot.snapshot_id)) + } + + +def _diagnostics(session, snapshot): + return [ + (item.code, item.message) + for item in session.scalars(select(RiDiagnostic).where(RiDiagnostic.snapshot_id == snapshot.snapshot_id)) + ] + + +def _service_graph(store, snapshot): + """A module, a symbol whose span contains the call, and the service node.""" + + _node(store, snapshot, "repository", "repo:root") + _node(store, snapshot, "module", "mod:app") + _node(store, snapshot, "file", "file:app/client.py") + store.add_node( + snapshot, + node_kind="symbol", + stable_key="app/client.py::load", + name="load", + evidence=[ + Evidence( + path="app/client.py", + start_line=4, + end_line=6, + extractor=SOURCE_PRODUCER, + extractor_version=SOURCE_VERSION, + logical_line_count=30, + ) + ], + ) + _node( + store, + snapshot, + "service", + "svc:https://api.example.com", + name="https://api.example.com", + properties={"origin": "https://api.example.com"}, + line=5, + ) + + +# --- calls_service ---------------------------------------------------------- + + +def test_a_proven_call_resolves_to_the_service_node_for_its_origin(session): + store, snapshot = _store(session) + _service_graph(store, snapshot) + _observation( + store, + snapshot, + kind="http_call", + subject_kind="module", + subject="mod:app", + referent="GET|https://api.example.com|/v1/users", + line=5, + ) + + result = RelationshipResolver(store).resolve(snapshot) + + assert result.edges_added == 1 + assert result.diagnostics_added == 0 + # Attributed to the symbol whose span contains the call, exactly like a + # generic ``calls`` reference — not to the whole module. + assert _triples(session, snapshot) == {("app/client.py::load", "calls_service", "svc:https://api.example.com")} + + +def test_a_call_outside_any_symbol_is_attributed_to_its_module(session): + store, snapshot = _store(session) + _service_graph(store, snapshot) + _observation( + store, + snapshot, + kind="http_call", + subject_kind="module", + subject="mod:app", + referent="POST|https://api.example.com|/v1", + line=1, + ) + + RelationshipResolver(store).resolve(snapshot) + + assert _triples(session, snapshot) == {("mod:app", "calls_service", "svc:https://api.example.com")} + + +def test_a_resolved_service_edge_is_evidence_backed_and_derived_from_its_observation(session): + store, snapshot = _store(session) + _service_graph(store, snapshot) + observation = _observation( + store, + snapshot, + kind="http_call", + subject_kind="module", + subject="mod:app", + referent="GET|https://api.example.com|/v1/users", + line=5, + ) + + RelationshipResolver(store).resolve(snapshot) + + edge = session.scalars(select(RiEdge).where(RiEdge.snapshot_id == snapshot.snapshot_id)).one() + assert edge.truth_class == "resolved" + assert edge.producer == RelationshipResolver.name + assert edge.producer_version == RelationshipResolver.version + evidence = session.scalars( + select(RiEvidence).where(RiEvidence.snapshot_id == snapshot.snapshot_id, RiEvidence.edge_ref == edge.id) + ).all() + assert [(item.path, item.start_line, item.end_line) for item in evidence] == [("app/client.py", 5, 5)] + derivations = session.scalars( + select(RiDerivation).where(RiDerivation.snapshot_id == snapshot.snapshot_id, RiDerivation.edge_ref == edge.id) + ).all() + assert [(item.ref_kind, item.ref_identity) for item in derivations] == [("observation", observation.observation_id)] + + +def test_an_origin_with_no_service_node_stays_unresolved(session): + store, snapshot = _store(session) + _service_graph(store, snapshot) + _observation( + store, + snapshot, + kind="http_call", + subject_kind="module", + subject="mod:app", + referent="GET|https://other.example.com|/v1", + line=5, + ) + + result = RelationshipResolver(store).resolve(snapshot) + + assert result.edges_added == 0 + assert _triples(session, snapshot) == set() + assert _diagnostics(session, snapshot) == [ + (RI_RES_UNRESOLVED, "calls_service target service is absent from the snapshot") + ] + + +@pytest.mark.parametrize("referent", ["GET|https://api.example.com", "", "GET||/v1", "not-a-destination"]) +def test_a_malformed_destination_referent_is_never_repaired(session, referent): + store, snapshot = _store(session) + _service_graph(store, snapshot) + _observation( + store, + snapshot, + kind="http_call", + subject_kind="module", + subject="mod:app", + referent=referent or None, + line=5, + ) + + result = RelationshipResolver(store).resolve(snapshot) + + assert result.edges_added == 0 + assert _diagnostics(session, snapshot) == [(RI_RES_UNRESOLVED, "calls_service has no parsable destination")] + + +# --- declares --------------------------------------------------------------- + + +def test_a_declared_iac_resource_is_attached_to_the_repository(session): + store, snapshot = _store(session) + _node(store, snapshot, "repository", "repo:root", path="docker-compose.yml") + _node( + store, + snapshot, + "iac_resource", + "iac:docker-compose.yml::service/api", + name="api", + path="docker-compose.yml", + line=2, + properties={ + "resource_type": "service", + "manifest_format": "docker-compose", + "manifest_path": "docker-compose.yml", + }, + ) + _observation( + store, + snapshot, + kind="iac_resource", + subject_kind="iac_resource", + subject="iac:docker-compose.yml::service/api", + referent="service/api", + path="docker-compose.yml", + line=2, + ) + + result = RelationshipResolver(store).resolve(snapshot) + + assert result.edges_added == 1 + assert _triples(session, snapshot) == {("repo:root", "declares", "iac:docker-compose.yml::service/api")} + edge = session.scalars(select(RiEdge).where(RiEdge.snapshot_id == snapshot.snapshot_id)).one() + assert edge.truth_class == "resolved" + assert edge.object_kind == "iac_resource" + + +def test_an_iac_observation_without_its_node_stays_unresolved(session): + store, snapshot = _store(session) + _node(store, snapshot, "repository", "repo:root", path="docker-compose.yml") + _node(store, snapshot, "file", "file:docker-compose.yml", path="docker-compose.yml") + _observation( + store, + snapshot, + kind="iac_resource", + subject_kind="file", + subject="file:docker-compose.yml", + referent="service/api", + path="docker-compose.yml", + line=2, + ) + + result = RelationshipResolver(store).resolve(snapshot) + + assert result.edges_added == 0 + assert _diagnostics(session, snapshot) == [(RI_RES_UNRESOLVED, "iac resource declaration is incomplete")] + + +# --- lockfile resolutions are not relationship inputs ----------------------- + + +def test_a_lockfile_resolution_never_becomes_a_direct_dependency_edge(session): + """A pin proves an installed version, not that the repository depends on it.""" + + store, snapshot = _store(session) + _node(store, snapshot, "repository", "repo:root", path="package-lock.json") + _node(store, snapshot, "dependency", "dep:npm:tiny", name="tiny", path="package-lock.json", line=9) + _observation( + store, + snapshot, + kind="resolution", + subject_kind="dependency", + subject="dep:npm:tiny", + referent="0.1.0", + path="package-lock.json", + line=9, + ) + + result = RelationshipResolver(store).resolve(snapshot) + + assert result.edges_added == 0 + # It is retained as an observation rather than downgraded to a diagnostic: + # nothing about it is unresolved, it simply is not a relationship claim. + assert result.diagnostics_added == 0 + assert _triples(session, snapshot) == set() + + +def test_a_manifest_declaration_still_produces_the_direct_dependency_edge(session): + store, snapshot = _store(session) + _node(store, snapshot, "repository", "repo:root", path="package.json") + _node(store, snapshot, "dependency", "dep:npm:react", name="react", path="package.json", line=4) + _observation( + store, + snapshot, + kind="dependency", + subject_kind="dependency", + subject="dep:npm:react", + referent="react", + path="package.json", + line=4, + ) + _observation( + store, + snapshot, + kind="resolution", + subject_kind="dependency", + subject="dep:npm:react", + referent="18.3.1", + path="package-lock.json", + line=9, + ) + + RelationshipResolver(store).resolve(snapshot) + + assert _triples(session, snapshot) == {("repo:root", "depends_on", "dep:npm:react")} + + +# --- determinism ------------------------------------------------------------ + + +def test_resolution_is_deterministic_across_repeated_passes(session): + store, snapshot = _store(session) + _service_graph(store, snapshot) + _observation( + store, + snapshot, + kind="http_call", + subject_kind="module", + subject="mod:app", + referent="GET|https://api.example.com|/v1/users", + line=5, + ) + + first = RelationshipResolver(store).resolve(snapshot) + triples = _triples(session, snapshot) + second = RelationshipResolver(store).resolve(snapshot) + + assert (first.edges_added, first.diagnostics_added) == (second.edges_added, second.diagnostics_added) + assert _triples(session, snapshot) == triples diff --git a/apps/backend/tests/test_account_deletion.py b/apps/backend/tests/test_account_deletion.py new file mode 100644 index 00000000..0d4465c4 --- /dev/null +++ b/apps/backend/tests/test_account_deletion.py @@ -0,0 +1,270 @@ +"""Coverage for verified account deletion (#290). + +These tests exercise the real ``DELETE /auth/me`` endpoint end to end +(reauth, cascade, storage cleanup, cross-owner isolation) plus one +service-level test for the seed-user guard, which has no reachable HTTP path +since the seed user can never hold an access token. +""" + +from pathlib import Path + +from tests.conftest import DEFAULT_TEST_PASSWORD, register_user + + +def _delete(client, headers, password=DEFAULT_TEST_PASSWORD, confirm_email="alice@example.com"): + return client.request( + "DELETE", + "/auth/me", + headers=headers, + json={"password": password, "confirmEmail": confirm_email}, + ) + + +# --- reauthentication and confirmation ----------------------------------------- + + +def test_delete_account_rejects_wrong_password(client): + auth = register_user(client, "alice@example.com") + + response = _delete(client, auth["headers"], password="wrong-password-entirely") + + assert response.status_code == 401 + # The account must still exist and be usable. + assert client.get("/auth/me", headers=auth["headers"]).status_code == 200 + + +def test_delete_account_rejects_confirmation_email_mismatch(client): + auth = register_user(client, "alice@example.com") + + response = _delete(client, auth["headers"], confirm_email="not-alice@example.com") + + assert response.status_code == 422 + assert client.get("/auth/me", headers=auth["headers"]).status_code == 200 + + +def test_delete_account_confirmation_email_is_case_and_whitespace_insensitive(client): + auth = register_user(client, "alice@example.com") + + response = _delete(client, auth["headers"], confirm_email=" Alice@Example.com ") + + assert response.status_code == 204 + + +# --- success path --------------------------------------------------------------- + + +def test_delete_account_succeeds_and_clears_the_refresh_cookie(client): + auth = register_user(client, "alice@example.com") + + response = _delete(client, auth["headers"]) + + assert response.status_code == 204 + set_cookie = response.headers.get("set-cookie", "") + assert "partha_refresh=" in set_cookie + # Deletion (expiry in the past / empty value) rather than a fresh session. + assert "Max-Age=0" in set_cookie or "expires=" in set_cookie.lower() + + +def test_deleted_account_can_no_longer_authenticate(client): + auth = register_user(client, "alice@example.com") + assert _delete(client, auth["headers"]).status_code == 204 + + # The now-stale access token is rejected... + assert client.get("/auth/me", headers=auth["headers"]).status_code == 401 + # ...and the account cannot log back in with its old credentials. + login = client.post("/auth/login", json={"email": "alice@example.com", "password": DEFAULT_TEST_PASSWORD}) + assert login.status_code == 401 + + +def test_repeated_deletion_request_is_a_safe_terminal_401(client): + auth = register_user(client, "alice@example.com") + assert _delete(client, auth["headers"]).status_code == 204 + + # A second attempt with the same (now invalid) token must not 500 or + # somehow re-run deletion; it fails the same way any stale token does. + second = _delete(client, auth["headers"]) + assert second.status_code == 401 + + +# --- cascade ---------------------------------------------------------------------- + + +def test_delete_account_removes_owner_scoped_database_rows(client, tmp_path: Path): + auth = register_user(client, "alice@example.com") + headers = auth["headers"] + user_id = auth["user"]["id"] + + repo_dir = tmp_path / "storage" / "repositories" / "repo-1" + repo_dir.mkdir(parents=True) + (repo_dir / "marker.txt").write_text("hello") + + from app.core.database import SessionLocal + from app.intelligence.snapshot_store import Evidence, Revision, SnapshotStore + from app.models.ai_conversation import AiConversationMessageRecord + from app.models.ai_provider_config import AiProviderConfigRecord + from app.models.analysis_job import AnalysisJob + from app.models.refresh_token import RefreshToken + from app.models.repository import RepositoryRecord + from app.models.snapshot import RiSnapshot + + revision_kind = "upload" + revision_value = f"sha256:{'0' * 64}" + + db = SessionLocal() + try: + db.add( + RepositoryRecord( + id="repo-1", + owner_id=user_id, + name="alice-repo", + source="upload", + local_path=str(repo_dir), + status="completed", + revision_kind=revision_kind, + revision_value=revision_value, + ) + ) + db.commit() + + db.add( + AiProviderConfigRecord( + id="cfg-1", + owner_id=user_id, + provider="openai", + encrypted_api_key="ciphertext", + api_key_last4="abcd", + ) + ) + db.add( + AiConversationMessageRecord( + id="msg-1", + owner_id=user_id, + repository_id="repo-1", + sequence=0, + role="user", + content="hello", + ) + ) + db.add( + AnalysisJob( + id="job-1", + repository_id="repo-1", + owner_id=user_id, + revision_kind=revision_kind, + revision_value=revision_value, + config_hash="sha256:config", + status="completed", + ) + ) + db.commit() + + # A sealed ri.v1 snapshot is keyed by (repository_id, revision_kind, + # revision_value), not directly by owner -- it cascades from the + # repository row, which itself cascades from the user. Sealing one + # here is what actually exercises that second hop, rather than + # asserting a fact this test can't observe. + store = SnapshotStore(db) + snapshot = store.begin( + repository_id="repo-1", + revision=Revision(revision_kind, revision_value, None), + schema_version="ri.v1", + producer_version_set=["repository-inventory@1.1.0"], + ) + store.add_node( + snapshot, + node_kind="repository", + stable_key="repo:root", + name="repository", + language=None, + evidence=[ + Evidence( + path="README.md", + start_line=1, + end_line=1, + logical_line_count=1, + extractor="repository-inventory", + extractor_version="1.1.0", + ) + ], + ) + store.seal(snapshot) + snapshot_id = snapshot.snapshot_id + finally: + db.close() + + assert repo_dir.exists() + + response = _delete(client, headers) + assert response.status_code == 204 + + db = SessionLocal() + try: + from app.models.account_deletion_audit import AccountDeletionAuditRecord + from app.models.user import User + + assert db.get(User, user_id) is None + assert db.query(RepositoryRecord).filter_by(owner_id=user_id).count() == 0 + assert db.query(AiProviderConfigRecord).filter_by(owner_id=user_id).count() == 0 + assert db.query(AiConversationMessageRecord).filter_by(owner_id=user_id).count() == 0 + assert db.query(RefreshToken).filter_by(user_id=user_id).count() == 0 + assert db.query(AnalysisJob).filter_by(owner_id=user_id).count() == 0 + assert db.get(RiSnapshot, snapshot_id) is None + + audits = db.query(AccountDeletionAuditRecord).filter_by(deleted_user_id=user_id).all() + assert len(audits) == 1 + assert audits[0].status == "completed" + assert audits[0].completed_at is not None + finally: + db.close() + + # Storage cleanup ran too: the repository's on-disk directory is gone. + assert not repo_dir.exists() + + +def test_delete_account_does_not_affect_other_owners(client): + alice = register_user(client, "alice@example.com") + bob = register_user(client, "bob@example.com") + + from app.core.database import SessionLocal + from app.models.repository import RepositoryRecord + + db = SessionLocal() + try: + db.add( + RepositoryRecord( + id="bob-repo", + owner_id=bob["user"]["id"], + name="bob-repo", + source="upload", + local_path="/tmp/bob-repo-does-not-exist", + status="completed", + ) + ) + db.commit() + finally: + db.close() + + assert _delete(client, alice["headers"]).status_code == 204 + + # Bob is untouched: still authenticated, still owns his repository. + assert client.get("/auth/me", headers=bob["headers"]).status_code == 200 + listing = client.get("/repositories", headers=bob["headers"]) + assert listing.status_code == 200 + assert listing.json()["total"] == 1 + + +# --- seed user guard (service-level: unreachable via HTTP) ---------------------- + + +def test_seed_user_cannot_be_deleted(): + from app.core.exceptions import ValidationServiceError + from app.models.user import SEED_USER_ID, SEED_USER_EMAIL, User + from app.services.account_deletion_service import AccountDeletionService + + seed_user = User(id=SEED_USER_ID, email=SEED_USER_EMAIL, password_hash=None) + service = AccountDeletionService(db=None, repository=None, storage=None) # type: ignore[arg-type] + + import pytest + + with pytest.raises(ValidationServiceError): + service.delete_account(seed_user, password="irrelevant", confirm_email=SEED_USER_EMAIL) diff --git a/apps/backend/tests/test_ai_api_contract.py b/apps/backend/tests/test_ai_api_contract.py index f7a2be70..14883c51 100644 --- a/apps/backend/tests/test_ai_api_contract.py +++ b/apps/backend/tests/test_ai_api_contract.py @@ -4,6 +4,8 @@ from app.ai.providers.registry import ProviderRegistry from app.ai.types import AiProviderConfig, AiProviderResponse, PromptBundle from app.api.deps import get_provider_registry +from tests.analysis_helpers import run_analysis_jobs +from tests.api_assertions import assert_error_response def _zip_bytes(files: dict[str, bytes]) -> bytes: @@ -22,14 +24,54 @@ async def complete(self, config: AiProviderConfig, prompt: PromptBundle) -> AiPr return AiProviderResponse(content="Repository summary from test provider.") -def test_ai_query_endpoint_preserves_public_response_contract(client): +class ConnectionTestProvider: + async def complete(self, config: AiProviderConfig, prompt: PromptBundle) -> AiProviderResponse: + assert config.provider == "openai" + assert config.api_key == "test-key" + assert config.model == "test-model" + assert prompt.system_prompt == "Reply with the single word: ok" + assert prompt.user_prompt == "Connection test." + return AiProviderResponse(content="ok") + + +def test_ai_test_endpoint_checks_the_saved_provider_without_external_network(auth_client): + registry = ProviderRegistry() + registry.register("openai", ConnectionTestProvider()) + auth_client.app.dependency_overrides[get_provider_registry] = lambda: registry + + try: + configured = auth_client.put( + "/ai/config", + json={"provider": "openai", "apiKey": "test-key", "model": "test-model"}, + ) + assert configured.status_code == 200 + + response = auth_client.post("/ai/test", json={"provider": "openai"}) + + assert response.status_code == 200 + body = response.json() + assert body["ok"] is True + assert body["message"] == "openai connection succeeded." + assert body["checkedAt"] + finally: + auth_client.app.dependency_overrides.pop(get_provider_registry, None) + + +def test_ai_test_endpoint_returns_the_standard_error_without_a_provider(auth_client): + response = auth_client.post("/ai/test", json={}) + + error = assert_error_response(response, 422, "validation_error") + assert "choose an ai provider" in error.message.lower() + + +def test_ai_query_endpoint_preserves_public_response_contract(auth_client): provider = ContractProvider() registry = ProviderRegistry() registry.register("openai", provider) - client.app.dependency_overrides[get_provider_registry] = lambda: registry + auth_client.app.dependency_overrides[get_provider_registry] = lambda: registry try: - upload_response = client.post( + upload_response = auth_client.post( "/repositories/upload", files={ "file": ( @@ -46,44 +88,93 @@ def test_ai_query_endpoint_preserves_public_response_contract(client): ) assert upload_response.status_code == 201 repository_id = upload_response.json()["id"] + assert auth_client.post(f"/analysis/{repository_id}/start").status_code == 200 + assert run_analysis_jobs() == 1 - config_response = client.put( + config_response = auth_client.put( "/ai/config", json={"provider": "openai", "apiKey": "test-key", "model": "test-model"}, ) assert config_response.status_code == 200 - response = client.post( + response = auth_client.post( "/ai/query", json={ "repositoryId": repository_id, "query": "Summarize this repository", - "context": {"selectedFile": "/src/app.ts"}, + "context": {"selectedFile": "src/app.ts"}, }, ) assert response.status_code == 200 body = response.json() assert set(body) == {"message", "suggestions"} - assert body["suggestions"] == [ - "Explain the main architecture boundaries.", - "What files should I read first?", - "What are the highest-risk engineering issues?", - ] + assert body["suggestions"] == [] message = body["message"] assert set(message) == {"role", "content", "timestamp", "citations"} assert message["role"] == "assistant" assert message["content"] == "Repository summary from test provider." assert isinstance(message["timestamp"], str) - assert isinstance(message["citations"], list) - assert message["citations"] - - citation = message["citations"][0] - assert set(citation) == {"file", "startLine", "endLine", "content"} - assert citation["file"] == "/src/app.ts" - assert citation["startLine"] == 1 - assert citation["endLine"] == 1 - assert citation["content"] == "Repository file path included in analysis context." + # Fabricated 1:1 placeholder citations were removed (F4/F5). The field is + # still present in the contract but is null until graph-grounded citations exist. + assert message["citations"] is None finally: - client.app.dependency_overrides.pop(get_provider_registry, None) + auth_client.app.dependency_overrides.pop(get_provider_registry, None) + + +def test_ai_query_resolves_snapshot_before_provider_configuration(auth_client): + upload = auth_client.post( + "/repositories/upload", + files={ + "file": ( + "no-snapshot.zip", + _zip_bytes({"sample/src/app.ts": b"export const answer = 42;\n"}), + "application/octet-stream", + ) + }, + ) + repository_id = upload.json()["id"] + + before_analysis = auth_client.post( + "/ai/query", + json={"repositoryId": repository_id, "query": "Summarize"}, + ) + assert_error_response(before_analysis, 404, "not_found") + + assert auth_client.post(f"/analysis/{repository_id}/start").status_code == 200 + assert run_analysis_jobs() == 1 + after_analysis = auth_client.post( + "/ai/query", + json={"repositoryId": repository_id, "query": "Summarize"}, + ) + error = assert_error_response(after_analysis, 422, "validation_error") + assert "provider is not configured" in error.message.lower() + + +def test_ai_query_rejects_a_selected_file_absent_from_the_snapshot(auth_client): + upload = auth_client.post( + "/repositories/upload", + files={ + "file": ( + "selected-file.zip", + _zip_bytes({"sample/src/app.ts": b"export const answer = 42;\n"}), + "application/octet-stream", + ) + }, + ) + repository_id = upload.json()["id"] + assert auth_client.post(f"/analysis/{repository_id}/start").status_code == 200 + assert run_analysis_jobs() == 1 + + response = auth_client.post( + "/ai/query", + json={ + "repositoryId": repository_id, + "query": "Explain it", + "context": {"selectedFile": "src/missing.ts"}, + }, + ) + + error = assert_error_response(response, 422, "validation_error") + assert error.details["selectedFile"] == "src/missing.ts" diff --git a/apps/backend/tests/test_ai_architecture.py b/apps/backend/tests/test_ai_architecture.py index 228e0a8a..a7dbefd1 100644 --- a/apps/backend/tests/test_ai_architecture.py +++ b/apps/backend/tests/test_ai_architecture.py @@ -2,55 +2,131 @@ from datetime import UTC, datetime from pathlib import Path +import pytest + from app.ai.orchestrator import AiOrchestrator from app.ai.prompt_builder import PromptBuilder from app.ai.providers.factory import ProviderFactory from app.ai.providers.registry import ProviderRegistry from app.ai.repository_context import RepositoryContextBuilder from app.ai.types import AiProviderConfig, AiProviderResponse, PromptBundle -from app.intelligence.engine import RepositoryIntelligenceEngine +from app.core.exceptions import NotFoundError +from app.intelligence.query_service import ( + ProductSnapshotProjection, + SnapshotDependencyDeclaration, + SnapshotDependencyFact, + SnapshotFileFact, + SnapshotModuleFact, +) from app.models.repository import RepositoryRecord -from app.parsers.repository_parser import RepositoryParser -from app.schemas.ai import AiQueryRequest - - -def _sample_repository(root: Path) -> None: - (root / "src" / "services").mkdir(parents=True) - (root / "package.json").write_text('{"dependencies":{"react":"^18.0.0"}}', encoding="utf-8") - (root / "src" / "main.tsx").write_text("import React from 'react';\nexport function App() { return null; }", encoding="utf-8") - (root / "src" / "services" / "user-service.ts").write_text("export class UserService {}", encoding="utf-8") - (root / "README.md").write_text("# Sample", encoding="utf-8") +from app.schemas.ai import AiCitation, AiMessage, AiQueryRequest def _record(root: Path) -> RepositoryRecord: - tree, meta, total_size = RepositoryParser().parse(root) - intelligence = RepositoryIntelligenceEngine().build("repo-1", "sample", root, tree, meta, total_size) - metadata = intelligence.metadata.model_dump(mode="json", by_alias=True) - metadata["intelligence"] = intelligence.model_dump(mode="json", by_alias=True) return RepositoryRecord( id="repo-1", + owner_id="owner-1", name="sample", source="upload", local_path=str(root), - size=total_size, - file_count=meta.total_files, + size=0, + file_count=3, status="completed", - data_source="real", analysis_stage="completed", analysis_progress=100, uploaded_at=datetime.now(UTC), analysed_at=datetime.now(UTC), - repo_metadata=metadata, - file_tree=[node.model_dump(mode="json", by_alias=True, exclude_none=True) for node in tree], + repo_metadata={"intelligence": {"ignored": True}}, + file_tree=[], + revision_kind="upload", + revision_value="sha256:" + "a" * 64, ) +def _projection(*, conflict: bool = False) -> ProductSnapshotProjection: + declarations = ( + ( + SnapshotDependencyDeclaration( + version="==2.31.0", + dependency_type="production", + manifest_path="requirements.txt", + workspace_path=".", + start_line=1, + end_line=1, + extractor="dependency-manifest", + extractor_version="1", + ), + SnapshotDependencyDeclaration( + version=">=2.32.0", + dependency_type="production", + manifest_path="requirements.txt", + workspace_path=".", + start_line=2, + end_line=2, + extractor="dependency-manifest", + extractor_version="1", + ), + ) + if conflict + else ( + SnapshotDependencyDeclaration( + version="^18.0.0", + dependency_type="production", + manifest_path="package.json", + workspace_path=".", + start_line=1, + end_line=1, + extractor="dependency-manifest", + extractor_version="1", + ), + ) + ) + dependency = SnapshotDependencyFact( + stable_key="dep:pypi:requests" if conflict else "dep:npm:react", + name="requests" if conflict else "react", + ecosystem="pypi" if conflict else "npm", + declarations=declarations, + ) + return ProductSnapshotProjection( + snapshot_id="snapshot-1", + schema_version="ri.v1", + repository_id="repo-1", + revision_kind="upload", + revision_value="sha256:" + "a" * 64, + revision_ref=None, + canonical_graph_hash="sha256:" + "b" * 64, + primary_language="TypeScript", + frameworks=() if conflict else ("React",), + entry_points=("src/main.tsx",), + modules=(SnapshotModuleFact(name="Services", role="service", paths=("src/services/user-service.ts",)),), + files=( + SnapshotFileFact("README.md", None, "documentation", "heuristic"), + SnapshotFileFact("src/main.tsx", "typescript", "entrypoint", "heuristic"), + SnapshotFileFact("src/services/user-service.ts", "typescript", "service", "heuristic"), + ), + dependencies=(dependency,), + routes=(), + diagnostics=(), + ) + + +class StaticSnapshots: + def __init__(self, projection: ProductSnapshotProjection) -> None: + self.projection = projection + + def product_projection(self, repository_id: str) -> ProductSnapshotProjection: + assert repository_id == "repo-1" + return self.projection + + class StaticRepository: def __init__(self, record: RepositoryRecord) -> None: self.record = record - def get(self, repository_id: str) -> RepositoryRecord | None: - return self.record if repository_id == self.record.id else None + def get_for_owner(self, repository_id: str, owner_id: str) -> RepositoryRecord | None: + if repository_id != self.record.id or owner_id != self.record.owner_id: + return None + return self.record class StaticConfigStore: @@ -58,6 +134,29 @@ def read_config(self) -> AiProviderConfig: return AiProviderConfig(provider="openai", api_key="key", model="test-model") +class FakeConversationRepository: + """In-memory stand-in for ``AiConversationRepository`` in orchestrator tests.""" + + def __init__(self) -> None: + self.rows: list[tuple[str, str, str, str, list[dict] | None, datetime]] = [] + + def append_turns(self, repository_id: str, owner_id: str, turns) -> None: + for role, content, citations, created_at in turns: + self.rows.append((repository_id, owner_id, role, content, citations, created_at)) + + def list_conversation(self, repository_id: str, owner_id: str) -> list[AiMessage]: + return [ + AiMessage( + role=role, + content=content, + timestamp=created_at, + citations=[AiCitation(**citation) for citation in citations] if citations else None, + ) + for repo_id, owner, role, content, citations, created_at in self.rows + if repo_id == repository_id and owner == owner_id + ] + + class FakeProvider: def __init__(self) -> None: self.prompt: PromptBundle | None = None @@ -67,24 +166,24 @@ async def complete(self, config: AiProviderConfig, prompt: PromptBundle) -> AiPr return AiProviderResponse(content=f"answer from {config.provider}") -def test_repository_context_builder_uses_repository_intelligence(tmp_path: Path): - _sample_repository(tmp_path) +def test_repository_context_builder_uses_sealed_snapshot(tmp_path: Path): record = _record(tmp_path) - context = RepositoryContextBuilder(RepositoryIntelligenceEngine()).build(record) + context = RepositoryContextBuilder(StaticSnapshots(_projection())).build(record) # type: ignore[arg-type] assert context.repository.name == "sample" assert context.architecture.primary_language == "TypeScript" assert any(module.name == "Services" for module in context.architecture.modules) assert any(dependency.name == "react" for dependency in context.dependencies) assert context.selected_files - assert context.citations[0].content == "Repository file path included in analysis context." + # Citations are intentionally empty: the context has no source lines to ground + # a real file:line citation, and placeholder citations were removed (F4/F5). + assert context.citations == () def test_prompt_builder_preserves_existing_system_prompt_shape(tmp_path: Path): - _sample_repository(tmp_path) record = _record(tmp_path) - context = RepositoryContextBuilder(RepositoryIntelligenceEngine()).build(record) + context = RepositoryContextBuilder(StaticSnapshots(_projection())).build(record) # type: ignore[arg-type] prompt = PromptBuilder().build(context, "What should I read first?") @@ -96,6 +195,20 @@ def test_prompt_builder_preserves_existing_system_prompt_shape(tmp_path: Path): assert "Files:" in prompt.system_prompt +def test_prompt_builder_renders_conflicting_declared_versions_without_none(tmp_path: Path): + record = _record(tmp_path) + + context = RepositoryContextBuilder(StaticSnapshots(_projection(conflict=True))).build(record) # type: ignore[arg-type] + dependency = next(item for item in context.dependencies if item.name == "requests") + prompt = PromptBuilder().build(context, "What dependencies conflict?") + + assert dependency.version is None + assert dependency.declared_versions == ("==2.31.0", ">=2.32.0") + assert dependency.has_version_conflict is True + assert "- requests (conflicting declared versions: ==2.31.0, >=2.32.0)" in prompt.system_prompt + assert "requests None" not in prompt.system_prompt + + def test_provider_factory_resolves_registered_provider(): provider = FakeProvider() registry = ProviderRegistry() @@ -107,22 +220,44 @@ def test_provider_factory_resolves_registered_provider(): def test_ai_orchestrator_preserves_query_response_shape(tmp_path: Path): - response, provider = asyncio.run(_query_with_fake_provider(tmp_path)) + response, provider, _orchestrator = asyncio.run(_query_with_fake_provider(tmp_path)) assert response.message.role == "assistant" assert response.message.content == "answer from openai" - assert response.message.citations - assert response.suggestions == [ - "Explain the main architecture boundaries.", - "What files should I read first?", - "What are the highest-risk engineering issues?", - ] + # No fabricated citations are returned (F4/F5); real ones await the graph (M2). + assert response.message.citations is None + assert response.suggestions == [] assert provider.prompt is not None assert provider.prompt.user_prompt == "Explain this repo" +def test_ai_orchestrator_persists_both_turns_and_lists_them_back(tmp_path: Path): + response, _provider, orchestrator = asyncio.run(_query_with_fake_provider(tmp_path)) + + thread = orchestrator.list_conversation("repo-1") + + assert [message.role for message in thread] == ["user", "assistant"] + assert thread[0].content == "Explain this repo" + assert thread[1].content == response.message.content + + +def test_ai_orchestrator_list_conversation_404s_for_a_repository_the_caller_does_not_own(tmp_path: Path): + record = _record(tmp_path) + orchestrator = AiOrchestrator( + repository=StaticRepository(record), # type: ignore[arg-type] + config_store=StaticConfigStore(), # type: ignore[arg-type] + context_builder=RepositoryContextBuilder(StaticSnapshots(_projection())), # type: ignore[arg-type] + prompt_builder=PromptBuilder(), + provider_factory=ProviderFactory(ProviderRegistry()), + conversation_repository=FakeConversationRepository(), + owner_id="someone-else", + ) + + with pytest.raises(NotFoundError): + orchestrator.list_conversation("repo-1") + + async def _query_with_fake_provider(tmp_path: Path): - _sample_repository(tmp_path) record = _record(tmp_path) provider = FakeProvider() registry = ProviderRegistry() @@ -130,10 +265,12 @@ async def _query_with_fake_provider(tmp_path: Path): orchestrator = AiOrchestrator( repository=StaticRepository(record), # type: ignore[arg-type] config_store=StaticConfigStore(), # type: ignore[arg-type] - context_builder=RepositoryContextBuilder(RepositoryIntelligenceEngine()), + context_builder=RepositoryContextBuilder(StaticSnapshots(_projection())), # type: ignore[arg-type] prompt_builder=PromptBuilder(), provider_factory=ProviderFactory(registry), + conversation_repository=FakeConversationRepository(), + owner_id="owner-1", ) response = await orchestrator.query(AiQueryRequest(repository_id="repo-1", query="Explain this repo")) - return response, provider + return response, provider, orchestrator diff --git a/apps/backend/tests/test_ai_conversation_persistence.py b/apps/backend/tests/test_ai_conversation_persistence.py new file mode 100644 index 00000000..2f09b4a1 --- /dev/null +++ b/apps/backend/tests/test_ai_conversation_persistence.py @@ -0,0 +1,250 @@ +"""AI Workspace conversation persistence (#231). + +The AI Workspace previously lost its whole thread on every navigation away +from the page: nothing was ever persisted server-side. These tests drive the +real `/ai/query` and `/ai/conversations` routes end to end, proving both +turns of a query are stored, ordering is preserved across multiple queries, +and the thread is owner- and repository-scoped exactly like every other +`/ai` route. +""" + +import io +import zipfile + +from app.ai.providers.registry import ProviderRegistry +from app.ai.types import AiProviderConfig, AiProviderResponse, PromptBundle +from app.api.deps import get_provider_registry +from tests.analysis_helpers import run_analysis_jobs + + +def _zip_bytes(files: dict[str, bytes]) -> bytes: + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as archive: + for path, content in files.items(): + archive.writestr(path, content) + return buffer.getvalue() + + +class RecordingProvider: + def __init__(self) -> None: + self.calls = 0 + + async def complete(self, config: AiProviderConfig, prompt: PromptBundle) -> AiProviderResponse: + self.calls += 1 + return AiProviderResponse(content=f"answer #{self.calls}") + + +def _analysed_repository(client, headers: dict, content: bytes = b"export const answer = 42;\n") -> str: + upload = client.post( + "/repositories/upload", + files={ + "file": ( + "sample.zip", + _zip_bytes({"sample/src/app.ts": content}), + "application/octet-stream", + ) + }, + headers=headers, + ) + assert upload.status_code == 201, upload.text + repository_id = upload.json()["id"] + assert client.post(f"/analysis/{repository_id}/start", headers=headers).status_code == 200 + assert run_analysis_jobs() == 1 + return repository_id + + +def _configure_provider(client, headers: dict) -> None: + response = client.put( + "/ai/config", + json={"provider": "openai", "apiKey": "test-key", "model": "test-model"}, + headers=headers, + ) + assert response.status_code == 200, response.text + + +def test_conversation_starts_empty_for_a_fresh_repository(auth_client): + repository_id = _analysed_repository(auth_client, auth_client.headers) + + response = auth_client.get("/ai/conversations", params={"repositoryId": repository_id}) + + assert response.status_code == 200 + assert response.json() == {"repositoryId": repository_id, "messages": []} + + +def test_query_persists_both_turns_in_order_across_multiple_queries(auth_client): + provider = RecordingProvider() + registry = ProviderRegistry() + registry.register("openai", provider) + auth_client.app.dependency_overrides[get_provider_registry] = lambda: registry + + try: + repository_id = _analysed_repository(auth_client, auth_client.headers) + _configure_provider(auth_client, auth_client.headers) + + first = auth_client.post("/ai/query", json={"repositoryId": repository_id, "query": "What is this?"}) + assert first.status_code == 200, first.text + second = auth_client.post("/ai/query", json={"repositoryId": repository_id, "query": "And then?"}) + assert second.status_code == 200, second.text + + conversation = auth_client.get("/ai/conversations", params={"repositoryId": repository_id}) + assert conversation.status_code == 200 + body = conversation.json() + assert body["repositoryId"] == repository_id + messages = body["messages"] + + assert [message["role"] for message in messages] == ["user", "assistant", "user", "assistant"] + assert [message["content"] for message in messages] == [ + "What is this?", + "answer #1", + "And then?", + "answer #2", + ] + finally: + auth_client.app.dependency_overrides.pop(get_provider_registry, None) + + +def test_conversation_is_isolated_per_repository(auth_client): + provider = RecordingProvider() + registry = ProviderRegistry() + registry.register("openai", provider) + auth_client.app.dependency_overrides[get_provider_registry] = lambda: registry + + try: + first_repository = _analysed_repository(auth_client, auth_client.headers, b"export const first = 1;\n") + second_repository = _analysed_repository(auth_client, auth_client.headers, b"export const second = 2;\n") + _configure_provider(auth_client, auth_client.headers) + + assert ( + auth_client.post( + "/ai/query", json={"repositoryId": first_repository, "query": "About repo one"} + ).status_code + == 200 + ) + assert ( + auth_client.post( + "/ai/query", json={"repositoryId": second_repository, "query": "About repo two"} + ).status_code + == 200 + ) + + first_thread = auth_client.get("/ai/conversations", params={"repositoryId": first_repository}).json() + second_thread = auth_client.get("/ai/conversations", params={"repositoryId": second_repository}).json() + + assert [m["content"] for m in first_thread["messages"]] == ["About repo one", "answer #1"] + assert [m["content"] for m in second_thread["messages"]] == ["About repo two", "answer #2"] + finally: + auth_client.app.dependency_overrides.pop(get_provider_registry, None) + + +def test_conversation_is_owner_scoped_and_returns_404_for_another_owner(client, make_auth_headers): + alice = make_auth_headers("alice-ai@example.com") + bob = make_auth_headers("bob-ai@example.com") + + provider = RecordingProvider() + registry = ProviderRegistry() + registry.register("openai", provider) + client.app.dependency_overrides[get_provider_registry] = lambda: registry + + try: + repository_id = _analysed_repository(client, alice["headers"]) + _configure_provider(client, alice["headers"]) + assert ( + client.post( + "/ai/query", + json={"repositoryId": repository_id, "query": "Hello"}, + headers=alice["headers"], + ).status_code + == 200 + ) + finally: + client.app.dependency_overrides.pop(get_provider_registry, None) + + denied = client.get("/ai/conversations", params={"repositoryId": repository_id}, headers=bob["headers"]) + assert denied.status_code == 404 + + allowed = client.get("/ai/conversations", params={"repositoryId": repository_id}, headers=alice["headers"]) + assert allowed.status_code == 200 + assert len(allowed.json()["messages"]) == 2 + + +def test_conversation_returns_404_for_an_unknown_repository(auth_client): + response = auth_client.get( + "/ai/conversations", + params={"repositoryId": "11111111-1111-1111-1111-111111111111"}, + ) + assert response.status_code == 404 + + +def test_deleting_repository_removes_its_conversation_turns(auth_client): + provider = RecordingProvider() + registry = ProviderRegistry() + registry.register("openai", provider) + auth_client.app.dependency_overrides[get_provider_registry] = lambda: registry + + try: + repository_id = _analysed_repository(auth_client, auth_client.headers) + _configure_provider(auth_client, auth_client.headers) + assert ( + auth_client.post("/ai/query", json={"repositoryId": repository_id, "query": "Keep me"}).status_code == 200 + ) + + # The conversation turns exist before deletion. + thread = auth_client.get("/ai/conversations", params={"repositoryId": repository_id}) + assert thread.status_code == 200 + assert len(thread.json()["messages"]) == 2 + + # Deleting the repository must cascade to its conversation turns + # instead of raising a foreign-key violation. + delete = auth_client.delete(f"/repositories/{repository_id}") + assert delete.status_code == 204, delete.text + + # The thread is gone with the repository. + assert auth_client.get("/ai/conversations", params={"repositoryId": repository_id}).status_code == 404 + finally: + auth_client.app.dependency_overrides.pop(get_provider_registry, None) + + +def test_concurrent_queries_do_not_interleave_turns(auth_client): + import threading + + provider = RecordingProvider() + registry = ProviderRegistry() + registry.register("openai", provider) + auth_client.app.dependency_overrides[get_provider_registry] = lambda: registry + + errors: list[Exception] = [] + try: + repository_id = _analysed_repository(auth_client, auth_client.headers) + _configure_provider(auth_client, auth_client.headers) + + def fire() -> None: + try: + auth_client.post( + "/ai/query", + json={"repositoryId": repository_id, "query": "Concurrent question"}, + ) + except Exception as exc: # noqa: BLE001 - surface in the main thread + errors.append(exc) + + # Fire several queries concurrently. The unique constraint on + # (owner_id, repository_id, sequence) plus the bounded retry in + # append_turns must keep every user/assistant pair intact and allocate + # distinct sequences rather than interleaving or 500-ing. + threads = [threading.Thread(target=fire) for _ in range(5)] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + + assert not errors, [str(e) for e in errors] + + conversation = auth_client.get("/ai/conversations", params={"repositoryId": repository_id}) + assert conversation.status_code == 200 + messages = conversation.json()["messages"] + # Five queries => ten turns, alternating user/assistant, all distinct + # sequences (asserted by the DB constraint, surfaced here as ordering). + assert len(messages) == 10 + roles = [message["role"] for message in messages] + assert roles == ["user", "assistant"] * 5 + finally: + auth_client.app.dependency_overrides.pop(get_provider_registry, None) diff --git a/apps/backend/tests/test_ai_egress_policy.py b/apps/backend/tests/test_ai_egress_policy.py new file mode 100644 index 00000000..e0d0ff73 --- /dev/null +++ b/apps/backend/tests/test_ai_egress_policy.py @@ -0,0 +1,844 @@ +"""Deterministic regression coverage for the AI provider egress boundary.""" + +import asyncio +import io +import logging +import ssl +import threading +import zipfile +from datetime import UTC, datetime, timedelta +from pathlib import Path +from uuid import uuid4 + +import httpx +import pytest +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID +from pydantic import ValidationError +from sqlalchemy import select + +from app.core.ai_egress import DestinationPolicyError, ProviderEgressPolicy, normalize_base_url +from app.ai.providers.config_store import EncryptedProviderConfigStore +from app.ai.providers.http import SecureProviderHttpSender, post +from app.ai.providers.ollama import OllamaProvider +from app.ai.types import AiProviderConfig, PromptBundle +from app.core.config import Settings +from app.core.crypto import build_provider_cipher +from app.core.exceptions import ExternalServiceError, ValidationServiceError +from app.core.logging import configure_logging +from app.models.ai_provider_config import AiProviderConfigRecord +from tests.analysis_helpers import run_analysis_jobs +from tests.conftest import register_user + + +PROMPT = PromptBundle(system_prompt="System", user_prompt="Question") +_SELF_HOSTED_BASE = "http://provider.example:11434/approved" +_SELF_HOSTED_CIDR = "203.0.113.0/24" + + +class MutableResolver: + def __init__(self, answers: list[str]) -> None: + self.answers = answers + self.calls: list[tuple[str, int]] = [] + + def __call__(self, host: str, port: int) -> list[str]: + self.calls.append((host, port)) + return self.answers + + +def _self_hosted_policy(resolver: MutableResolver) -> ProviderEgressPolicy: + return ProviderEgressPolicy( + mode="self_hosted", + allowed_base_urls=[_SELF_HOSTED_BASE], + allowed_cidrs=[_SELF_HOSTED_CIDR], + resolver=resolver, + ) + + +def _ollama_config() -> AiProviderConfig: + return AiProviderConfig(provider="ollama", base_url=_SELF_HOSTED_BASE) + + +def _write_test_tls_chain(tmp_path: Path, hostname: str) -> tuple[Path, Path, Path]: + """Create a short-lived CA and server certificate for a real TLS handshake.""" + + now = datetime.now(UTC) + ca_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + ca_name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "PARTHA test CA")]) + ca_certificate = ( + x509.CertificateBuilder() + .subject_name(ca_name) + .issuer_name(ca_name) + .public_key(ca_key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now - timedelta(minutes=1)) + .not_valid_after(now + timedelta(days=1)) + .add_extension(x509.BasicConstraints(ca=True, path_length=0), critical=True) + .add_extension( + x509.KeyUsage( + digital_signature=False, + content_commitment=False, + key_encipherment=False, + data_encipherment=False, + key_agreement=False, + key_cert_sign=True, + crl_sign=True, + encipher_only=False, + decipher_only=False, + ), + critical=True, + ) + .add_extension(x509.SubjectKeyIdentifier.from_public_key(ca_key.public_key()), critical=False) + .add_extension(x509.AuthorityKeyIdentifier.from_issuer_public_key(ca_key.public_key()), critical=False) + .sign(ca_key, hashes.SHA256()) + ) + + server_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + server_name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, hostname)]) + server_certificate = ( + x509.CertificateBuilder() + .subject_name(server_name) + .issuer_name(ca_name) + .public_key(server_key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now - timedelta(minutes=1)) + .not_valid_after(now + timedelta(days=1)) + .add_extension(x509.SubjectAlternativeName([x509.DNSName(hostname)]), critical=False) + .add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True) + .add_extension(x509.SubjectKeyIdentifier.from_public_key(server_key.public_key()), critical=False) + .add_extension(x509.AuthorityKeyIdentifier.from_issuer_public_key(ca_key.public_key()), critical=False) + .add_extension(x509.ExtendedKeyUsage([ExtendedKeyUsageOID.SERVER_AUTH]), critical=False) + .sign(ca_key, hashes.SHA256()) + ) + + ca_path = tmp_path / "ca.pem" + certificate_path = tmp_path / "server.pem" + key_path = tmp_path / "server-key.pem" + ca_path.write_bytes(ca_certificate.public_bytes(serialization.Encoding.PEM)) + certificate_path.write_bytes(server_certificate.public_bytes(serialization.Encoding.PEM)) + key_path.write_bytes( + server_key.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, + serialization.NoEncryption(), + ) + ) + return ca_path, certificate_path, key_path + + +def _zip_bytes(files: dict[str, str]) -> bytes: + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as archive: + for path, content in files.items(): + archive.writestr(path, content) + return buffer.getvalue() + + +def _upload_sample(client, headers: dict[str, str]) -> str: + response = client.post( + "/repositories/upload", + files={ + "file": ( + "sample.zip", + _zip_bytes({"sample/package.json": '{"dependencies":{}}'}), + "application/octet-stream", + ) + }, + headers=headers, + ) + assert response.status_code == 201, response.text + return response.json()["id"] + + +def _insert_unsafe_ollama_config(owner_id: str) -> None: + from app.core.database import SessionLocal + + db = SessionLocal() + try: + now = datetime.now(UTC) + db.add( + AiProviderConfigRecord( + id=str(uuid4()), + owner_id=owner_id, + provider="ollama", + encrypted_api_key=None, + api_key_last4=None, + model="llama3.2", + base_url="http://unapproved.example:11434", + created_at=now, + updated_at=now, + ) + ) + db.commit() + finally: + db.close() + + +def test_invalid_mode_and_malformed_administrator_allowlists_fail_closed(): + with pytest.raises(ValidationError, match="AI_EGRESS_MODE"): + Settings(ai_egress_mode="permissive") + with pytest.raises(ValidationError, match="AI_EGRESS_ALLOWED_BASE_URLS"): + Settings(ai_egress_allowed_base_urls=["http://user@provider.example"]) + with pytest.raises(ValidationError, match="AI_EGRESS_ALLOWED_CIDRS"): + Settings(ai_egress_allowed_cidrs=["not-a-cidr"]) + + +def test_base_url_normalization_is_consistent_for_idna_default_port_trailing_dot_and_path(): + normalized = normalize_base_url("HTTPS://exämple.com.:443/approved/") + + assert normalized.base_url == "https://xn--exmple-cua.com/approved" + + +def test_fixed_providers_reject_tenant_supplied_base_urls_without_dns(): + resolver = MutableResolver(["203.0.113.10"]) + policy = ProviderEgressPolicy(mode="hosted", resolver=resolver) + + with pytest.raises(DestinationPolicyError): + policy.validate_config(AiProviderConfig(provider="openai", api_key="key", base_url="https://provider.example")) + + assert resolver.calls == [] + + +def test_hosted_mode_rejects_unapproved_custom_destination_before_resolution(): + resolver = MutableResolver(["203.0.113.10"]) + policy = ProviderEgressPolicy(mode="hosted", resolver=resolver) + + with pytest.raises(DestinationPolicyError): + policy.validate_config(AiProviderConfig(provider="ollama", base_url="https://provider.example")) + + assert resolver.calls == [] + + +@pytest.mark.parametrize( + "answer", + [ + "127.0.0.1", + "10.0.0.1", + "169.254.10.20", + "0.0.0.0", + "224.0.0.1", + "240.0.0.1", + "100.64.0.1", + "::1", + "::", + "fe80::1", + "ff02::1", + "2001:db8::10", + "::ffff:127.0.0.1", + "::ffff:224.0.0.1", + "2606:4700:4700::1111%3", + ], +) +def test_hosted_mode_rejects_non_public_unicast_ipv4_and_ipv6_answers(answer: str): + resolver = MutableResolver([answer]) + policy = ProviderEgressPolicy( + mode="hosted", + allowed_base_urls=["https://provider.example"], + resolver=resolver, + ) + + with pytest.raises(DestinationPolicyError): + policy.validate_config(AiProviderConfig(provider="ollama", base_url="https://provider.example")) + + +@pytest.mark.parametrize("answer", ["8.8.8.8", "2606:4700:4700::1111", "::ffff:8.8.8.8"]) +def test_hosted_mode_accepts_only_public_unicast_answers_for_an_approved_url(answer: str): + policy = ProviderEgressPolicy( + mode="hosted", + allowed_base_urls=["https://provider.example"], + resolver=MutableResolver([answer]), + ) + + policy.validate_config(AiProviderConfig(provider="ollama", base_url="https://provider.example")) + + +def test_self_hosted_exact_approved_base_and_cidr_succeeds(): + resolver = MutableResolver(["203.0.113.10"]) + policy = _self_hosted_policy(resolver) + + policy.validate_config(AiProviderConfig(provider="ollama", base_url=f"{_SELF_HOSTED_BASE}/")) + prepared = policy.prepare_request(_ollama_config(), f"{_SELF_HOSTED_BASE}/api/chat") + + assert prepared.destination.request_url == f"{_SELF_HOSTED_BASE}/api/chat" + assert str(prepared.address) == "203.0.113.10" + assert resolver.calls == [("provider.example", 11434), ("provider.example", 11434)] + + +@pytest.mark.parametrize("answer", ["127.0.0.1", "::1", "198.51.100.10"]) +def test_self_hosted_private_or_local_addresses_require_an_approved_cidr(answer: str): + resolver = MutableResolver([answer]) + policy = ProviderEgressPolicy( + mode="self_hosted", + allowed_base_urls=[_SELF_HOSTED_BASE], + allowed_cidrs=["192.0.2.0/24"], + resolver=resolver, + ) + + with pytest.raises(DestinationPolicyError): + policy.validate_config(_ollama_config()) + + +@pytest.mark.parametrize( + ("base_url", "answer", "cidr"), + [ + ("http://localhost:11434", "127.0.0.1", "127.0.0.1/32"), + ("http://provider.internal:11434", "10.20.30.40", "10.20.30.0/24"), + ("http://[::1]:11434", "::1", "::1/128"), + ("http://mapped.internal:11434", "::ffff:127.0.0.1", "::ffff:7f00:0/104"), + ], +) +def test_self_hosted_explicit_cidr_supports_private_loopback_and_mapped_unicast( + base_url: str, + answer: str, + cidr: str, +): + policy = ProviderEgressPolicy( + mode="self_hosted", + allowed_base_urls=[base_url], + allowed_cidrs=[cidr], + resolver=MutableResolver([answer]), + ) + + policy.validate_config(AiProviderConfig(provider="ollama", base_url=base_url)) + + +@pytest.mark.parametrize( + ("answer", "cidr"), + [ + ("169.254.10.20", "0.0.0.0/0"), + ("0.0.0.0", "0.0.0.0/0"), + ("224.0.0.1", "0.0.0.0/0"), + ("240.0.0.1", "0.0.0.0/0"), + ("100.64.0.1", "0.0.0.0/0"), + ("fe80::1", "::/0"), + ("::", "::/0"), + ("ff02::1", "::/0"), + ], +) +def test_self_hosted_mode_rejects_non_unicast_or_ambiguous_classes_even_with_a_broad_cidr( + answer: str, + cidr: str, +): + policy = ProviderEgressPolicy( + mode="self_hosted", + allowed_base_urls=[_SELF_HOSTED_BASE], + allowed_cidrs=[cidr], + resolver=MutableResolver([answer]), + ) + + with pytest.raises(DestinationPolicyError): + policy.validate_config(_ollama_config()) + + +@pytest.mark.parametrize( + "request_url", + [ + "https://provider.example:11434/approved/api/chat", + "http://provider.example:11435/approved/api/chat", + "http://other.example:11434/approved/api/chat", + "http://provider.example:11434/not-approved/api/chat", + ], +) +def test_origin_scheme_port_hostname_and_path_mismatches_are_rejected(request_url: str): + policy = _self_hosted_policy(MutableResolver(["203.0.113.10"])) + + with pytest.raises(DestinationPolicyError): + policy.prepare_request(_ollama_config(), request_url) + + +@pytest.mark.parametrize( + "unsafe_url", + [ + "ftp://provider.example", + "https:///missing-host", + "http://user@provider.example", + "http://provider.example/#fragment", + "http://provider.example#", + "http://provider.example?", + "http://2130706433", + "http://127.1", + "http://0177.0.0.1", + "http://0x7f000001", + "http://provider.example:", + "http://provider.example:0", + "http://provider.example:65536", + "http://provider.example..", + "http://provider.example/%2fprivate", + "http://provider.example/approved/../private", + "http://provider.example/approved//private", + "http://provider.example\\@other.example", + "http://provider.example\r\nX-Test: injected", + ], +) +def test_malformed_and_ambiguous_base_urls_are_rejected(unsafe_url: str): + policy = ProviderEgressPolicy(mode="hosted", allowed_base_urls=["https://provider.example"]) + + with pytest.raises(DestinationPolicyError): + policy.validate_config(AiProviderConfig(provider="ollama", base_url=unsafe_url)) + + +def test_mixed_dns_answers_fail_closed(): + policy = _self_hosted_policy(MutableResolver(["203.0.113.10", "127.0.0.1"])) + + with pytest.raises(DestinationPolicyError): + policy.validate_config(_ollama_config()) + + +def test_hosted_mixed_public_and_internal_dns_answers_fail_closed(): + policy = ProviderEgressPolicy( + mode="hosted", + allowed_base_urls=["https://provider.example"], + resolver=MutableResolver(["8.8.8.8", "127.0.0.1"]), + ) + + with pytest.raises(DestinationPolicyError): + policy.validate_config(AiProviderConfig(provider="ollama", base_url="https://provider.example")) + + +def test_fixed_cloud_provider_dns_rebinding_is_blocked_even_in_self_hosted_mode(): + policy = ProviderEgressPolicy( + mode="self_hosted", + allowed_base_urls=["http://localhost:11434"], + allowed_cidrs=["127.0.0.1/32"], + resolver=MutableResolver(["127.0.0.1"]), + ) + + with pytest.raises(DestinationPolicyError): + policy.prepare_request( + AiProviderConfig(provider="openai", api_key="key"), + "https://api.openai.com/v1/chat/completions", + ) + + +def test_configuration_save_is_validated_before_database_mutation(client): + auth = register_user(client, "egress-save@example.com") + response = client.put( + "/ai/config", + json={"provider": "ollama", "baseUrl": "http://unapproved.example:11434"}, + headers=auth["headers"], + ) + + assert response.status_code == 422 + body = response.json() + assert body["code"] == "validation_error" + assert "unapproved.example" not in response.text + + from app.core.database import SessionLocal + + db = SessionLocal() + try: + assert ( + db.scalars( + select(AiProviderConfigRecord).where(AiProviderConfigRecord.owner_id == auth["user"]["id"]) + ).first() + is None + ) + finally: + db.close() + + +def test_fixed_provider_base_url_is_rejected_without_mutating_a_saved_configuration(client): + auth = register_user(client, "egress-fixed-save@example.com") + original = client.put( + "/ai/config", + json={"provider": "openai", "apiKey": "sk-unchanged-1234", "model": "gpt-4o-mini"}, + headers=auth["headers"], + ) + assert original.status_code == 200 + + rejected = client.put( + "/ai/config", + json={"provider": "openai", "baseUrl": "https://provider.example"}, + headers=auth["headers"], + ) + assert rejected.status_code == 422 + assert "provider.example" not in rejected.text + + saved = client.get("/ai/config", headers=auth["headers"]) + assert saved.status_code == 200 + assert saved.json()["provider"] == "openai" + assert saved.json()["model"] == "gpt-4o-mini" + assert saved.json()["apiKeyLast4"] == "1234" + + +def test_request_time_dns_recheck_blocks_changed_answer_without_invoking_transport(client): + auth = register_user(client, "egress-recheck@example.com") + resolver = MutableResolver(["203.0.113.10"]) + policy = _self_hosted_policy(resolver) + + from app.core.database import SessionLocal + + db = SessionLocal() + try: + store = EncryptedProviderConfigStore( + db, + build_provider_cipher(Settings(app_env="test")), + auth["user"]["id"], + policy, + ) + store.save_config(_ollama_config()) + saved = store.read_config() + assert saved is not None + + calls = 0 + + async def handler(_: httpx.Request) -> httpx.Response: + nonlocal calls + calls += 1 + return httpx.Response(200, json={"message": {"content": "ok"}}) + + resolver.answers = ["127.0.0.1"] + sender = SecureProviderHttpSender(policy, transport=httpx.MockTransport(handler)) + with pytest.raises(ValidationServiceError, match="destination is not permitted"): + asyncio.run(OllamaProvider(sender).complete(saved, PROMPT)) + + assert calls == 0 + finally: + db.close() + + +def test_allowed_dns_answer_is_pinned_to_an_ip_with_original_host_and_sni(monkeypatch): + resolver = MutableResolver(["8.8.8.8"]) + policy = ProviderEgressPolicy(mode="hosted", resolver=resolver) + observed: dict[str, object] = {} + client_options: dict[str, object] = {} + real_async_client = httpx.AsyncClient + + def recording_client(*args: object, **kwargs: object) -> httpx.AsyncClient: + client_options.update(kwargs) + return real_async_client(*args, **kwargs) + + monkeypatch.setattr(httpx, "AsyncClient", recording_client) + + async def handler(request: httpx.Request) -> httpx.Response: + observed["host"] = request.url.host + observed["port"] = request.url.port + observed["host_header"] = request.headers["host"] + observed["sni"] = request.extensions["sni_hostname"] + return httpx.Response(200, json={"message": {"content": "ok"}}, request=request) + + sender = SecureProviderHttpSender(policy, transport=httpx.MockTransport(handler)) + config = AiProviderConfig(provider="openai", api_key="key") + response = asyncio.run( + sender.post( + config, + "https://api.openai.com/v1/chat/completions", + headers={"Host": "attacker.invalid"}, + json={"model": "example"}, + ) + ) + + assert response.status_code == 200 + assert observed == { + "host": "8.8.8.8", + "port": None, + "host_header": "api.openai.com", + "sni": "api.openai.com", + } + assert client_options["verify"] is True + assert client_options["trust_env"] is False + assert client_options["follow_redirects"] is False + + +def test_request_time_resolution_runs_off_the_event_loop_thread(): + resolution_thread_ids: list[int] = [] + + def recording_resolver(_: str, __: int) -> list[str]: + resolution_thread_ids.append(threading.get_ident()) + return ["8.8.8.8"] + + policy = ProviderEgressPolicy(mode="hosted", resolver=recording_resolver) + + async def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={"ok": True}, request=request) + + async def exercise() -> int: + event_loop_thread_id = threading.get_ident() + sender = SecureProviderHttpSender(policy, transport=httpx.MockTransport(handler)) + await sender.post( + AiProviderConfig(provider="openai", api_key="key"), + "https://api.openai.com/v1/chat/completions", + ) + return event_loop_thread_id + + event_loop_thread_id = asyncio.run(exercise()) + + assert resolution_thread_ids + assert all(thread_id != event_loop_thread_id for thread_id in resolution_thread_ids) + + +def test_real_tls_handshake_uses_original_hostname_for_sni_and_verification(tmp_path: Path): + hostname = "provider.test" + ca_path, certificate_path, key_path = _write_test_tls_chain(tmp_path, hostname) + + async def exercise() -> tuple[int, list[str | None]]: + observed_sni: list[str | None] = [] + server_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + server_context.load_cert_chain(certificate_path, key_path) + server_context.set_servername_callback(lambda _socket, server_name, _context: observed_sni.append(server_name)) + + async def handler(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: + try: + await reader.readuntil(b"\r\n\r\n") + writer.write(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nOK") + await writer.drain() + finally: + writer.close() + await writer.wait_closed() + + server = await asyncio.start_server(handler, "127.0.0.1", 0, ssl=server_context) + port = server.sockets[0].getsockname()[1] + base_url = f"https://{hostname}:{port}" + policy = ProviderEgressPolicy( + mode="self_hosted", + allowed_base_urls=[base_url], + allowed_cidrs=["127.0.0.1/32"], + resolver=MutableResolver(["127.0.0.1"]), + ) + client_context = ssl.create_default_context(cafile=str(ca_path)) + transport = httpx.AsyncHTTPTransport(verify=client_context, retries=0) + sender = SecureProviderHttpSender(policy, transport=transport) + + try: + response = await sender.post( + AiProviderConfig(provider="ollama", base_url=base_url), + f"{base_url}/api/chat", + ) + finally: + server.close() + await server.wait_closed() + + return response.status_code, observed_sni + + status_code, observed_sni = asyncio.run(exercise()) + + assert status_code == 200 + assert observed_sni == [hostname] + + +def test_real_tls_handshake_rejects_certificate_for_wrong_hostname(tmp_path: Path): + hostname = "provider.test" + ca_path, certificate_path, key_path = _write_test_tls_chain(tmp_path, "other-provider.test") + + async def exercise() -> list[str | None]: + observed_sni: list[str | None] = [] + server_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + server_context.load_cert_chain(certificate_path, key_path) + server_context.set_servername_callback(lambda _socket, server_name, _context: observed_sni.append(server_name)) + + async def handler(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: + try: + await reader.readuntil(b"\r\n\r\n") + writer.write(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nOK") + await writer.drain() + finally: + writer.close() + await writer.wait_closed() + + server = await asyncio.start_server(handler, "127.0.0.1", 0, ssl=server_context) + port = server.sockets[0].getsockname()[1] + base_url = f"https://{hostname}:{port}" + policy = ProviderEgressPolicy( + mode="self_hosted", + allowed_base_urls=[base_url], + allowed_cidrs=["127.0.0.1/32"], + resolver=MutableResolver(["127.0.0.1"]), + ) + client_context = ssl.create_default_context(cafile=str(ca_path)) + transport = httpx.AsyncHTTPTransport(verify=client_context, retries=0) + sender = SecureProviderHttpSender(policy, transport=transport) + + try: + with pytest.raises(httpx.ConnectError): + await sender.post( + AiProviderConfig(provider="ollama", base_url=base_url), + f"{base_url}/api/chat", + ) + finally: + server.close() + await server.wait_closed() + + return observed_sni + + observed_sni = asyncio.run(exercise()) + + assert observed_sni == [hostname] + + +def test_ipv6_host_header_uses_brackets_and_non_default_port(): + base_url = "http://[::1]:11434" + policy = ProviderEgressPolicy( + mode="self_hosted", + allowed_base_urls=[base_url], + allowed_cidrs=["::1/128"], + ) + observed: dict[str, object] = {} + + async def handler(request: httpx.Request) -> httpx.Response: + observed["url"] = str(request.url) + observed["host"] = request.headers["host"] + observed["sni"] = request.extensions["sni_hostname"] + return httpx.Response(200, json={"message": {"content": "ok"}}, request=request) + + sender = SecureProviderHttpSender(policy, transport=httpx.MockTransport(handler)) + config = AiProviderConfig(provider="ollama", base_url=base_url) + asyncio.run(sender.post(config, f"{base_url}/api/chat", json={"model": "example"})) + + assert observed == { + "url": "http://[::1]:11434/api/chat", + "host": "[::1]:11434", + "sni": "::1", + } + + +def test_production_transport_disables_connection_retries(monkeypatch): + resolver = MutableResolver(["8.8.8.8"]) + policy = ProviderEgressPolicy(mode="hosted", resolver=resolver) + options: dict[str, object] = {} + + async def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={"ok": True}, request=request) + + mock_transport = httpx.MockTransport(handler) + + def transport_factory(**kwargs: object) -> httpx.AsyncBaseTransport: + options.update(kwargs) + return mock_transport + + monkeypatch.setattr(httpx, "AsyncHTTPTransport", transport_factory) + sender = SecureProviderHttpSender(policy) + asyncio.run( + sender.post( + AiProviderConfig(provider="openai", api_key="key"), + "https://api.openai.com/v1/chat/completions", + ) + ) + + assert options == {"verify": True, "retries": 0} + assert resolver.calls == [("api.openai.com", 443)] + + +def test_each_request_reresolves_and_uses_a_new_pinned_address(): + resolver = MutableResolver(["203.0.113.10"]) + policy = _self_hosted_policy(resolver) + destinations: list[str] = [] + + async def handler(request: httpx.Request) -> httpx.Response: + destinations.append(request.url.host) + return httpx.Response(200, json={"message": {"content": "ok"}}, request=request) + + sender = SecureProviderHttpSender(policy, transport=httpx.MockTransport(handler)) + asyncio.run(sender.post(_ollama_config(), f"{_SELF_HOSTED_BASE}/api/chat")) + resolver.answers = ["203.0.113.11"] + asyncio.run(sender.post(_ollama_config(), f"{_SELF_HOSTED_BASE}/api/chat")) + + assert destinations == ["203.0.113.10", "203.0.113.11"] + assert resolver.calls == [("provider.example", 11434), ("provider.example", 11434)] + + +@pytest.mark.parametrize("status_code", [301, 302, 303, 307, 308]) +def test_redirects_are_not_followed_and_only_one_request_is_sent(status_code: int): + resolver = MutableResolver(["203.0.113.10"]) + policy = _self_hosted_policy(resolver) + calls = 0 + + async def handler(request: httpx.Request) -> httpx.Response: + nonlocal calls + calls += 1 + return httpx.Response(status_code, headers={"Location": "https://redirected.example"}, request=request) + + sender = SecureProviderHttpSender(policy, transport=httpx.MockTransport(handler)) + with pytest.raises(ExternalServiceError) as caught: + asyncio.run(post(_ollama_config(), f"{_SELF_HOSTED_BASE}/api/chat", sender=sender, json={"model": "example"})) + + assert caught.value.message == "AI provider request failed." + assert calls == 1 + + +def test_persisted_unsafe_configuration_and_all_ai_routes_cannot_bypass_policy(client): + auth = register_user(client, "egress-routes@example.com") + repository_id = _upload_sample(client, auth["headers"]) + assert client.post(f"/analysis/{repository_id}/start", headers=auth["headers"]).status_code == 200 + assert run_analysis_jobs() == 1 + _insert_unsafe_ollama_config(auth["user"]["id"]) + + test_response = client.post( + "/ai/test", + json={"provider": "ollama", "baseUrl": "http://unapproved.example:11434"}, + headers=auth["headers"], + ) + assert test_response.status_code == 422 + + for path in ("/ai/query",): + response = client.post( + path, json={"repositoryId": repository_id, "query": "Summarize"}, headers=auth["headers"] + ) + assert response.status_code == 422 + assert "unapproved.example" not in response.text + + +def test_policy_denials_never_include_url_or_resolved_address(): + policy = ProviderEgressPolicy( + mode="hosted", allowed_base_urls=["https://provider.example"], resolver=MutableResolver(["127.0.0.1"]) + ) + + with pytest.raises(DestinationPolicyError) as caught: + policy.validate_config(AiProviderConfig(provider="ollama", base_url="https://provider.example")) + + assert "provider.example" not in str(caught.value) + assert "127.0.0.1" not in str(caught.value) + + +def test_provider_http_client_logs_do_not_expose_key_url_or_pinned_address(capsys): + configure_logging("DEBUG", "text") + resolver = MutableResolver(["203.0.113.10"]) + policy = _self_hosted_policy(resolver) + secret = "provider-secret-value" + + async def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={"message": {"content": "ok"}}, request=request) + + sender = SecureProviderHttpSender(policy, transport=httpx.MockTransport(handler)) + asyncio.run( + sender.post( + _ollama_config(), + f"{_SELF_HOSTED_BASE}/api/chat", + headers={"Authorization": f"Bearer {secret}"}, + ) + ) + + output = capsys.readouterr().out + assert logging.getLogger("httpx").getEffectiveLevel() >= logging.WARNING + assert logging.getLogger("httpcore").getEffectiveLevel() >= logging.WARNING + assert secret not in output + assert "provider.example" not in output + assert "203.0.113.10" not in output + + +def test_invalid_secret_header_encoding_is_a_generic_provider_error_without_a_network_call(): + policy = ProviderEgressPolicy(mode="hosted", resolver=MutableResolver(["8.8.8.8"])) + calls = 0 + + async def handler(request: httpx.Request) -> httpx.Response: + nonlocal calls + calls += 1 + return httpx.Response(200, json={"ok": True}, request=request) + + sender = SecureProviderHttpSender(policy, transport=httpx.MockTransport(handler)) + config = AiProviderConfig(provider="openai", api_key="invalid-unicode-secret") + + with pytest.raises(ExternalServiceError) as caught: + asyncio.run( + post( + config, + "https://api.openai.com/v1/chat/completions", + sender=sender, + headers={"Authorization": "Bearer \N{SNOWMAN}"}, + ) + ) + + assert caught.value.message == "AI provider request failed." + assert caught.value.details == {"provider": "openai"} + assert calls == 0 diff --git a/apps/backend/tests/test_ai_provider_capabilities.py b/apps/backend/tests/test_ai_provider_capabilities.py new file mode 100644 index 00000000..183c0307 --- /dev/null +++ b/apps/backend/tests/test_ai_provider_capabilities.py @@ -0,0 +1,130 @@ +"""GET /ai/providers: safe, non-secret setup metadata (#291). + +Covers the acceptance criteria that matter most: the response never leaks a +secret, it's auth-gated like every other /ai/* route, and its facts actually +agree with what the save/test flow enforces -- the whole point of a single +registry is that these two things cannot silently drift apart. +""" + +import re + +import pytest + +from app.ai.providers.capabilities import PROVIDER_CAPABILITIES, capability_for +from tests.conftest import register_user + + +def test_list_providers_requires_authentication(client): + response = client.get("/ai/providers") + + assert response.status_code == 401 + + +def test_list_providers_returns_every_supported_provider_with_the_expected_shape(auth_client): + response = auth_client.get("/ai/providers") + + assert response.status_code == 200, response.text + body = response.json() + providers = {item["provider"]: item for item in body["providers"]} + assert set(providers) == set(PROVIDER_CAPABILITIES) + + for provider_id, item in providers.items(): + capability = capability_for(provider_id) + assert item["displayName"] == capability.display_name + assert item["requiresApiKey"] == capability.requires_api_key + assert item["requiresBaseUrl"] == capability.requires_base_url + assert item["defaultModel"] == capability.default_model + assert item["setupUrl"] == capability.setup_url + assert item["supportState"] == "supported" + # At most 4 short steps, matching the issue's own UI constraint. + assert 1 <= len(item["setupSteps"]) <= 4 + assert all(isinstance(step, str) and step for step in item["setupSteps"]) + + +def test_list_providers_setup_urls_are_https_and_point_at_the_providers_own_domain(auth_client): + response = auth_client.get("/ai/providers") + providers = {item["provider"]: item["setupUrl"] for item in response.json()["providers"]} + + expected_hosts = { + "openai": "platform.openai.com", + "anthropic": "console.anthropic.com", + "gemini": "aistudio.google.com", + "openrouter": "openrouter.ai", + "ollama": "ollama.com", + } + for provider_id, expected_host in expected_hosts.items(): + url = providers[provider_id] + assert url.startswith("https://"), f"{provider_id} setup URL must be HTTPS: {url}" + assert expected_host in url, f"{provider_id} setup URL must point at {expected_host}: {url}" + + +def test_list_providers_response_carries_no_secret_looking_material(auth_client): + """The schema legitimately has fields *about* keys (requiresApiKey) -- + what must never appear is an actual key-shaped value.""" + + response = auth_client.get("/ai/providers") + serialized = response.text + + assert not re.search(r"sk-[A-Za-z0-9]{10,}", serialized) + # Every string value in the payload is either a known short label/URL/ + # step sentence, or a boolean/provider id -- assert none of them is a + # long opaque token, which is what a real leaked credential would look + # like regardless of which field carried it. + for item in response.json()["providers"]: + for value in item.values(): + if isinstance(value, str): + assert not re.fullmatch(r"[A-Za-z0-9_-]{24,}", value), f"looks like a token: {value!r}" + + +def _config_store(db, owner_id: str): + """A store wired with an egress policy that always allows, so these + tests exercise only the capability-driven key requirement -- egress/SSRF + policy has its own dedicated coverage in test_ai_egress_policy.py.""" + + from app.ai.providers.config_store import EncryptedProviderConfigStore + from app.core.crypto import build_provider_cipher + from app.core.config import Settings + + class _AllowAllEgressPolicy: + def validate_config(self, config): # noqa: ANN001, ANN201 -- test stub + return None + + return EncryptedProviderConfigStore( + db=db, + cipher=build_provider_cipher(Settings(app_env="test")), + owner_id=owner_id, + egress_policy=_AllowAllEgressPolicy(), + ) + + +def test_ollama_capability_matches_actual_save_time_enforcement(client): + """The registry says ollama doesn't require a key; prove the store + actually behaves that way, so the two can't silently diverge.""" + + from app.ai.types import AiProviderConfig + from app.core.database import SessionLocal + + assert capability_for("ollama").requires_api_key is False + owner_id = register_user(client, "ollama-capability@example.com")["user"]["id"] + + with SessionLocal() as db: + store = _config_store(db, owner_id) + result = store.save_config(AiProviderConfig(provider="ollama", base_url="http://provider.example:11434")) + + assert result.has_api_key is False + assert result.provider == "ollama" + + +def test_cloud_provider_capability_matches_actual_save_time_enforcement(client): + """The registry says openai requires a key; prove the store actually + rejects saving one without it.""" + + from app.ai.types import AiProviderConfig + from app.core.database import SessionLocal + from app.core.exceptions import ValidationServiceError + + assert capability_for("openai").requires_api_key is True + owner_id = register_user(client, "openai-capability@example.com")["user"]["id"] + + with SessionLocal() as db, pytest.raises(ValidationServiceError, match="API key is required"): + _config_store(db, owner_id).save_config(AiProviderConfig(provider="openai")) diff --git a/apps/backend/tests/test_ai_providers.py b/apps/backend/tests/test_ai_providers.py index 8e17729f..17c3728f 100644 --- a/apps/backend/tests/test_ai_providers.py +++ b/apps/backend/tests/test_ai_providers.py @@ -1,191 +1,220 @@ import asyncio -from collections.abc import Callable from copy import deepcopy from typing import Any import httpx import pytest -from app.ai.orchestrator import AiOrchestrator -from app.ai.prompt_builder import PromptBuilder from app.ai.providers.anthropic import AnthropicProvider -from app.ai.providers.factory import ProviderFactory from app.ai.providers.gemini import GeminiProvider -from app.ai.providers.legacy import LegacyProvider from app.ai.providers.ollama import OllamaProvider from app.ai.providers.openai import OpenAIProvider from app.ai.providers.openrouter import OpenRouterProvider -from app.ai.providers.registry import ProviderRegistry -from app.ai.repository_context import RepositoryContextBuilder from app.ai.types import AiProviderConfig, PromptBundle from app.api.deps import get_provider_registry -from app.core.exceptions import ExternalServiceError, ValidationServiceError -from app.schemas.ai import AiProviderTestRequest +from app.core.exceptions import ExternalServiceError, TimeoutServiceError, ValidationServiceError PROMPT = PromptBundle(system_prompt="System prompt", user_prompt="User prompt") -class FakeResponse: - def __init__(self, payload: dict[str, Any]) -> None: +class RecordingSender: + def __init__(self, payload: dict[str, Any], error: Exception | None = None) -> None: self.payload = payload + self.error = error + self.calls: list[dict[str, Any]] = [] - def raise_for_status(self) -> None: - return None + async def post(self, config: AiProviderConfig, url: str, **kwargs: object) -> httpx.Response: + self.calls.append({"config": config, "url": url, "kwargs": deepcopy(kwargs)}) + if self.error: + raise self.error + return httpx.Response(200, json=self.payload, request=httpx.Request("POST", "https://provider.example")) - def json(self) -> dict[str, Any]: - return self.payload +class ConcurrencyTrackingSender: + """Records how many `post()` calls were ever in flight at once (#414). -class RecordingAsyncClient: - calls: list[dict[str, Any]] = [] - payload: dict[str, Any] = {} - exception: httpx.HTTPError | None = None + Holds each call open for `delay` seconds before returning, so several + calls fired at the same time genuinely have a window to overlap if + nothing is gating them -- a delay of 0 would let every call finish + before the next even starts, defeating the point of the test. + """ - def __init__(self, timeout: int) -> None: - self.timeout = timeout - - async def __aenter__(self): - return self - - async def __aexit__(self, exc_type, exc, traceback) -> None: - return None - - async def post(self, url: str, **kwargs): - self.calls.append({"timeout": self.timeout, "url": url, "kwargs": deepcopy(kwargs)}) - if self.exception: - raise self.exception - return FakeResponse(deepcopy(self.payload)) - - -class StaticConfigStore: - def __init__(self, config: AiProviderConfig) -> None: - self.config = config + def __init__(self, payload: dict[str, Any], *, delay: float = 0.05) -> None: + self.payload = payload + self.delay = delay + self.current = 0 + self.max_concurrent = 0 - def config_for_test(self, request: AiProviderTestRequest) -> AiProviderConfig: - return self.config + async def post(self, config: AiProviderConfig, url: str, **kwargs: object) -> httpx.Response: + self.current += 1 + self.max_concurrent = max(self.max_concurrent, self.current) + await asyncio.sleep(self.delay) + self.current -= 1 + return httpx.Response(200, json=self.payload, request=httpx.Request("POST", "https://provider.example")) def _provider_cases(): return [ ( - "openai", OpenAIProvider, AiProviderConfig(provider="openai", api_key="key"), {"choices": [{"message": {"content": "OpenAI answer"}}]}, + "https://api.openai.com/v1/chat/completions", ), ( - "anthropic", AnthropicProvider, AiProviderConfig(provider="anthropic", api_key="key"), - {"content": [{"type": "text", "text": "Anthropic"}, {"type": "tool", "text": "ignored"}, {"type": "text", "text": "answer"}]}, + {"content": [{"type": "text", "text": "Anthropic answer"}]}, + "https://api.anthropic.com/v1/messages", ), ( - "gemini", GeminiProvider, AiProviderConfig(provider="gemini", api_key="key"), - {"candidates": [{"content": {"parts": [{"text": "Gemini"}, {"text": "answer"}]}}]}, + {"candidates": [{"content": {"parts": [{"text": "Gemini answer"}]}}]}, + "https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent", ), ( - "openrouter", OpenRouterProvider, AiProviderConfig(provider="openrouter", api_key="key"), {"choices": [{"message": {"content": "OpenRouter answer"}}]}, + "https://openrouter.ai/api/v1/chat/completions", ), ( - "ollama", OllamaProvider, - AiProviderConfig(provider="ollama", base_url="http://localhost:11434/"), + AiProviderConfig(provider="ollama", base_url="http://provider.example:11434"), {"message": {"content": "Ollama answer"}}, + "http://provider.example:11434/api/chat", ), ] -def _run_with_recording(provider, config: AiProviderConfig, payload: dict[str, Any], monkeypatch: pytest.MonkeyPatch): - RecordingAsyncClient.calls = [] - RecordingAsyncClient.payload = payload - RecordingAsyncClient.exception = None - monkeypatch.setattr(httpx, "AsyncClient", RecordingAsyncClient) - response = asyncio.run(provider.complete(config, PROMPT)) - return response, deepcopy(RecordingAsyncClient.calls) +@pytest.mark.parametrize(("provider_class", "config", "payload", "expected_url"), _provider_cases()) +def test_each_registered_provider_uses_the_injected_central_sender(provider_class, config, payload, expected_url): + sender = RecordingSender(payload) + response = asyncio.run(provider_class(sender).complete(config, PROMPT)) -def _malformed_payload(provider_name: str) -> dict[str, Any]: - if provider_name == "anthropic": - return {"content": None} - return {} + assert response.content.endswith("answer") + assert len(sender.calls) == 1 + assert sender.calls[0]["url"] == expected_url + assert sender.calls[0]["config"] is config -def _capture_exception(call: Callable[[], Any]) -> Exception: - try: - call() - except Exception as exc: - return exc - raise AssertionError("Expected provider call to fail.") +def test_gemini_credential_is_sent_in_a_header_not_the_url(): + sender = RecordingSender({"candidates": [{"content": {"parts": [{"text": "Gemini answer"}]}}]}) + key = "gemini-secret-value" + asyncio.run(GeminiProvider(sender).complete(AiProviderConfig(provider="gemini", api_key=key), PROMPT)) -@pytest.mark.parametrize(("provider_name", "provider_class", "config", "payload"), _provider_cases()) -def test_dedicated_provider_matches_legacy_success(provider_name, provider_class, config, payload, monkeypatch: pytest.MonkeyPatch): - legacy_response, legacy_calls = _run_with_recording(LegacyProvider(), config, payload, monkeypatch) - provider_response, provider_calls = _run_with_recording(provider_class(), config, payload, monkeypatch) - - assert provider_response == legacy_response - assert provider_calls == legacy_calls - assert provider_calls[0]["timeout"] == 60 + call = sender.calls[0] + assert key not in call["url"] + assert call["kwargs"]["headers"] == {"x-goog-api-key": key} + assert "params" not in call["kwargs"] @pytest.mark.parametrize( - ("provider_name", "provider_class", "config"), + ("provider_class", "config"), [ - ("openai", OpenAIProvider, AiProviderConfig(provider="openai")), - ("anthropic", AnthropicProvider, AiProviderConfig(provider="anthropic")), - ("gemini", GeminiProvider, AiProviderConfig(provider="gemini")), - ("openrouter", OpenRouterProvider, AiProviderConfig(provider="openrouter")), + (OpenAIProvider, AiProviderConfig(provider="openai")), + (AnthropicProvider, AiProviderConfig(provider="anthropic")), + (GeminiProvider, AiProviderConfig(provider="gemini")), + (OpenRouterProvider, AiProviderConfig(provider="openrouter")), ], ) -def test_dedicated_provider_matches_legacy_authentication_failure(provider_name, provider_class, config): - legacy_error = _capture_exception(lambda: asyncio.run(LegacyProvider().complete(config, PROMPT))) - provider_error = _capture_exception(lambda: asyncio.run(provider_class().complete(config, PROMPT))) +def test_cloud_providers_require_a_key_before_sending(provider_class, config): + sender = RecordingSender({}) + + with pytest.raises(ValidationServiceError, match="API key is required"): + asyncio.run(provider_class(sender).complete(config, PROMPT)) + + assert sender.calls == [] + + +def test_provider_connect_errors_get_an_honest_unreachable_message(): + """#291: "unreachable Ollama" and "invalid base URL" surface identically + at the connection layer, so one honest message covers both rather than + guessing which one it was.""" + + request = httpx.Request("POST", "https://provider.example") + sender = RecordingSender({}, httpx.ConnectError("network failed", request=request)) + + with pytest.raises(ExternalServiceError) as caught: + asyncio.run(OpenAIProvider(sender).complete(AiProviderConfig(provider="openai", api_key="key"), PROMPT)) + + assert "Could not reach the AI provider" in caught.value.message + assert caught.value.details == {"provider": "openai"} + + +def test_provider_connect_timeout_is_also_reported_as_unreachable(): + """httpx.ConnectTimeout inherits from both ConnectError and + TimeoutException; a timeout during the connect phase itself should read + as unreachable, not as "the provider is just slow to answer".""" + + request = httpx.Request("POST", "https://provider.example") + sender = RecordingSender({}, httpx.ConnectTimeout("connect timed out", request=request)) + + with pytest.raises(ExternalServiceError) as caught: + asyncio.run(OpenAIProvider(sender).complete(AiProviderConfig(provider="openai", api_key="key"), PROMPT)) + + assert "Could not reach the AI provider" in caught.value.message + + +def test_provider_read_timeout_is_reported_as_a_timeout_not_unreachable(): + request = httpx.Request("POST", "https://provider.example") + sender = RecordingSender({}, httpx.ReadTimeout("read timed out", request=request)) + + with pytest.raises(TimeoutServiceError) as caught: + asyncio.run(OpenAIProvider(sender).complete(AiProviderConfig(provider="openai", api_key="key"), PROMPT)) + + assert "did not respond in time" in caught.value.message + + +@pytest.mark.parametrize("status_code", (401, 403)) +def test_provider_auth_rejection_explains_the_key_is_the_problem(status_code): + request = httpx.Request("POST", "https://provider.example") + response = httpx.Response(status_code, request=request) + sender = RecordingSender({}, httpx.HTTPStatusError("unauthorized", request=request, response=response)) + + with pytest.raises(ValidationServiceError) as caught: + asyncio.run(OpenAIProvider(sender).complete(AiProviderConfig(provider="openai", api_key="key"), PROMPT)) + + assert "rejected the API key" in caught.value.message + + +def test_provider_rate_limit_is_explained_in_plain_language(): + request = httpx.Request("POST", "https://provider.example") + response = httpx.Response(429, request=request) + sender = RecordingSender({}, httpx.HTTPStatusError("too many requests", request=request, response=response)) + + with pytest.raises(ExternalServiceError) as caught: + asyncio.run(OpenAIProvider(sender).complete(AiProviderConfig(provider="openai", api_key="key"), PROMPT)) - assert type(provider_error) is type(legacy_error) - assert isinstance(provider_error, ValidationServiceError) - assert provider_error.message == legacy_error.message - assert provider_error.details == legacy_error.details + assert "rate limit" in caught.value.message.lower() -@pytest.mark.parametrize(("provider_name", "provider_class", "config", "payload"), _provider_cases()) -def test_dedicated_provider_matches_legacy_network_failure(provider_name, provider_class, config, payload, monkeypatch: pytest.MonkeyPatch): - request = httpx.Request("POST", "https://example.test") +@pytest.mark.parametrize("status_code", (400, 404, 422)) +def test_provider_bad_request_is_explained_as_an_unsupported_model(status_code): + request = httpx.Request("POST", "https://provider.example") + response = httpx.Response(status_code, request=request) + sender = RecordingSender({}, httpx.HTTPStatusError("bad request", request=request, response=response)) - def run(provider): - RecordingAsyncClient.calls = [] - RecordingAsyncClient.payload = payload - RecordingAsyncClient.exception = httpx.ConnectError("network failed", request=request) - monkeypatch.setattr(httpx, "AsyncClient", RecordingAsyncClient) - return _capture_exception(lambda: asyncio.run(provider.complete(config, PROMPT))) + with pytest.raises(ValidationServiceError) as caught: + asyncio.run(OpenAIProvider(sender).complete(AiProviderConfig(provider="openai", api_key="key"), PROMPT)) - legacy_error = run(LegacyProvider()) - provider_error = run(provider_class()) + assert "unsupported model" in caught.value.message.lower() - assert type(provider_error) is type(legacy_error) - assert isinstance(provider_error, ExternalServiceError) - assert provider_error.message == legacy_error.message - assert provider_error.details == legacy_error.details +def test_provider_response_parsing_errors_remain_normalized(): + sender = RecordingSender({}) -@pytest.mark.parametrize(("provider_name", "provider_class", "config", "payload"), _provider_cases()) -def test_dedicated_provider_matches_legacy_response_parsing_failure(provider_name, provider_class, config, payload, monkeypatch: pytest.MonkeyPatch): - malformed_payload = _malformed_payload(provider_name) - legacy_error = _capture_exception(lambda: _run_with_recording(LegacyProvider(), config, malformed_payload, monkeypatch)) - provider_error = _capture_exception(lambda: _run_with_recording(provider_class(), config, malformed_payload, monkeypatch)) + with pytest.raises(ExternalServiceError) as caught: + asyncio.run(OpenAIProvider(sender).complete(AiProviderConfig(provider="openai", api_key="key"), PROMPT)) - assert type(provider_error) is type(legacy_error) - assert isinstance(provider_error, ExternalServiceError) - assert provider_error.message == legacy_error.message - assert provider_error.details == legacy_error.details + assert caught.value.message == "AI provider request failed." -def test_registry_uses_dedicated_providers(): +def test_default_registry_contains_only_dedicated_secure_provider_implementations(): registry = get_provider_registry() assert isinstance(registry.get("openai"), OpenAIProvider) @@ -193,41 +222,108 @@ def test_registry_uses_dedicated_providers(): assert isinstance(registry.get("gemini"), GeminiProvider) assert isinstance(registry.get("openrouter"), OpenRouterProvider) assert isinstance(registry.get("ollama"), OllamaProvider) - assert not isinstance(registry.get("openai"), LegacyProvider) -def test_factory_resolves_dedicated_provider(): - registry = ProviderRegistry() - provider = OpenAIProvider() - registry.register("openai", provider) +# --- Ollama concurrency limit (#414) ----------------------------------------- +# +# Ollama runs inference on the same machine PARTHA runs on, so an unbounded +# number of concurrent requests competes with the user's own machine for CPU +# and memory -- unlike the four hosted providers above, which each call a +# fixed remote URL with their own server-side infrastructure. See the linked +# issue for the real reproduction (measured CPU/memory) that motivated this. - resolved = ProviderFactory(registry).resolve(AiProviderConfig(provider="openai", api_key="key")) - assert resolved is provider +def test_ollama_never_sends_more_than_one_request_at_a_time(): + sender = ConcurrencyTrackingSender({"message": {"content": "ok"}}) + provider = OllamaProvider(sender) + config = AiProviderConfig(provider="ollama", base_url="http://provider.example:11434") + async def fire_five(): + await asyncio.gather(*(provider.complete(config, PROMPT) for _ in range(5))) -def test_connection_testing_uses_resolved_dedicated_provider(monkeypatch: pytest.MonkeyPatch): + asyncio.run(fire_five()) + + assert sender.max_concurrent == 1 + + +def test_ollama_still_serves_every_request_it_just_serializes_them(): + sender = ConcurrencyTrackingSender({"message": {"content": "ok"}}) + provider = OllamaProvider(sender) + config = AiProviderConfig(provider="ollama", base_url="http://provider.example:11434") + + async def fire_five(): + return await asyncio.gather(*(provider.complete(config, PROMPT) for _ in range(5))) + + responses = asyncio.run(fire_five()) + + # Queued and served, never dropped or errored -- this is resource + # management, not a rate limit. + assert len(responses) == 5 + assert all(response.content == "ok" for response in responses) + + +def test_a_second_ollama_provider_instance_shares_the_same_limit(): + """The registry constructs a fresh OllamaProvider per request (#414) -- + the limit has to live above any single instance or it would do nothing. + """ + + sender = ConcurrencyTrackingSender({"message": {"content": "ok"}}) + config = AiProviderConfig(provider="ollama", base_url="http://provider.example:11434") + + async def fire_from_two_instances(): + first = OllamaProvider(sender) + second = OllamaProvider(sender) + await asyncio.gather( + *(first.complete(config, PROMPT) for _ in range(3)), + *(second.complete(config, PROMPT) for _ in range(3)), + ) + + asyncio.run(fire_from_two_instances()) + + assert sender.max_concurrent == 1 + + +def test_the_ollama_limit_does_not_apply_to_a_hosted_provider(): + """A hosted provider has its own remote infrastructure, not the user's + machine, behind it -- it must never be throttled by Ollama's limit.""" + + sender = ConcurrencyTrackingSender({"choices": [{"message": {"content": "ok"}}]}) + provider = OpenAIProvider(sender) config = AiProviderConfig(provider="openai", api_key="key") - registry = ProviderRegistry() - registry.register("openai", OpenAIProvider()) - orchestrator = AiOrchestrator( - repository=object(), # type: ignore[arg-type] - config_store=StaticConfigStore(config), # type: ignore[arg-type] - context_builder=RepositoryContextBuilder(object()), # type: ignore[arg-type] - prompt_builder=PromptBuilder(), - provider_factory=ProviderFactory(registry), - ) - - RecordingAsyncClient.calls = [] - RecordingAsyncClient.payload = {"choices": [{"message": {"content": "ok"}}]} - RecordingAsyncClient.exception = None - monkeypatch.setattr(httpx, "AsyncClient", RecordingAsyncClient) - - response = asyncio.run(orchestrator.test_connection(AiProviderTestRequest(provider="openai"))) - - assert response.ok is True - assert response.message == "openai connection succeeded." - assert RecordingAsyncClient.calls[0]["kwargs"]["json"]["messages"] == [ - {"role": "system", "content": "Reply with the single word: ok"}, - {"role": "user", "content": "Connection test."}, - ] + + async def fire_five(): + await asyncio.gather(*(provider.complete(config, PROMPT) for _ in range(5))) + + asyncio.run(fire_five()) + + assert sender.max_concurrent == 5 + + +# --- Ollama local-inference timeout ----------------------------------------- +# +# The shared sender defaults to 60s, which is right for a hosted API but cuts +# off a local Ollama mid-generation (model load + CPU inference legitimately +# runs longer). Ollama passes an explicit long read budget instead; hosted +# providers keep the default. + + +def test_ollama_requests_a_long_read_budget_for_local_generation(): + sender = RecordingSender({"message": {"content": "ok"}}) + config = AiProviderConfig(provider="ollama", base_url="http://provider.example:11434") + + asyncio.run(OllamaProvider(sender).complete(config, PROMPT)) + + timeout = sender.calls[0]["kwargs"]["timeout"] + assert isinstance(timeout, httpx.Timeout) + assert timeout.read is not None and timeout.read >= 300 + # The connect phase stays tight so a wrong/unreachable base URL fails fast. + assert timeout.connect is not None and timeout.connect <= 15 + + +def test_hosted_providers_keep_the_default_sender_timeout(): + sender = RecordingSender({"choices": [{"message": {"content": "ok"}}]}) + config = AiProviderConfig(provider="openai", api_key="key") + + asyncio.run(OpenAIProvider(sender).complete(config, PROMPT)) + + assert sender.calls[0]["kwargs"]["timeout"] is None diff --git a/apps/backend/tests/test_analysis_control_plane.py b/apps/backend/tests/test_analysis_control_plane.py new file mode 100644 index 00000000..6551a431 --- /dev/null +++ b/apps/backend/tests/test_analysis_control_plane.py @@ -0,0 +1,840 @@ +"""The analysis queue and control-plane boundary (#324). + +These tests exercise ``app.workers.control_plane`` and ``app.workers.runner`` +directly, without running the extraction pipeline: the point is *who owns a +job*, not what analysing it produces. Pipeline behaviour is already covered by +``test_analysis_worker.py``, and this file must not become a second copy of it. + +Determinism: every race here is expressed as an explicit interleaving (both +sides read, then both sides write) or synchronised with a ``threading.Barrier``. +Nothing sleeps waiting for a race to happen, so a slow machine cannot turn a +correctness assertion into a flake. The one genuinely threaded race is gated on +a real PostgreSQL server, matching the repository's established pattern for +concurrency that SQLite's single-writer lock cannot represent. +""" + +from __future__ import annotations + +import os +import threading +from datetime import UTC, datetime, timedelta +from uuid import uuid4 + +import pytest +from sqlalchemy import create_engine, func, select +from sqlalchemy.orm import sessionmaker + +from app.core.database import register_sqlite_foreign_key_enforcement +from app.models import RepositoryRecord, User +from app.models.analysis_job import AnalysisJob +from app.models.base import Base +from app.services.analysis_job_service import ANALYSIS_CONFIG_HASH +from app.workers.control_plane import ( + DatabaseAnalysisControlPlane, + JobLease, + lease_expired, +) +from app.workers.runner import AnalysisWorkerRunner, new_worker_id + +UPLOAD_REVISION = "sha256:" + "c" * 64 +PG_URL = os.environ.get("PARTHA_TEST_PG_URL") + + +@pytest.fixture() +def session_factory(tmp_path): + register_sqlite_foreign_key_enforcement() + engine = create_engine(f"sqlite:///{tmp_path / 'control-plane.db'}") + Base.metadata.create_all(engine) + factory = sessionmaker(bind=engine, expire_on_commit=False) + yield factory + engine.dispose() + + +def _owner(session) -> User: + owner = User(id=str(uuid4()), email=f"{uuid4().hex}@example.com", password_hash=None) + session.add(owner) + session.commit() + return owner + + +def _repository(session, owner: User) -> RepositoryRecord: + record = RepositoryRecord( + id=str(uuid4()), + owner_id=owner.id, + name="repo", + source="upload", + revision_kind="upload", + revision_value=UPLOAD_REVISION, + local_path="/x", + status="analysing", + file_tree=[], + ) + session.add(record) + session.commit() + return record + + +def _queued_job(session, record: RepositoryRecord, **overrides) -> AnalysisJob: + """Insert a queued job row directly -- the queue's input, not the pipeline's.""" + + values: dict[str, object] = { + "id": str(uuid4()), + "repository_id": record.id, + "owner_id": record.owner_id, + "revision_kind": record.revision_kind, + "revision_value": record.revision_value, + "config_hash": ANALYSIS_CONFIG_HASH, + "status": "queued", + "attempt": 0, + } + values.update(overrides) + job = AnalysisJob(**values) + session.add(job) + session.commit() + return job + + +def _bootstrap(factory, **overrides) -> tuple[str, str]: + """Create one owner, repository and queued job; return (record_id, job_id).""" + + with factory() as session: + owner = _owner(session) + record = _repository(session, owner) + job = _queued_job(session, record, **overrides) + return record.id, job.id + + +def _plane(lease_seconds: int = 60, clock=None) -> DatabaseAnalysisControlPlane: + if clock is None: + return DatabaseAnalysisControlPlane(lease_seconds=lease_seconds) + return DatabaseAnalysisControlPlane(lease_seconds=lease_seconds, clock=clock) + + +# -- AC1: jobs are claimed through an explicit lease path -------------------- + + +def test_claim_returns_a_lease_and_marks_the_job_running(session_factory): + _, job_id = _bootstrap(session_factory) + plane = _plane() + + with session_factory() as session: + lease = plane.claim(session, worker_id="worker-a") + + assert lease is not None + assert lease.job_id == job_id + assert lease.worker_id == "worker-a" + assert lease.attempt == 1 + + with session_factory() as reader: + job = reader.get(AnalysisJob, job_id) + assert job.status == "running" + assert job.worker_id == "worker-a" + assert job.lease_expires_at is not None + assert job.started_at is not None + assert job.next_attempt_at is None + + +def test_claim_returns_none_when_the_queue_is_empty(session_factory): + with session_factory() as session: + owner = _owner(session) + _repository(session, owner) + + with session_factory() as session: + assert _plane().claim(session, worker_id="worker-a") is None + + +def test_claim_skips_a_job_still_serving_retry_backoff(session_factory): + """``next_attempt_at`` is queue eligibility, not just a record of intent.""" + + future = datetime.now(UTC) + timedelta(seconds=300) + _, job_id = _bootstrap(session_factory, next_attempt_at=future) + plane = _plane() + + with session_factory() as session: + assert plane.claim(session, worker_id="worker-a") is None + + # Once the delay elapses the same job becomes claimable, unchanged. + later = _plane(clock=lambda: datetime.now(UTC) + timedelta(seconds=600)) + with session_factory() as session: + lease = later.claim(session, worker_id="worker-a") + assert lease is not None and lease.job_id == job_id + + +def test_claim_takes_the_oldest_eligible_job_first(session_factory): + with session_factory() as session: + owner = _owner(session) + record = _repository(session, owner) + older = _queued_job(session, record, created_at=datetime.now(UTC) - timedelta(minutes=5)) + # A second repository, because one repository may hold only one + # effective-identity job at a time. + other_record = _repository(session, owner) + _queued_job(session, other_record, created_at=datetime.now(UTC)) + older_id = older.id + + with session_factory() as session: + lease = _plane().claim(session, worker_id="worker-a") + + assert lease is not None and lease.job_id == older_id + + +# -- AC2: duplicate claims and expired leases -------------------------------- + + +class _PinnedCandidatePlane(DatabaseAnalysisControlPlane): + """A control plane frozen just after it read its candidate. + + A claim is a candidate read followed by a compare-and-swap. The loser of a + real race is a worker whose read happened *before* the winner committed, so + reproducing the race means holding that stale candidate across the winner's + claim. Pinning the id does exactly that and changes nothing else: the swap + under test is the unmodified production statement. + """ + + def __init__(self, candidate_id: str, **kwargs) -> None: + super().__init__(**kwargs) + self._candidate_id = candidate_id + + def next_eligible_job_id(self, session, *, now=None): + return self._candidate_id + + +def test_two_workers_racing_one_job_produce_exactly_one_owner(session_factory): + """The claim guard, proved by an explicit interleaving. + + Both workers resolve the same candidate before either swaps, which is + precisely the window a ``SELECT`` then ``UPDATE`` claim has to survive. The + ``status='queued'`` predicate is what makes the loser's write match no row. + """ + + _, job_id = _bootstrap(session_factory) + + session_a = session_factory() + session_b = session_factory() + try: + # Both read the queue while the job is still queued. + candidate_a = _plane().next_eligible_job_id(session_a) + candidate_b = _plane().next_eligible_job_id(session_b) + assert candidate_a == candidate_b == job_id + + # Both now swap, each still holding the candidate it read. + lease_a = _PinnedCandidatePlane(candidate_a, lease_seconds=60).claim(session_a, worker_id="worker-a") + lease_b = _PinnedCandidatePlane(candidate_b, lease_seconds=60).claim(session_b, worker_id="worker-b") + finally: + session_a.close() + session_b.close() + + winners = [lease for lease in (lease_a, lease_b) if lease is not None] + assert len(winners) == 1, "both workers claimed the same job" + + with session_factory() as reader: + job = reader.get(AnalysisJob, job_id) + assert job.status == "running" + assert job.worker_id == winners[0].worker_id + # The loser must not have inflated the attempt budget on its way past. + assert job.attempt == 1 + + +def test_a_second_claim_of_a_running_job_takes_nothing(session_factory): + """The swap guard alone, with no candidate-selection help.""" + + _, job_id = _bootstrap(session_factory) + + with session_factory() as session: + assert _plane().claim(session, worker_id="worker-a") is not None + + # worker-b swaps against a candidate that is no longer queued. + with session_factory() as session: + assert _PinnedCandidatePlane(job_id, lease_seconds=60).claim(session, worker_id="worker-b") is None + + with session_factory() as reader: + job = reader.get(AnalysisJob, job_id) + assert job.worker_id == "worker-a" + assert job.attempt == 1 + + +def test_two_workers_claim_two_separate_jobs(session_factory): + with session_factory() as session: + owner = _owner(session) + first = _queued_job(session, _repository(session, owner)) + second = _queued_job(session, _repository(session, owner)) + job_ids = {first.id, second.id} + + with session_factory() as session: + lease_a = _plane().claim(session, worker_id="worker-a") + with session_factory() as session: + lease_b = _plane().claim(session, worker_id="worker-b") + + assert lease_a is not None and lease_b is not None + assert {lease_a.job_id, lease_b.job_id} == job_ids + + +def test_an_expired_lease_is_reclaimable_by_another_worker(session_factory): + _, job_id = _bootstrap(session_factory) + expired_clock = datetime.now(UTC) - timedelta(hours=1) + stale = _plane(clock=lambda: expired_clock) + + with session_factory() as session: + assert stale.claim(session, worker_id="worker-a") is not None + + fresh = _plane() + with session_factory() as session: + assert fresh.expired_job_ids(session) == (job_id,) + job = session.get(AnalysisJob, job_id) + reclaimed = fresh.reclaim(session, job, worker_id="worker-b") + session.commit() + + assert reclaimed is not None + assert reclaimed.worker_id == "worker-b" + + with session_factory() as reader: + row = reader.get(AnalysisJob, job_id) + assert row.worker_id == "worker-b" + assert row.status == "running" + # Reclaim transfers ownership only; the attempt budget is retry policy + # and must not be spent by a handoff. + assert row.attempt == 1 + + +def test_an_active_lease_is_never_stolen(session_factory): + _, job_id = _bootstrap(session_factory) + plane = _plane(lease_seconds=3600) + + with session_factory() as session: + assert plane.claim(session, worker_id="worker-a") is not None + + with session_factory() as session: + assert plane.expired_job_ids(session) == () + job = session.get(AnalysisJob, job_id) + assert plane.reclaim(session, job, worker_id="worker-b") is None + + with session_factory() as reader: + assert reader.get(AnalysisJob, job_id).worker_id == "worker-a" + + +def test_a_renewal_between_scan_and_reclaim_defeats_the_reclaim(session_factory): + """The reclaim guard pins the exact lease instant it observed. + + A sweeper that read an expired row must not act on that stale read if the + owner renewed in between -- otherwise a live worker loses its job to a + sweep it had already outrun. + """ + + _, job_id = _bootstrap(session_factory) + expired_clock = datetime.now(UTC) - timedelta(hours=1) + stale = _plane(clock=lambda: expired_clock) + + with session_factory() as session: + lease = stale.claim(session, worker_id="worker-a") + assert lease is not None + + sweeper = _plane() + with session_factory() as scan_session: + # The sweeper observes the row while the lease is still lapsed. + assert sweeper.expired_job_ids(scan_session) == (job_id,) + observed = scan_session.get(AnalysisJob, job_id) + assert observed.worker_id == "worker-a" + + # The rightful owner renews after that scan, from its own session. + with session_factory() as owner_session: + assert _plane().renew(owner_session, lease).held is True + + # The sweeper now acts on what it read. Its guard pins that lease + # instant, which no longer matches the row, so it takes nothing. + assert sweeper.reclaim(scan_session, observed, worker_id="worker-b") is None + + with session_factory() as reader: + assert reader.get(AnalysisJob, job_id).worker_id == "worker-a" + + +def test_lease_expired_compares_naive_and_aware_timestamps(session_factory): + """SQLite hands back naive datetimes; PostgreSQL hands back aware ones.""" + + aware = datetime(2026, 1, 1, 12, 0, tzinfo=UTC) + naive = datetime(2026, 1, 1, 12, 0) + + assert lease_expired(naive, aware + timedelta(seconds=1)) is True + assert lease_expired(naive, aware - timedelta(seconds=1)) is False + assert lease_expired(aware, naive + timedelta(seconds=1)) is True + assert lease_expired(aware, naive - timedelta(seconds=1)) is False + + +# -- ownership enforcement --------------------------------------------------- + + +def test_a_non_owner_cannot_mutate_another_workers_job(session_factory): + _, job_id = _bootstrap(session_factory) + plane = _plane() + + with session_factory() as session: + assert plane.claim(session, worker_id="worker-a") is not None + + with session_factory() as session: + held = plane.update_owned( + session, + job_id=job_id, + worker_id="worker-b", + values={"status": "completed", "progress": 100}, + ) + session.rollback() + + assert held is False + with session_factory() as reader: + job = reader.get(AnalysisJob, job_id) + assert job.status == "running" + assert job.worker_id == "worker-a" + assert job.progress == 0 + + +def test_a_non_owner_cannot_renew_another_workers_lease(session_factory): + _, job_id = _bootstrap(session_factory) + plane = _plane() + + with session_factory() as session: + owned = plane.claim(session, worker_id="worker-a") + assert owned is not None + + impostor = JobLease(job_id=job_id, worker_id="worker-b", expires_at=owned.expires_at, attempt=1) + with session_factory() as session: + renewal = plane.renew(session, impostor) + + assert renewal.lost is True + with session_factory() as reader: + assert reader.get(AnalysisJob, job_id).worker_id == "worker-a" + + +def test_a_worker_that_lost_its_lease_is_told_so_on_the_next_renewal(session_factory): + """The handoff signal: renewal is how a displaced owner finds out.""" + + _, job_id = _bootstrap(session_factory) + plane = _plane() + + with session_factory() as session: + lease = plane.claim(session, worker_id="worker-a") + assert lease is not None + + with session_factory() as session: + assert plane.renew(session, lease).held is True + + # A reclaim hands the job to worker-b. + with session_factory() as session: + job = session.get(AnalysisJob, job_id) + job.lease_expires_at = datetime.now(UTC) - timedelta(hours=1) + session.commit() + assert plane.reclaim(session, job, worker_id="worker-b") is not None + session.commit() + + with session_factory() as session: + assert plane.renew(session, lease).lost is True + + +def test_a_terminal_job_cannot_be_renewed_or_mutated(session_factory): + _, job_id = _bootstrap(session_factory) + plane = _plane() + + with session_factory() as session: + lease = plane.claim(session, worker_id="worker-a") + assert lease is not None + + with session_factory() as session: + job = session.get(AnalysisJob, job_id) + job.status = "completed" + session.commit() + + with session_factory() as session: + assert plane.renew(session, lease).lost is True + assert plane.update_owned(session, job_id=job_id, values={"progress": 50}, worker_id="worker-a") is False + session.rollback() + + with session_factory() as reader: + assert reader.get(AnalysisJob, job_id).status == "completed" + + +# -- AC3: cancellation across the boundary ----------------------------------- + + +def test_a_renewal_reports_a_cancellation_request(session_factory): + _, job_id = _bootstrap(session_factory) + plane = _plane() + + with session_factory() as session: + lease = plane.claim(session, worker_id="worker-a") + assert lease is not None + + with session_factory() as session: + assert plane.renew(session, lease).cancel_requested is False + assert plane.cancel_requested(session, job_id) is False + + with session_factory() as session: + session.get(AnalysisJob, job_id).cancel_requested = True + session.commit() + + with session_factory() as session: + renewal = plane.renew(session, lease) + assert renewal.held is True + assert renewal.cancel_requested is True + assert plane.cancel_requested(session, job_id) is True + + +def test_a_cancellation_request_survives_a_reclaim_handoff(session_factory): + """Cancellation must not be dropped when ownership moves.""" + + _, job_id = _bootstrap(session_factory) + plane = _plane() + + with session_factory() as session: + assert plane.claim(session, worker_id="worker-a") is not None + + with session_factory() as session: + job = session.get(AnalysisJob, job_id) + job.cancel_requested = True + job.lease_expires_at = datetime.now(UTC) - timedelta(hours=1) + session.commit() + + reclaimed = plane.reclaim(session, job, worker_id="worker-b") + session.commit() + assert reclaimed is not None + + with session_factory() as session: + assert plane.cancel_requested(session, job_id) is True + assert plane.renew(session, reclaimed).cancel_requested is True + + +def test_cancellation_is_not_resurrected_after_a_cancelled_job_is_reclaimed(session_factory): + """A cancelled job is terminal: no later reclaim can put it back to work.""" + + _, job_id = _bootstrap(session_factory) + plane = _plane() + + with session_factory() as session: + assert plane.claim(session, worker_id="worker-a") is not None + + with session_factory() as session: + job = session.get(AnalysisJob, job_id) + job.status = "cancelled" + job.worker_id = None + job.cancel_requested = False + job.lease_expires_at = None + job.completed_at = datetime.now(UTC) + session.commit() + + with session_factory() as session: + # It is neither claimable nor sweepable, so no worker can own it again. + assert plane.claim(session, worker_id="worker-b") is None + assert plane.expired_job_ids(session) == () + + with session_factory() as reader: + assert reader.get(AnalysisJob, job_id).status == "cancelled" + + +def test_the_cancel_not_requested_guard_refuses_to_complete_a_cancelling_job(session_factory): + """``require_cancel_not_requested`` is what makes cancellation idempotent. + + A completion that raced an accepted cancellation must lose, so the request + cannot be silently discarded by a worker finishing a moment later. + """ + + _, job_id = _bootstrap(session_factory) + plane = _plane() + + with session_factory() as session: + assert plane.claim(session, worker_id="worker-a") is not None + + with session_factory() as session: + session.get(AnalysisJob, job_id).cancel_requested = True + session.commit() + + with session_factory() as session: + guarded = plane.update_owned( + session, + job_id=job_id, + worker_id="worker-a", + values={"status": "completed"}, + require_cancel_not_requested=True, + ) + session.rollback() + # The same worker may still act on the job -- it only may not pretend + # the cancellation never happened. + unguarded = plane.update_owned( + session, + job_id=job_id, + worker_id="worker-a", + values={"status": "cancelled", "cancel_requested": False}, + ) + session.commit() + + assert guarded is False + assert unguarded is True + with session_factory() as reader: + job = reader.get(AnalysisJob, job_id) + assert job.status == "cancelled" + assert job.cancel_requested is False + + +def test_repeated_cancellation_reads_are_idempotent(session_factory): + _, job_id = _bootstrap(session_factory) + plane = _plane() + + with session_factory() as session: + lease = plane.claim(session, worker_id="worker-a") + session.get(AnalysisJob, job_id).cancel_requested = True + session.commit() + assert lease is not None + + with session_factory() as session: + for _ in range(3): + assert plane.cancel_requested(session, job_id) is True + assert plane.renew(session, lease).cancel_requested is True + + with session_factory() as reader: + job = reader.get(AnalysisJob, job_id) + assert job.cancel_requested is True + assert job.status == "running" + + +# -- the in-process compatibility runner ------------------------------------- + + +class _RecordingWorker: + """A worker stand-in that records how the runner drives it.""" + + def __init__(self, outcomes: list[bool]) -> None: + self.worker_id = "worker-recording" + self._outcomes = list(outcomes) + self.run_once_calls = 0 + self.sweep_calls = 0 + self.shutdown_calls = 0 + self.drained = threading.Event() + + def run_once(self) -> bool: + self.run_once_calls += 1 + if self._outcomes: + return self._outcomes.pop(0) + self.drained.set() + return False + + def sweep_stale(self) -> int: + self.sweep_calls += 1 + return 0 + + def shutdown(self) -> None: + self.shutdown_calls += 1 + + +def _runner(worker, **kwargs) -> AnalysisWorkerRunner: + kwargs.setdefault("poll_interval_seconds", 0.01) + return AnalysisWorkerRunner(worker, **kwargs) + + +def test_the_runner_sweeps_once_before_it_starts_polling(session_factory): + worker = _RecordingWorker([]) + runner = _runner(worker) + + runner.sweep_on_start() + + assert worker.sweep_calls == 1 + assert worker.run_once_calls == 0 + + +def test_the_runner_drains_a_backlog_without_sleeping_between_jobs(): + worker = _RecordingWorker([True, True, True]) + runner = _runner(worker, poll_interval_seconds=30) + + runner.start() + try: + assert worker.drained.wait(timeout=5), "the runner did not drain the backlog" + finally: + runner.stop(timeout=5) + + # Three claimed jobs, then the empty poll that set ``drained``. A runner + # that slept between jobs could not have reached the fourth call with a + # 30-second poll interval. + assert worker.run_once_calls >= 4 + + +def test_the_runner_sweeps_stale_jobs_on_its_configured_cadence(): + worker = _RecordingWorker([]) + runner = _runner(worker, stale_sweep_interval_polls=2) + + runner.start() + try: + assert worker.drained.wait(timeout=5) + _wait_until(lambda: worker.sweep_calls >= 2, timeout=5) + finally: + runner.stop(timeout=5) + + # One startup sweep plus at least one periodic sweep from the loop. + assert worker.sweep_calls >= 2 + + +def test_a_failing_iteration_does_not_kill_the_runner_loop(): + class _ExplodingWorker(_RecordingWorker): + def run_once(self) -> bool: + self.run_once_calls += 1 + if self.run_once_calls == 1: + raise RuntimeError("boom") + self.drained.set() + return False + + worker = _ExplodingWorker([]) + runner = _runner(worker) + + runner.start() + try: + assert worker.drained.wait(timeout=5), "the loop died on the first failure" + finally: + runner.stop(timeout=5) + + +def test_stopping_the_runner_signals_the_worker_and_joins_the_thread(): + worker = _RecordingWorker([]) + runner = _runner(worker) + + runner.start() + assert worker.drained.wait(timeout=5) + runner.stop(timeout=5) + + assert worker.shutdown_calls == 1 + assert runner._thread is None + assert not any(thread.name == "analysis-worker" for thread in threading.enumerate()) + + +def test_stopping_a_runner_that_never_started_is_safe(): + worker = _RecordingWorker([]) + runner = _runner(worker) + + runner.stop(timeout=1) + + assert worker.shutdown_calls == 1 + assert worker.run_once_calls == 0 + + +def test_starting_a_running_runner_twice_is_refused(): + worker = _RecordingWorker([]) + runner = _runner(worker) + + runner.start() + try: + with pytest.raises(RuntimeError): + runner.start() + finally: + runner.stop(timeout=5) + + +def test_worker_ids_are_unique_within_one_process(): + """Every ownership guard is ``worker_id`` equality, so collisions are fatal.""" + + ids = {new_worker_id(pid=7) for _ in range(50)} + + assert len(ids) == 50 + assert all(worker_id.startswith("analysis-worker-7-") for worker_id in ids) + assert all(len(worker_id) <= 64 for worker_id in ids) + + +# -- the worker still reaches the queue only through the boundary ------------ + + +def test_the_worker_claims_through_its_injected_control_plane(session_factory): + """The seam is real: swapping the control plane changes what the worker gets.""" + + from app.workers.analysis_worker import AnalysisWorker + + _bootstrap(session_factory) + + class _EmptyQueue(DatabaseAnalysisControlPlane): + def claim(self, session, *, worker_id): + return None + + worker = AnalysisWorker( + session_factory, + worker_id="worker-a", + lease_seconds=60, + control_plane=_EmptyQueue(lease_seconds=60), + ) + + assert worker.run_once() is False + + with session_factory() as reader: + assert reader.scalar(select(func.count()).select_from(AnalysisJob).where(AnalysisJob.status == "queued")) == 1 + + +def test_the_worker_defaults_to_the_database_control_plane(session_factory): + from app.workers.analysis_worker import AnalysisWorker + + worker = AnalysisWorker(session_factory, worker_id="worker-a", lease_seconds=60) + + assert isinstance(worker.control_plane, DatabaseAnalysisControlPlane) + assert worker.control_plane.lease_seconds == 60 + + +# -- PostgreSQL: the deployment authority ------------------------------------ + + +def _assert_threaded_claim_race_has_one_winner(factory) -> None: + """Two real concurrent claims, released together by a barrier.""" + + _, job_id = _bootstrap(factory) + barrier = threading.Barrier(2) + results: dict[str, object] = {} + errors: list[BaseException] = [] + + def claim(worker_id: str) -> None: + session = factory() + try: + # Both workers resolve their candidate before either writes, then + # the barrier releases them into the real race window still holding + # it -- so both genuinely reach the compare-and-swap. + candidate = _plane().next_eligible_job_id(session) + assert candidate == job_id + plane = _PinnedCandidatePlane(candidate, lease_seconds=60) + barrier.wait(timeout=10) + results[worker_id] = plane.claim(session, worker_id=worker_id) + except BaseException as exc: # noqa: BLE001 - surfaced to the assertion + errors.append(exc) + finally: + session.close() + + threads = [threading.Thread(target=claim, args=(f"worker-{name}",)) for name in ("a", "b")] + for thread in threads: + thread.start() + for thread in threads: + thread.join(timeout=20) + + assert not errors, errors + assert all(not thread.is_alive() for thread in threads) + winners = [worker_id for worker_id, lease in results.items() if lease is not None] + assert len(winners) == 1, f"expected exactly one winner, got {winners}" + + with factory() as reader: + job = reader.get(AnalysisJob, job_id) + assert job.status == "running" + assert job.worker_id == winners[0] + assert job.attempt == 1 + + +@pytest.mark.skipif(not PG_URL, reason="set PARTHA_TEST_PG_URL to run the Postgres control-plane concurrency test") +def test_concurrent_claims_have_exactly_one_winner_on_postgres(): + """PostgreSQL is the deployment authority for this race. + + SQLite serialises writers, so its version of this race is expressed as an + explicit interleaving above. Only a real MVCC server exercises two claims + genuinely in flight at once. + """ + + engine = create_engine(PG_URL) + Base.metadata.create_all(engine) + factory = sessionmaker(bind=engine, expire_on_commit=False) + try: + _assert_threaded_claim_race_has_one_winner(factory) + finally: + engine.dispose() + + +def _wait_until(predicate, *, timeout: float) -> None: + """Poll ``predicate`` until true, failing the test if it never becomes true.""" + + deadline = datetime.now(UTC) + timedelta(seconds=timeout) + while datetime.now(UTC) < deadline: + if predicate(): + return + threading.Event().wait(0.01) + raise AssertionError("condition was never met") diff --git a/apps/backend/tests/test_analysis_job_model.py b/apps/backend/tests/test_analysis_job_model.py new file mode 100644 index 00000000..024fe3ef --- /dev/null +++ b/apps/backend/tests/test_analysis_job_model.py @@ -0,0 +1,289 @@ +"""Test analysis_job model and constraints.""" + +from datetime import UTC, datetime +from uuid import uuid4 + +import pytest +from sqlalchemy import create_engine, select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session, sessionmaker + +from app.core.database import register_sqlite_foreign_key_enforcement +from app.models import AnalysisJob, RepositoryRecord, User +from app.models.base import Base + + +@pytest.fixture() +def db(tmp_path): + """Create in-memory SQLite database with FK enforcement for tests.""" + register_sqlite_foreign_key_enforcement() + engine = create_engine(f"sqlite:///{tmp_path / 'analysis_jobs.db'}") + Base.metadata.create_all(engine) + factory = sessionmaker(bind=engine) + with factory() as session: + yield session, factory + engine.dispose() + + +def _owner(session: Session, email: str = "owner@example.com") -> User: + """Create and return a test user.""" + owner = User(id=str(uuid4()), email=email, password_hash=None) + session.add(owner) + session.commit() + return owner + + +def _repository(session: Session, owner: User) -> RepositoryRecord: + """Create and return a test repository.""" + record = RepositoryRecord( + id=str(uuid4()), + owner_id=owner.id, + name=f"repo-{uuid4().hex[:6]}", + source="upload", + source_url=None, + branch=None, + revision_kind="upload", + revision_value="sha256:" + "a" * 64, + revision_ref=None, + local_path="/stored/revision", + status="completed", + repo_metadata={}, + file_tree=[], + ) + session.add(record) + session.commit() + return record + + +def test_analysis_job_table_exists(db): + """Verify the analysis_jobs table can be created.""" + session, _ = db + # If Base.metadata.create_all succeeded, the table exists. + # Verify by querying it. + result = session.execute(select(AnalysisJob)).scalars().all() + assert result == [] + + +def test_analysis_job_basic_insert(db): + """Verify basic analysis job insertion.""" + session, _ = db + owner = _owner(session) + repo = _repository(session, owner) + + job = AnalysisJob( + id=str(uuid4()), + repository_id=repo.id, + owner_id=owner.id, + revision_kind="upload", + revision_value=repo.revision_value, + config_hash="sha256:" + "b" * 64, + status="queued", + ) + session.add(job) + session.commit() + + # Verify it was inserted + result = session.execute(select(AnalysisJob).where(AnalysisJob.id == job.id)).scalar_one() + assert result.status == "queued" + assert result.progress == 0 + assert result.attempt == 0 + assert result.max_attempts == 3 + + +def test_analysis_job_status_constraint_valid(db): + """Verify valid status values are accepted.""" + session, _ = db + owner = _owner(session) + + for status in ["queued", "running", "completed", "failed", "cancelled"]: + # Create a new repo for each status to avoid partial unique index conflicts + repo = _repository(session, owner) + job = AnalysisJob( + id=str(uuid4()), + repository_id=repo.id, + owner_id=owner.id, + revision_kind="upload", + revision_value=repo.revision_value, + config_hash="sha256:" + "b" * 64, + status=status, + ) + session.add(job) + session.commit() + + # Verify it was inserted + result = session.execute(select(AnalysisJob).where(AnalysisJob.id == job.id)).scalar_one() + assert result.status == status + + +def test_analysis_job_status_constraint_invalid(db): + """Verify invalid status values are rejected by CheckConstraint.""" + session, _ = db + owner = _owner(session) + repo = _repository(session, owner) + + job = AnalysisJob( + id=str(uuid4()), + repository_id=repo.id, + owner_id=owner.id, + revision_kind="upload", + revision_value=repo.revision_value, + config_hash="sha256:" + "b" * 64, + status="invalid_status", + ) + session.add(job) + + # CheckConstraint should reject this + with pytest.raises(IntegrityError): + session.commit() + + +def test_analysis_job_partial_unique_index_queued_duplicate_rejected(db): + """Verify partial unique index rejects duplicate queued jobs for same identity.""" + session, _ = db + owner = _owner(session) + repo = _repository(session, owner) + + config_hash = "sha256:" + "b" * 64 + + # Insert first queued job + job1 = AnalysisJob( + id=str(uuid4()), + repository_id=repo.id, + owner_id=owner.id, + revision_kind="upload", + revision_value=repo.revision_value, + config_hash=config_hash, + status="queued", + ) + session.add(job1) + session.commit() + + # Try to insert second queued job with same (repository_id, revision_value, config_hash) + job2 = AnalysisJob( + id=str(uuid4()), + repository_id=repo.id, + owner_id=owner.id, + revision_kind="upload", + revision_value=repo.revision_value, + config_hash=config_hash, + status="queued", + ) + session.add(job2) + + # Should reject due to partial unique index + with pytest.raises(IntegrityError): + session.commit() + + +def test_analysis_job_effective_identity_rejects_duplicate_completed_job(db): + """A semantic identity has at most one authoritative completed job.""" + session, _ = db + owner = _owner(session) + repo = _repository(session, owner) + + config_hash = "sha256:" + "b" * 64 + now = datetime.now(UTC) + + # Insert first completed job + job1 = AnalysisJob( + id=str(uuid4()), + repository_id=repo.id, + owner_id=owner.id, + revision_kind="upload", + revision_value=repo.revision_value, + config_hash=config_hash, + status="completed", + completed_at=now, + ) + session.add(job1) + session.commit() + + # A second completed row would make public status ambiguous. + job2 = AnalysisJob( + id=str(uuid4()), + repository_id=repo.id, + owner_id=owner.id, + revision_kind="upload", + revision_value=repo.revision_value, + config_hash=config_hash, + status="completed", + completed_at=now, + ) + session.add(job2) + with pytest.raises(IntegrityError): + session.commit() + + +def test_analysis_job_effective_identity_rejects_queued_plus_completed(db): + """Queued work cannot coexist with a completed result for one identity.""" + session, _ = db + owner = _owner(session) + repo = _repository(session, owner) + + config_hash = "sha256:" + "b" * 64 + now = datetime.now(UTC) + + # Insert queued job + job1 = AnalysisJob( + id=str(uuid4()), + repository_id=repo.id, + owner_id=owner.id, + revision_kind="upload", + revision_value=repo.revision_value, + config_hash=config_hash, + status="queued", + ) + session.add(job1) + session.commit() + + # A worker completion racing this queued row must collide atomically. + job2 = AnalysisJob( + id=str(uuid4()), + repository_id=repo.id, + owner_id=owner.id, + revision_kind="upload", + revision_value=repo.revision_value, + config_hash=config_hash, + status="completed", + completed_at=now, + ) + session.add(job2) + with pytest.raises(IntegrityError): + session.commit() + + +@pytest.mark.parametrize("terminal_status", ["failed", "cancelled"]) +def test_analysis_job_effective_identity_allows_retry_after_unsuccessful_terminal(db, terminal_status): + """Failed and cancelled history remains retryable when no result exists.""" + + session, _ = db + owner = _owner(session) + repo = _repository(session, owner) + config_hash = "sha256:" + "b" * 64 + session.add( + AnalysisJob( + id=str(uuid4()), + repository_id=repo.id, + owner_id=owner.id, + revision_kind="upload", + revision_value=repo.revision_value, + config_hash=config_hash, + status=terminal_status, + ) + ) + session.commit() + session.add( + AnalysisJob( + id=str(uuid4()), + repository_id=repo.id, + owner_id=owner.id, + revision_kind="upload", + revision_value=repo.revision_value, + config_hash=config_hash, + status="queued", + ) + ) + session.commit() + + statuses = session.scalars(select(AnalysisJob.status).where(AnalysisJob.repository_id == repo.id)).all() + assert set(statuses) == {terminal_status, "queued"} diff --git a/apps/backend/tests/test_analysis_job_service.py b/apps/backend/tests/test_analysis_job_service.py new file mode 100644 index 00000000..320819a7 --- /dev/null +++ b/apps/backend/tests/test_analysis_job_service.py @@ -0,0 +1,563 @@ +from __future__ import annotations + +import os +from uuid import uuid4 + +import pytest +from sqlalchemy import create_engine, func, select +from sqlalchemy.orm import sessionmaker + +from app.core.database import register_sqlite_foreign_key_enforcement +from app.core.exceptions import ConflictServiceError, NotFoundError +from app.intelligence.snapshot_store import Evidence, Revision, SnapshotStore +from app.models import RepositoryRecord, User +from app.models.analysis_job import AnalysisJob +from app.models.base import Base +from app.models.snapshot import RiSnapshot +from app.services.analysis_job_service import ( + ANALYSIS_CONFIG_HASH, + ANALYSIS_PRODUCER_VERSION_SET, + AnalysisJobService, +) +from app.workers.analysis_worker import AnalysisWorker, _StageContext + +UPLOAD_REVISION = "sha256:" + "b" * 64 +PG_URL = os.environ.get("PARTHA_TEST_PG_URL") + + +@pytest.fixture() +def session_factory(tmp_path): + register_sqlite_foreign_key_enforcement() + engine = create_engine(f"sqlite:///{tmp_path / 'jobs.db'}") + Base.metadata.create_all(engine) + factory = sessionmaker(bind=engine, expire_on_commit=False) + yield factory + engine.dispose() + + +def _owner(session) -> User: + owner = User(id=str(uuid4()), email=f"{uuid4().hex}@example.com", password_hash=None) + session.add(owner) + session.commit() + return owner + + +def _repository(session, owner: User) -> RepositoryRecord: + record = RepositoryRecord( + id=str(uuid4()), + owner_id=owner.id, + name="repo", + source="upload", + revision_kind="upload", + revision_value=UPLOAD_REVISION, + local_path="/x", + status="analysing", + file_tree=[], + ) + session.add(record) + session.commit() + return record + + +def _seal_minimal_snapshot(session, record: RepositoryRecord) -> RiSnapshot: + """Seal a completed snapshot carrying the exact analysis identity.""" + + store = SnapshotStore(session) + snapshot = store.begin( + repository_id=record.id, + revision=Revision(record.revision_kind, record.revision_value, record.revision_ref), + producer_version_set=ANALYSIS_PRODUCER_VERSION_SET, + config_hash=ANALYSIS_CONFIG_HASH, + ) + store.add_node( + snapshot, + node_kind="repository", + stable_key="repo:root", + evidence=[ + Evidence( + path="README.md", + start_line=1, + end_line=1, + extractor="repository-inventory", + extractor_version="1.1.0", + logical_line_count=1, + granularity="file", + ) + ], + ) + return store.seal(snapshot) + + +def test_submit_enqueues_a_queued_job(session_factory): + with session_factory() as session: + owner = _owner(session) + record = _repository(session, owner) + service = AnalysisJobService(session, owner.id) + + job = service.submit(record.id) + + assert job.status == "queued" + assert job.attempt == 0 + assert job.repository_id == record.id + assert job.config_hash == ANALYSIS_CONFIG_HASH + assert job.revision_value == UPLOAD_REVISION + + +def test_submit_is_idempotent_for_an_active_job(session_factory): + with session_factory() as session: + owner = _owner(session) + record = _repository(session, owner) + service = AnalysisJobService(session, owner.id) + + first = service.submit(record.id) + second = service.submit(record.id) + + assert first.id == second.id + with session_factory() as reader: + count = reader.scalar( + select(func.count()).select_from(AnalysisJob).where(AnalysisJob.repository_id == record.id) + ) + assert count == 1 + + +def test_concurrent_submit_race_reconciles_to_one_job(session_factory): + # Simulate two racing submits: both prechecks miss the active row, both try + # to insert, and the partial unique index rejects the loser — whose service + # must re-read and return the winner rather than error. + with session_factory() as bootstrap: + owner = _owner(bootstrap) + record = _repository(bootstrap, owner) + owner_id = owner.id + record_id = record.id + + session_a = session_factory() + session_b = session_factory() + try: + service_a = AnalysisJobService(session_a, owner_id) + service_b = AnalysisJobService(session_b, owner_id) + + record_b = service_b.repository.get_for_owner(record_id, owner_id) + # B's precheck sees no active job yet. + assert service_b._active_job(record_id, ANALYSIS_CONFIG_HASH) is None + + # A wins the race and commits its queued job. + job_a = service_a.submit(record_id) + + # B's insert now collides on the active-identity index and reconciles. + job_b = service_b._insert_queued_job(record_b, ANALYSIS_CONFIG_HASH) + assert job_b.id == job_a.id + finally: + session_a.close() + session_b.close() + + with session_factory() as reader: + count = reader.scalar( + select(func.count()).select_from(AnalysisJob).where(AnalysisJob.repository_id == record_id) + ) + assert count == 1 + + +def _assert_submit_reconciles_worker_completion(factory) -> None: + with factory() as bootstrap: + owner = _owner(bootstrap) + record = _repository(bootstrap, owner) + owner_id = owner.id + record_id = record.id + original = AnalysisJobService(bootstrap, owner_id).submit(record_id) + + submit_session = factory() + worker_session = factory() + try: + submitting = AnalysisJobService(submit_session, owner_id) + submit_record = submitting.repository.get_for_owner(record_id, owner_id) + + # 1. The submission's first completed-snapshot observation misses. + assert submitting._completed_snapshot(submit_record, UPLOAD_REVISION) is None + + # 2. The existing worker seals and completes in a separate transaction. + worker = AnalysisWorker(factory, worker_id="worker-a", lease_seconds=60) + claimed = worker._claim(worker_session) + assert claimed is not None and claimed.id == original.id + worker_record = worker_session.get(RepositoryRecord, record_id) + snapshot = _seal_minimal_snapshot(worker_session, worker_record) + claimed.snapshot_id = snapshot.snapshot_id + ctx = _StageContext(session=worker_session, job=claimed, record=worker_record) + worker._checkpoint(ctx, "preparing-architecture", 90) + worker._complete(ctx) + + # 3. The stale submission misses active work and attempts its INSERT. + assert submitting._active_job(record_id, ANALYSIS_CONFIG_HASH) is None + reconciled = submitting._insert_queued_job(submit_record, ANALYSIS_CONFIG_HASH) + + # 4. The effective-identity constraint returns the committed winner. + assert reconciled.id == original.id + assert reconciled.status == "completed" + assert submitting.status(record_id).id == original.id + assert submitting.status(record_id).status == "completed" + finally: + submit_session.close() + worker_session.close() + + with factory() as reader: + jobs = reader.scalars(select(AnalysisJob).where(AnalysisJob.repository_id == record_id)).all() + assert len(jobs) == 1 + assert jobs[0].status == "completed" + assert jobs[0].snapshot_id is not None + assert ( + reader.scalar( + select(func.count()) + .select_from(RiSnapshot) + .where( + RiSnapshot.repository_id == record_id, + RiSnapshot.state == "completed", + ) + ) + == 1 + ) + + +def test_submit_is_atomic_against_worker_completion(session_factory): + _assert_submit_reconciles_worker_completion(session_factory) + + +@pytest.mark.skipif(not PG_URL, reason="set PARTHA_TEST_PG_URL to run the Postgres concurrency test") +def test_submit_is_atomic_against_worker_completion_on_postgres(): + engine = create_engine(PG_URL) + Base.metadata.create_all(engine) + factory = sessionmaker(bind=engine, expire_on_commit=False) + try: + _assert_submit_reconciles_worker_completion(factory) + finally: + engine.dispose() + + +def test_submit_short_circuits_when_a_completed_snapshot_exists(session_factory): + with session_factory() as session: + owner = _owner(session) + record = _repository(session, owner) + snapshot = _seal_minimal_snapshot(session, record) + + before = session.scalar(select(func.count()).select_from(RiSnapshot)) + service = AnalysisJobService(session, owner.id) + job = service.submit(record.id) + + assert job.status == "completed" + assert job.snapshot_id == snapshot.snapshot_id + assert job.progress == 100 + # No new snapshot work was created. + after = session.scalar(select(func.count()).select_from(RiSnapshot)) + assert after == before == 1 + + +def test_submit_reuses_the_synthesized_completed_job(session_factory): + with session_factory() as session: + owner = _owner(session) + record = _repository(session, owner) + _seal_minimal_snapshot(session, record) + service = AnalysisJobService(session, owner.id) + + first = service.submit(record.id) + second = service.submit(record.id) + + assert first.id == second.id + count = session.scalar( + select(func.count()).select_from(AnalysisJob).where(AnalysisJob.repository_id == record.id) + ) + assert count == 1 + + +def test_concurrent_completed_snapshot_submits_reconcile_to_one_job(session_factory): + with session_factory() as bootstrap: + owner = _owner(bootstrap) + record = _repository(bootstrap, owner) + snapshot = _seal_minimal_snapshot(bootstrap, record) + owner_id = owner.id + record_id = record.id + snapshot_id = snapshot.snapshot_id + + session_a = session_factory() + session_b = session_factory() + try: + service_a = AnalysisJobService(session_a, owner_id) + service_b = AnalysisJobService(session_b, owner_id) + record_a = service_a.repository.get_for_owner(record_id, owner_id) + record_b = service_b.repository.get_for_owner(record_id, owner_id) + snapshot_a = session_a.get(RiSnapshot, snapshot_id) + snapshot_b = session_b.get(RiSnapshot, snapshot_id) + + # Both callers complete the initial lookup before either inserts. + assert service_a._job_for_snapshot(record_id, snapshot_id) is None + assert service_b._job_for_snapshot(record_id, snapshot_id) is None + + winner = service_a._insert_completed_job(record_a, snapshot_a) + reconciled = service_b._insert_completed_job(record_b, snapshot_b) + + assert reconciled.id == winner.id + finally: + session_a.close() + session_b.close() + + with session_factory() as reader: + jobs = reader.scalars(select(AnalysisJob).where(AnalysisJob.snapshot_id == snapshot_id)).all() + assert len(jobs) == 1 + assert jobs[0].status == "completed" + assert reader.scalar(select(func.count()).select_from(RiSnapshot)) == 1 + + +def test_status_returns_none_before_any_submission(session_factory): + with session_factory() as session: + owner = _owner(session) + record = _repository(session, owner) + service = AnalysisJobService(session, owner.id) + + assert service.status(record.id) is None + + +def test_status_returns_the_most_recent_job(session_factory): + with session_factory() as session: + owner = _owner(session) + record = _repository(session, owner) + service = AnalysisJobService(session, owner.id) + job = service.submit(record.id) + + assert service.status(record.id).id == job.id + + +def test_status_prefers_the_completed_snapshot_over_a_newer_failed_attempt(session_factory): + with session_factory() as session: + owner = _owner(session) + record = _repository(session, owner) + _seal_minimal_snapshot(session, record) + service = AnalysisJobService(session, owner.id) + completed = service.submit(record.id) + session.add( + AnalysisJob( + id=str(uuid4()), + repository_id=record.id, + owner_id=owner.id, + revision_kind=record.revision_kind, + revision_value=record.revision_value, + config_hash=ANALYSIS_CONFIG_HASH, + status="failed", + completed_at=completed.completed_at, + ) + ) + session.commit() + + authoritative = service.status(record.id) + + assert authoritative.id == completed.id + assert authoritative.status == "completed" + + +def test_cancel_queued_job_transitions_to_cancelled(session_factory): + with session_factory() as session: + owner = _owner(session) + record = _repository(session, owner) + service = AnalysisJobService(session, owner.id) + service.submit(record.id) + + cancelled = service.cancel(record.id) + assert cancelled.status == "cancelled" + assert cancelled.worker_id is None + assert cancelled.lease_expires_at is None + session.refresh(record) + assert record.status == "cancelled" + assert record.analysis_stage is None + assert record.analysis_progress == 0 + + +def test_cancelled_job_can_be_restarted_without_reupload(session_factory): + with session_factory() as session: + owner = _owner(session) + record = _repository(session, owner) + service = AnalysisJobService(session, owner.id) + service.submit(record.id) + cancelled = service.cancel(record.id) + + restarted = service.submit(record.id) + + assert cancelled.status == "cancelled" + assert restarted.status == "queued" + assert restarted.id != cancelled.id + session.refresh(record) + assert record.status == "analysing" + assert record.analysis_progress == 0 + + +def test_cancel_running_job_sets_cancel_requested(session_factory): + with session_factory() as session: + owner = _owner(session) + record = _repository(session, owner) + service = AnalysisJobService(session, owner.id) + job = service.submit(record.id) + job.status = "running" + session.commit() + + result = service.cancel(record.id) + assert result.status == "running" + assert result.cancel_requested is True + + +def test_cancel_reconciles_a_queued_to_running_claim_race(session_factory): + with session_factory() as bootstrap: + owner = _owner(bootstrap) + record = _repository(bootstrap, owner) + owner_id = owner.id + record_id = record.id + AnalysisJobService(bootstrap, owner_id).submit(record_id) + + cancel_session = session_factory() + worker_session = session_factory() + try: + service = AnalysisJobService(cancel_session, owner_id) + stale_queued = service._latest_job(record_id) + assert stale_queued.status == "queued" + + worker = AnalysisWorker(session_factory, worker_id="worker-a", lease_seconds=60) + claimed = worker._claim(worker_session) + assert claimed is not None + assert claimed.worker_id == "worker-a" + + cancellation = service._cancel_job(stale_queued) + assert cancellation.status == "running" + assert cancellation.cancel_requested is True + assert cancellation.worker_id == "worker-a" + assert cancellation.lease_expires_at is not None + + list(worker._execute_stages(worker_session, claimed)) + finally: + cancel_session.close() + worker_session.close() + + with session_factory() as reader: + job = reader.scalars(select(AnalysisJob).where(AnalysisJob.repository_id == record_id)).one() + assert job.status == "cancelled" + assert job.worker_id is None + assert job.lease_expires_at is None + assert reader.scalar(select(func.count()).select_from(RiSnapshot)) == 0 + + +def test_cancel_does_not_overwrite_a_worker_completion_race(session_factory): + with session_factory() as bootstrap: + owner = _owner(bootstrap) + record = _repository(bootstrap, owner) + owner_id = owner.id + record_id = record.id + AnalysisJobService(bootstrap, owner_id).submit(record_id) + + cancel_session = session_factory() + worker_session = session_factory() + try: + worker = AnalysisWorker(session_factory, worker_id="worker-a", lease_seconds=60) + claimed = worker._claim(worker_session) + assert claimed is not None + + service = AnalysisJobService(cancel_session, owner_id) + stale_running = service._latest_job(record_id) + assert stale_running.status == "running" + + worker._complete( + _StageContext( + session=worker_session, + job=claimed, + record=worker_session.get(RepositoryRecord, record_id), + ) + ) + + with pytest.raises(ConflictServiceError): + service._cancel_job(stale_running) + finally: + cancel_session.close() + worker_session.close() + + with session_factory() as reader: + job = reader.scalars(select(AnalysisJob).where(AnalysisJob.repository_id == record_id)).one() + assert job.status == "completed" + assert job.cancel_requested is False + assert job.worker_id is None + assert job.lease_expires_at is None + + +def test_accepted_cancel_wins_after_the_workers_final_cancel_check(session_factory): + with session_factory() as bootstrap: + owner = _owner(bootstrap) + record = _repository(bootstrap, owner) + owner_id = owner.id + record_id = record.id + AnalysisJobService(bootstrap, owner_id).submit(record_id) + + worker_session = session_factory() + cancel_session = session_factory() + try: + worker = AnalysisWorker(session_factory, worker_id="worker-a", lease_seconds=60) + claimed = worker._claim(worker_session) + assert claimed is not None + ctx = _StageContext( + session=worker_session, + job=claimed, + record=worker_session.get(RepositoryRecord, record_id), + ) + + # Exact ordering: the worker's final cooperative read sees false, then + # cancellation commits before the completion CAS executes. + assert worker._cancel_requested(ctx) is False + cancellation = AnalysisJobService(cancel_session, owner_id).cancel(record_id) + assert cancellation.status == "running" + assert cancellation.cancel_requested is True + + worker._complete(ctx) + finally: + worker_session.close() + cancel_session.close() + + with session_factory() as reader: + job = reader.scalars(select(AnalysisJob).where(AnalysisJob.repository_id == record_id)).one() + assert job.status == "cancelled" + assert job.completed_at is not None + assert job.worker_id is None + assert job.lease_expires_at is None + assert reader.scalar(select(func.count()).select_from(RiSnapshot)) == 0 + + +def test_cancel_terminal_job_conflicts(session_factory): + with session_factory() as session: + owner = _owner(session) + record = _repository(session, owner) + service = AnalysisJobService(session, owner.id) + job = service.submit(record.id) + job.status = "completed" + session.commit() + + with pytest.raises(ConflictServiceError): + service.cancel(record.id) + + +def test_cancel_without_a_job_conflicts(session_factory): + with session_factory() as session: + owner = _owner(session) + record = _repository(session, owner) + service = AnalysisJobService(session, owner.id) + + with pytest.raises(ConflictServiceError): + service.cancel(record.id) + + +def test_submit_unknown_repository_is_not_found(session_factory): + with session_factory() as session: + owner = _owner(session) + service = AnalysisJobService(session, owner.id) + + with pytest.raises(NotFoundError): + service.submit(str(uuid4())) + + +def test_submit_cross_owner_repository_is_not_found(session_factory): + with session_factory() as session: + owner = _owner(session) + record = _repository(session, owner) + other = _owner(session) + service = AnalysisJobService(session, other.id) + + with pytest.raises(NotFoundError): + service.submit(record.id) diff --git a/apps/backend/tests/test_analysis_resource_budget.py b/apps/backend/tests/test_analysis_resource_budget.py new file mode 100644 index 00000000..955d5e13 --- /dev/null +++ b/apps/backend/tests/test_analysis_resource_budget.py @@ -0,0 +1,178 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from app.analysis.resource_budget import AnalysisResourceBudget, AnalysisResourceExceeded +from app.analysis.source_stream import RepositorySourceStream, UnsafeSourcePath + + +def _budget( + *, + source_bytes: int = 1024, + rss_bytes: int = 1024, + seconds: float = 60, + rss_reader=lambda: 1, + monotonic=lambda: 0.0, +) -> AnalysisResourceBudget: + return AnalysisResourceBudget( + max_source_bytes=source_bytes, + max_rss_bytes=rss_bytes, + max_seconds=seconds, + rss_reader=rss_reader, + monotonic=monotonic, + ) + + +def test_source_budget_fails_before_accepting_an_oversized_repository(): + budget = _budget(source_bytes=5) + + budget.charge_source(3) + + with pytest.raises(AnalysisResourceExceeded, match="source_bytes budget exceeded") as caught: + budget.charge_source(3) + assert caught.value.resource == "source_bytes" + assert budget.source_bytes == 3 + + +def test_rss_and_elapsed_budgets_track_the_observed_high_watermark(): + rss_values = iter((10, 80)) + budget = _budget( + rss_bytes=50, + seconds=10, + rss_reader=lambda: next(rss_values), + ) + + budget.check() + with pytest.raises(AnalysisResourceExceeded, match="rss_bytes budget exceeded"): + budget.check() + assert budget.peak_rss_bytes == 80 + + +def test_elapsed_budget_is_fail_closed(): + times = iter((0.0, 11.0)) + budget = _budget(seconds=10, monotonic=lambda: next(times)) + + with pytest.raises(AnalysisResourceExceeded, match="elapsed_seconds budget exceeded"): + budget.check() + + +@pytest.mark.parametrize("observed_rss", (10, 20, 30), ids=("small", "medium", "large")) +def test_sized_fixture_rss_samples_stay_within_the_enforced_budget(observed_rss: int): + budget = _budget(rss_bytes=40, rss_reader=lambda: observed_rss) + + budget.check() + + assert budget.peak_rss_bytes == observed_rss + + +def test_independent_budgets_do_not_cross_contaminate(): + # Two separate analysis runs must never share charged state -- a shared + # counter here would mean one repository's byte usage silently eats into + # another's budget headroom. + first = _budget(source_bytes=10) + second = _budget(source_bytes=10) + + first.charge_source(9) + + assert first.source_bytes == 9 + assert second.source_bytes == 0 + # The second budget's own headroom is untouched by the first's charge. + second.charge_source(9) + assert second.source_bytes == 9 + + +def test_repository_source_stream_is_sorted_bounded_and_charged(tmp_path: Path): + (tmp_path / "z.py").write_bytes(b"z = 1\n") + (tmp_path / "a.py").write_bytes(b"a = 1\n") + tree = [ + {"type": "file", "path": "z.py"}, + {"type": "file", "path": "/a.py"}, + {"type": "file", "path": "a.py"}, + ] + budget = _budget(source_bytes=100) + + sources = list( + RepositorySourceStream( + root=tmp_path, + file_tree=tree, + max_file_bytes=4, + budget=budget, + ) + ) + + assert sources == [("a.py", b"a = 1"), ("z.py", b"z = 1")] + assert budget.source_bytes == 12 + + +def test_repository_source_stream_rejects_symlinks(tmp_path: Path): + target = tmp_path / "target.py" + target.write_bytes(b"x = 1\n") + link = tmp_path / "link.py" + try: + link.symlink_to(target) + except OSError: + pytest.skip("creating symlinks is not permitted on this platform") + + stream = RepositorySourceStream( + root=tmp_path, + file_tree=[{"type": "file", "path": "link.py"}], + max_file_bytes=512, + budget=_budget(), + ) + + with pytest.raises(UnsafeSourcePath, match="traverses a symlink"): + list(stream) + + +def test_repository_source_stream_rejects_manifest_path_escape(tmp_path: Path): + stream = RepositorySourceStream( + root=tmp_path, + file_tree=[{"type": "file", "path": "../outside.py"}], + max_file_bytes=512, + budget=_budget(), + ) + + with pytest.raises(UnsafeSourcePath, match="escapes the repository root"): + list(stream) + + +def test_repository_source_stream_rejects_non_regular_files(tmp_path: Path): + # A directory is not a symlink, so it reaches the post-open fstat check + # rather than the symlink-component check -- and unlike a FIFO or a + # socket special file, opening a directory O_RDONLY is universally safe + # and never blocks, so this exercises the stat.S_ISREG rejection directly + # without any platform-specific setup or risk of hanging the test. + (tmp_path / "not-a-file").mkdir() + + stream = RepositorySourceStream( + root=tmp_path, + file_tree=[{"type": "file", "path": "not-a-file"}], + max_file_bytes=512, + budget=_budget(), + ) + + with pytest.raises(UnsafeSourcePath, match="is not a regular file"): + list(stream) + + +def test_repository_source_stream_skips_files_removed_after_ingestion(tmp_path: Path): + (tmp_path / "kept.py").write_bytes(b"kept = 1\n") + removed_path = tmp_path / "removed.py" + removed_path.write_bytes(b"removed = 1\n") + tree = [ + {"type": "file", "path": "kept.py"}, + {"type": "file", "path": "removed.py"}, + ] + budget = _budget() + stream = RepositorySourceStream(root=tmp_path, file_tree=tree, max_file_bytes=512, budget=budget) + + # The manifest still names removed.py -- the record was ingested, then the + # stored file vanished before this analysis run read it. That must be + # skipped silently, not raised, per the stream's existing policy. + removed_path.unlink() + + sources = list(stream) + + assert sources == [("kept.py", b"kept = 1\n")] diff --git a/apps/backend/tests/test_analysis_worker.py b/apps/backend/tests/test_analysis_worker.py new file mode 100644 index 00000000..9325c6f5 --- /dev/null +++ b/apps/backend/tests/test_analysis_worker.py @@ -0,0 +1,925 @@ +from __future__ import annotations + +import threading +import time +from datetime import UTC, datetime, timedelta +from pathlib import Path +from uuid import uuid4 + +import pytest +from sqlalchemy import create_engine, func, select, update +from sqlalchemy.orm import sessionmaker + +from app.core.database import register_sqlite_foreign_key_enforcement +from app.intelligence.query_service import SnapshotQueryService +from app.intelligence.snapshot_store import Evidence, Revision, SnapshotStore +from app.models import RepositoryRecord, User +from app.models.analysis_job import AnalysisJob +from app.models.base import Base +from app.models.snapshot import RiSnapshot +from app.schemas.repository import RepositoryMeta +from app.services.analysis_job_service import ( + ANALYSIS_CONFIG_HASH, + ANALYSIS_PRODUCER_VERSION_SET, + AnalysisJobService, +) +from app.workers.analysis_worker import AnalysisWorker, _HeartbeatState + +UPLOAD_REVISION = "sha256:" + "d" * 64 + + +@pytest.fixture() +def session_factory(tmp_path): + register_sqlite_foreign_key_enforcement() + engine = create_engine(f"sqlite:///{tmp_path / 'worker.db'}") + Base.metadata.create_all(engine) + factory = sessionmaker(bind=engine, expire_on_commit=False) + yield factory + engine.dispose() + + +def _owner(session) -> User: + owner = User(id=str(uuid4()), email=f"{uuid4().hex}@example.com", password_hash=None) + session.add(owner) + session.commit() + return owner + + +def _meta() -> dict: + return RepositoryMeta( + language="Python", + framework="FastAPI", + total_files=2, + total_folders=1, + entry_point="app/main.py", + config_files=[], + package_manager=None, + has_readme=True, + has_license=False, + license_name=None, + ).model_dump(mode="json", by_alias=True) + + +def _repository_with_sources(session, owner: User, root: Path) -> RepositoryRecord: + """Create a repository whose files exist on disk, ready for real extraction.""" + + root.mkdir(parents=True, exist_ok=True) + (root / "README.md").write_text("# Worker fixture\n", encoding="utf-8") + (root / "app").mkdir(exist_ok=True) + (root / "app" / "main.py").write_text("def handler():\n return 1\n", encoding="utf-8") + + file_tree = [ + {"id": "1", "name": "README.md", "type": "file", "path": "README.md", "size": 18, "extension": "md"}, + { + "id": "2", + "name": "app", + "type": "folder", + "path": "app", + "children": [ + { + "id": "3", + "name": "main.py", + "type": "file", + "path": "app/main.py", + "size": 28, + "extension": "py", + "language": "python", + } + ], + }, + ] + record = RepositoryRecord( + id=str(uuid4()), + owner_id=owner.id, + name="repo", + source="upload", + revision_kind="upload", + revision_value=UPLOAD_REVISION, + local_path=str(root), + status="analysing", + file_tree=file_tree, + repo_metadata=_meta(), + ) + session.add(record) + session.commit() + return record + + +def _seal_analysis_snapshot(session, record: RepositoryRecord) -> RiSnapshot: + """Seal a completed snapshot carrying the exact analysis semantic identity.""" + + store = SnapshotStore(session) + snapshot = store.begin( + repository_id=record.id, + revision=Revision(record.revision_kind, record.revision_value, record.revision_ref), + producer_version_set=ANALYSIS_PRODUCER_VERSION_SET, + config_hash=ANALYSIS_CONFIG_HASH, + ) + store.add_node( + snapshot, + node_kind="repository", + stable_key="repo:root", + evidence=[ + Evidence( + path="README.md", + start_line=1, + end_line=1, + extractor="repository-inventory", + extractor_version="1.1.0", + logical_line_count=1, + granularity="file", + ) + ], + ) + return store.seal(snapshot) + + +def _building_analysis_snapshot(session, record: RepositoryRecord) -> RiSnapshot: + store = SnapshotStore(session) + snapshot = store.begin( + repository_id=record.id, + revision=Revision(record.revision_kind, record.revision_value, record.revision_ref), + producer_version_set=ANALYSIS_PRODUCER_VERSION_SET, + config_hash=ANALYSIS_CONFIG_HASH, + ) + session.commit() + return snapshot + + +def _claim_with_snapshot( + session_factory, + tmp_path: Path, + now: list[datetime], + *, + max_attempts: int = 3, + completed_snapshot: bool = False, +) -> tuple[AnalysisWorker, str, str]: + with session_factory() as session: + owner = _owner(session) + record = _repository_with_sources(session, owner, tmp_path / str(uuid4())) + snapshot = ( + _seal_analysis_snapshot(session, record) + if completed_snapshot + else _building_analysis_snapshot(session, record) + ) + job = AnalysisJobService(session, owner.id).submit(record.id) + # submit short-circuits to completed when the snapshot is already sealed; + # force the crash-window state that existed before the completion commit. + job.status = "queued" + job.snapshot_id = snapshot.snapshot_id + job.max_attempts = max_attempts + session.commit() + record_id = record.id + snapshot_id = snapshot.snapshot_id + + worker = AnalysisWorker( + session_factory, + worker_id="stale-worker", + lease_seconds=60, + clock=lambda: now[0], + ) + with session_factory() as session: + assert worker._claim(session) is not None + return worker, record_id, snapshot_id + + +def _wait_for(predicate, *, timeout: float = 3.0) -> None: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if predicate(): + return + time.sleep(0.01) + raise AssertionError("condition was not met before timeout") + + +def test_run_once_returns_false_when_queue_is_empty(session_factory): + worker = AnalysisWorker(session_factory, worker_id="w", lease_seconds=60) + assert worker.run_once() is False + + +def test_run_once_executes_end_to_end_and_seals_a_readable_snapshot(session_factory, tmp_path): + with session_factory() as session: + owner = _owner(session) + record = _repository_with_sources(session, owner, tmp_path / "repo") + owner_id = owner.id + record_id = record.id + AnalysisJobService(session, owner_id).submit(record_id) + + worker = AnalysisWorker(session_factory, worker_id="w", lease_seconds=60) + assert worker.run_once() is True + # Queue is now drained. + assert worker.run_once() is False + + with session_factory() as session: + job = session.scalars(select(AnalysisJob).where(AnalysisJob.repository_id == record_id)).one() + assert job.status == "completed" + assert job.progress == 100 + assert job.stage == "completed" + assert job.snapshot_id is not None + assert job.completed_at is not None + assert job.worker_id is None + + record = session.get(RepositoryRecord, record_id) + assert record.status == "completed" + assert record.analysis_stage == "completed" + assert record.analysis_progress == 100 + assert record.analysed_at is not None + + # The whole point of #93: a real product-ingested snapshot now exists and + # is readable through the owner-scoped query service. + query = SnapshotQueryService(session, owner_id) + snapshot = query.metadata(job.snapshot_id) + assert snapshot.state == "completed" + _, symbols, total = query.symbols(job.snapshot_id, offset=0, limit=50) + assert total >= 1 + assert any(node.name == "handler" for node in symbols) + + +def test_run_once_reuses_an_already_sealed_snapshot(session_factory, tmp_path): + with session_factory() as session: + owner = _owner(session) + record = _repository_with_sources(session, owner, tmp_path / "repo") + snapshot = _seal_analysis_snapshot(session, record) + reused_id = snapshot.snapshot_id + record_id = record.id + # Insert a queued job directly for the same identity (bypassing submit's + # completed-snapshot short-circuit) to exercise the worker's reuse branch. + session.add( + AnalysisJob( + id=str(uuid4()), + repository_id=record.id, + owner_id=owner.id, + revision_kind=record.revision_kind, + revision_value=record.revision_value, + config_hash=ANALYSIS_CONFIG_HASH, + status="queued", + attempt=0, + ) + ) + session.commit() + before = session.scalar(select(func.count()).select_from(RiSnapshot)) + + worker = AnalysisWorker( + session_factory, + worker_id="w", + lease_seconds=60, + max_process_rss_bytes=1, + rss_reader=lambda: 2, + ) + assert worker.run_once() is True + + with session_factory() as session: + job = session.scalars(select(AnalysisJob).where(AnalysisJob.repository_id == record_id)).one() + assert job.status == "completed" + assert job.snapshot_id == reused_id + # No new snapshot work was created. + after = session.scalar(select(func.count()).select_from(RiSnapshot)) + assert after == before == 1 + + +def test_bounded_retry_requeues_with_backoff_then_fails(session_factory, tmp_path, monkeypatch): + with session_factory() as session: + owner = _owner(session) + record = _repository_with_sources(session, owner, tmp_path / "repo") + record_id = record.id + AnalysisJobService(session, owner.id).submit(record_id) + + now = [datetime(2026, 1, 1, tzinfo=UTC)] + worker = AnalysisWorker(session_factory, worker_id="w", lease_seconds=60, clock=lambda: now[0]) + + def _boom(ctx): + raise RuntimeError("stage exploded") + + monkeypatch.setattr(worker, "_stage_open_snapshot", _boom) + + # Attempt 1: fails, re-queued with a future next_attempt_at. + assert worker.run_once() is True + with session_factory() as session: + job = session.scalars(select(AnalysisJob).where(AnalysisJob.repository_id == record_id)).one() + assert job.status == "queued" + assert job.attempt == 1 + assert job.next_attempt_at is not None + assert job.error_message == "stage exploded" + + # The backoff is in the future, so the job is not yet claimable. + assert worker.run_once() is False + + # Advance past the backoff: attempt 2 fails and re-queues again. + now[0] = now[0] + timedelta(seconds=120) + assert worker.run_once() is True + with session_factory() as session: + job = session.scalars(select(AnalysisJob).where(AnalysisJob.repository_id == record_id)).one() + assert job.status == "queued" + assert job.attempt == 2 + + # Attempt 3 reaches max_attempts (default 3): terminal failure, never retries again. + now[0] = now[0] + timedelta(seconds=120) + assert worker.run_once() is True + with session_factory() as session: + job = session.scalars(select(AnalysisJob).where(AnalysisJob.repository_id == record_id)).one() + assert job.status == "failed" + assert job.attempt == 3 + assert job.error_message == "stage exploded" + record = session.get(RepositoryRecord, record_id) + assert record.status == "error" + assert record.error_message == "Repository analysis failed." + + now[0] = now[0] + timedelta(seconds=120) + assert worker.run_once() is False + + +def test_retry_fails_the_first_snapshot_before_the_next_attempt_succeeds(session_factory, tmp_path, monkeypatch): + with session_factory() as session: + owner = _owner(session) + record = _repository_with_sources(session, owner, tmp_path / "repo") + record_id = record.id + AnalysisJobService(session, owner.id).submit(record_id) + + now = [datetime(2026, 1, 1, tzinfo=UTC)] + worker = AnalysisWorker( + session_factory, + worker_id="retry-worker", + lease_seconds=60, + clock=lambda: now[0], + ) + original_extract = worker._stage_extract + failed_once = False + + def _fail_first_extraction(ctx): + nonlocal failed_once + if not failed_once: + failed_once = True + raise RuntimeError("extraction failed after snapshot open") + original_extract(ctx) + + monkeypatch.setattr(worker, "_stage_extract", _fail_first_extraction) + + assert worker.run_once() is True + with session_factory() as session: + job = session.scalars(select(AnalysisJob).where(AnalysisJob.repository_id == record_id)).one() + first_snapshot_id = job.snapshot_id + assert job.status == "queued" + assert job.attempt == 1 + assert first_snapshot_id is not None + assert session.get(RiSnapshot, first_snapshot_id).state == "failed" + + now[0] += timedelta(seconds=3) + assert worker.run_once() is True + + with session_factory() as session: + job = session.scalars(select(AnalysisJob).where(AnalysisJob.repository_id == record_id)).one() + assert job.status == "completed" + assert job.attempt == 2 + assert job.snapshot_id is not None + assert job.snapshot_id != first_snapshot_id + assert session.get(RiSnapshot, first_snapshot_id).state == "failed" + assert session.get(RiSnapshot, job.snapshot_id).state == "completed" + assert session.scalar(select(func.count()).select_from(RiSnapshot).where(RiSnapshot.state == "building")) == 0 + assert session.scalar(select(func.count()).select_from(RiSnapshot).where(RiSnapshot.state == "completed")) == 1 + + +def test_cooperative_cancellation_fails_open_snapshot_and_cancels_job(session_factory, tmp_path): + session = session_factory() + try: + owner = _owner(session) + record = _repository_with_sources(session, owner, tmp_path / "repo") + service = AnalysisJobService(session, owner.id) + service.submit(record.id) + + worker = AnalysisWorker(session_factory, worker_id="w", lease_seconds=60) + job = worker._claim(session) + assert job is not None + + stages = worker._execute_stages(session, job) + next(stages) # legacy stage completes + next(stages) # snapshot opened (building) — id recorded before sealing + snapshot_id = job.snapshot_id + assert snapshot_id is not None + assert session.get(RiSnapshot, snapshot_id).state == "building" + + # Cancel mid-run: the running job is flagged, and the worker observes it + # at the next stage boundary. + service.cancel(record.id) + + list(stages) # drain remaining boundaries; the cancel is honoured + + session.expire_all() + cancelled = session.get(AnalysisJob, job.id) + assert cancelled.status == "cancelled" + assert cancelled.completed_at is not None + # The opened snapshot was failed, never sealed to completed. + assert session.get(RiSnapshot, snapshot_id).state == "failed" + session.refresh(record) + assert record.status == "cancelled" + assert record.analysis_stage is None + assert record.analysis_progress == 0 + finally: + session.close() + + +def test_cancellation_after_snapshot_seal_is_immediately_completed(session_factory, tmp_path): + session = session_factory() + try: + owner = _owner(session) + record = _repository_with_sources(session, owner, tmp_path / "repo") + service = AnalysisJobService(session, owner.id) + submitted = service.submit(record.id) + + worker = AnalysisWorker(session_factory, worker_id="w", lease_seconds=60) + job = worker._claim(session) + assert job is not None + assert job.id == submitted.id + + stages = worker._execute_stages(session, job) + next(stages) # snapshot opened + next(stages) # extraction + next(stages) # snapshot sealed, job still running + snapshot_id = job.snapshot_id + assert snapshot_id is not None + assert session.get(RiSnapshot, snapshot_id).state == "completed" + assert session.get(AnalysisJob, job.id).status == "running" + + service.cancel(record.id) + list(stages) + session.expire_all() + completed = session.get(AnalysisJob, job.id) + assert completed.status == "completed" + assert completed.cancel_requested is False + assert session.get(RiSnapshot, snapshot_id).state == "completed" + assert service.status(record.id).status == "completed" + assert completed.stage == "completed" + assert completed.progress == 100 + assert completed.snapshot_id == snapshot_id + assert completed.worker_id is None + assert completed.lease_expires_at is None + assert completed.completed_at is not None + assert session.scalar(select(func.count()).select_from(RiSnapshot).where(RiSnapshot.state == "completed")) == 1 + assert session.scalar(select(func.count()).select_from(AnalysisJob)) == 1 + session.refresh(record) + assert record.status == "completed" + assert record.analysis_stage == "completed" + assert record.analysis_progress == 100 + assert record.analysed_at is not None + finally: + session.close() + + +def test_heartbeat_keeps_a_long_stage_from_being_reclaimed(session_factory, tmp_path, monkeypatch): + now = [datetime(2026, 1, 1, tzinfo=UTC)] + with session_factory() as session: + owner = _owner(session) + record = _repository_with_sources(session, owner, tmp_path / "repo") + record_id = record.id + AnalysisJobService(session, owner.id).submit(record_id) + + entered = threading.Event() + release = threading.Event() + worker = AnalysisWorker( + session_factory, + worker_id="healthy-worker", + lease_seconds=1, + clock=lambda: now[0], + heartbeat_interval_seconds=0.02, + ) + + original_open_snapshot = worker._stage_open_snapshot + + def _long_stage(ctx): + entered.set() + assert release.wait(timeout=3) + original_open_snapshot(ctx) + + monkeypatch.setattr(worker, "_stage_open_snapshot", _long_stage) + thread = threading.Thread(target=worker.run_once) + thread.start() + assert entered.wait(timeout=3) + + with session_factory() as reader: + original_lease = reader.scalars( + select(AnalysisJob.lease_expires_at).where(AnalysisJob.repository_id == record_id) + ).one() + + now[0] += timedelta(seconds=2) + + def _lease_was_renewed() -> bool: + with session_factory() as reader: + lease = reader.scalars( + select(AnalysisJob.lease_expires_at).where(AnalysisJob.repository_id == record_id) + ).one() + return lease is not None and lease > original_lease and lease > now[0].replace(tzinfo=None) + + _wait_for(_lease_was_renewed) + sweeper = AnalysisWorker( + session_factory, + worker_id="sweeper", + lease_seconds=1, + clock=lambda: now[0], + ) + assert sweeper.sweep_stale() == 0 + + release.set() + thread.join(timeout=5) + assert not thread.is_alive() + assert not any( + candidate.name.startswith("analysis-heartbeat-") and candidate.is_alive() for candidate in threading.enumerate() + ) + + with session_factory() as reader: + job = reader.scalars(select(AnalysisJob).where(AnalysisJob.repository_id == record_id)).one() + assert job.status == "completed" + assert job.attempt == 1 + + +def test_sqlite_write_lock_does_not_leave_a_heartbeat_thread_running(session_factory, tmp_path): + with session_factory() as session: + owner = _owner(session) + record = _repository_with_sources(session, owner, tmp_path / "repo") + record_id = record.id + AnalysisJobService(session, owner.id).submit(record_id) + + worker = AnalysisWorker( + session_factory, + worker_id="worker-a", + lease_seconds=60, + heartbeat_interval_seconds=0.02, + ) + with session_factory() as claim_session: + lease = worker.control_plane.claim(claim_session, worker_id="worker-a") + assert lease is not None + + blocker = session_factory() + try: + blocker.execute( + update(RepositoryRecord).where(RepositoryRecord.id == record_id).values(updated_at=datetime.now(UTC)) + ) + state = _HeartbeatState() + thread = threading.Thread(target=worker._heartbeat_once, args=(lease, state)) + thread.start() + thread.join(timeout=1) + + assert not thread.is_alive() + assert not state.ownership_lost.is_set() + assert not state.cancel_requested.is_set() + assert state.failure is None + finally: + blocker.rollback() + blocker.close() + + +def test_heartbeat_ownership_loss_abandons_stage_writes(session_factory, tmp_path, monkeypatch): + with session_factory() as session: + owner = _owner(session) + record = _repository_with_sources(session, owner, tmp_path / "repo") + record_id = record.id + AnalysisJobService(session, owner.id).submit(record_id) + + entered = threading.Event() + release = threading.Event() + ownership_lost = threading.Event() + worker = AnalysisWorker( + session_factory, + worker_id="worker-a", + lease_seconds=60, + heartbeat_interval_seconds=0.02, + ) + original_heartbeat = worker._heartbeat_once + + def _observe_heartbeat(job_id, state): + original_heartbeat(job_id, state) + if state.ownership_lost.is_set(): + ownership_lost.set() + + def _long_stage(_ctx): + entered.set() + assert release.wait(timeout=3) + + monkeypatch.setattr(worker, "_heartbeat_once", _observe_heartbeat) + monkeypatch.setattr(worker, "_stage_open_snapshot", _long_stage) + thread = threading.Thread(target=worker.run_once) + thread.start() + assert entered.wait(timeout=3) + + with session_factory() as replacement: + replacement.execute( + update(AnalysisJob) + .where(AnalysisJob.repository_id == record_id, AnalysisJob.status == "running") + .values(worker_id="worker-b") + ) + replacement.commit() + + assert ownership_lost.wait(timeout=3) + release.set() + thread.join(timeout=5) + assert not thread.is_alive() + + with session_factory() as reader: + job = reader.scalars(select(AnalysisJob).where(AnalysisJob.repository_id == record_id)).one() + assert job.status == "running" + assert job.worker_id == "worker-b" + assert reader.scalar(select(func.count()).select_from(RiSnapshot)) == 0 + + +def test_cancellation_interrupts_in_flight_snapshot_extraction(session_factory, tmp_path, monkeypatch): + with session_factory() as session: + owner = _owner(session) + record = _repository_with_sources(session, owner, tmp_path / "repo") + owner_id = owner.id + record_id = record.id + AnalysisJobService(session, owner_id).submit(record_id) + + entered = threading.Event() + worker = AnalysisWorker( + session_factory, + worker_id="worker-a", + lease_seconds=60, + heartbeat_interval_seconds=0.02, + ) + + class _InterruptibleSourceStream: + def __init__(self, **kwargs): + self.check_cancelled = kwargs["check_cancelled"] + + def __iter__(self): + entered.set() + while True: + self.check_cancelled() + time.sleep(0.005) + yield # pragma: no cover - makes this an iterator + + monkeypatch.setattr("app.workers.analysis_worker.RepositorySourceStream", _InterruptibleSourceStream) + thread = threading.Thread(target=worker.run_once) + thread.start() + assert entered.wait(timeout=3) + + with session_factory() as cancel_session: + accepted = AnalysisJobService(cancel_session, owner_id).cancel(record_id) + assert accepted.cancel_requested is True + + thread.join(timeout=5) + assert not thread.is_alive() + + with session_factory() as reader: + job = reader.scalars(select(AnalysisJob).where(AnalysisJob.repository_id == record_id)).one() + assert job.status == "cancelled" + assert reader.get(RepositoryRecord, record_id).status == "cancelled" + snapshots = list(reader.scalars(select(RiSnapshot))) + assert len(snapshots) == 1 + assert snapshots[0].state == "failed" + + +def test_repository_byte_budget_is_terminal_and_never_seals(session_factory, tmp_path): + with session_factory() as session: + owner = _owner(session) + record = _repository_with_sources(session, owner, tmp_path / "repo") + record_id = record.id + AnalysisJobService(session, owner.id).submit(record_id) + + worker = AnalysisWorker( + session_factory, + worker_id="worker-a", + lease_seconds=60, + max_repository_source_bytes=1, + rss_reader=lambda: 1, + ) + + assert worker.run_once() is True + + with session_factory() as session: + job = session.scalars(select(AnalysisJob).where(AnalysisJob.repository_id == record_id)).one() + assert job.status == "failed" + assert job.attempt == 1 + assert job.error_code == "resource_exceeded" + assert "source_bytes budget exceeded" in (job.error_message or "") + snapshot = session.get(RiSnapshot, job.snapshot_id) + assert snapshot is not None + assert snapshot.state == "failed" + assert snapshot.failure_code == "resource_exceeded" + assert session.scalar(select(func.count()).select_from(RiSnapshot).where(RiSnapshot.state == "completed")) == 0 + + +def test_process_rss_budget_fails_before_repository_work(session_factory, tmp_path): + with session_factory() as session: + owner = _owner(session) + record = _repository_with_sources(session, owner, tmp_path / "repo") + record_id = record.id + AnalysisJobService(session, owner.id).submit(record_id) + + worker = AnalysisWorker( + session_factory, + worker_id="worker-a", + lease_seconds=60, + max_process_rss_bytes=10, + rss_reader=lambda: 11, + ) + + assert worker.run_once() is True + + with session_factory() as session: + job = session.scalars(select(AnalysisJob).where(AnalysisJob.repository_id == record_id)).one() + assert job.status == "failed" + assert job.attempt == 1 + assert job.error_code == "resource_exceeded" + assert "rss_bytes budget exceeded" in (job.error_message or "") + assert job.snapshot_id is None + + +def test_sweep_stale_requeues_and_fails_orphaned_building_snapshot(session_factory, tmp_path): + now = [datetime(2026, 1, 1, tzinfo=UTC)] + worker, record_id, snapshot_id = _claim_with_snapshot(session_factory, tmp_path, now) + + assert worker.sweep_stale() == 0 + now[0] += timedelta(seconds=61) + assert worker.sweep_stale() == 1 + + with session_factory() as session: + job = session.scalars(select(AnalysisJob).where(AnalysisJob.repository_id == record_id)).one() + assert job.status == "queued" + assert job.worker_id is None + assert job.lease_expires_at is None + assert job.next_attempt_at is not None + assert job.error_code == "RI-JOB-STALE" + assert session.get(RiSnapshot, snapshot_id).state == "failed" + record = session.get(RepositoryRecord, record_id) + assert record.status == "analysing" + assert record.analysis_progress == 0 + + +def test_sweep_stale_fails_job_at_attempt_limit(session_factory, tmp_path): + now = [datetime(2026, 1, 1, tzinfo=UTC)] + worker, record_id, snapshot_id = _claim_with_snapshot(session_factory, tmp_path, now, max_attempts=1) + + now[0] += timedelta(seconds=61) + assert worker.sweep_stale() == 1 + + with session_factory() as session: + job = session.scalars(select(AnalysisJob).where(AnalysisJob.repository_id == record_id)).one() + assert job.status == "failed" + assert job.completed_at is not None + assert job.next_attempt_at is None + assert session.get(RiSnapshot, snapshot_id).state == "failed" + record = session.get(RepositoryRecord, record_id) + assert record.status == "error" + assert "lease expired" in record.error_message + + +def test_sweep_stale_self_heals_job_after_snapshot_seal(session_factory, tmp_path): + now = [datetime(2026, 1, 1, tzinfo=UTC)] + worker, record_id, snapshot_id = _claim_with_snapshot(session_factory, tmp_path, now, completed_snapshot=True) + + now[0] += timedelta(seconds=61) + assert worker.sweep_stale() == 1 + + with session_factory() as session: + job = session.scalars(select(AnalysisJob).where(AnalysisJob.repository_id == record_id)).one() + assert job.status == "completed" + assert job.progress == 100 + assert job.snapshot_id == snapshot_id + assert session.scalar(select(func.count()).select_from(RiSnapshot)) == 1 + record = session.get(RepositoryRecord, record_id) + assert record.status == "completed" + assert record.analysis_progress == 100 + + +def test_sweep_stale_leaves_unexpired_job_untouched(session_factory, tmp_path): + now = [datetime(2026, 1, 1, tzinfo=UTC)] + worker, record_id, snapshot_id = _claim_with_snapshot(session_factory, tmp_path, now) + + now[0] += timedelta(seconds=59) + assert worker.sweep_stale() == 0 + + with session_factory() as session: + job = session.scalars(select(AnalysisJob).where(AnalysisJob.repository_id == record_id)).one() + assert job.status == "running" + assert job.worker_id == "stale-worker" + assert session.get(RiSnapshot, snapshot_id).state == "building" + + +def test_reclaimed_job_is_not_overwritten_by_the_worker_that_lost_its_lease(session_factory, tmp_path): + now = [datetime(2026, 1, 1, tzinfo=UTC)] + session_a = session_factory() + session_b = session_factory() + try: + owner = _owner(session_a) + record = _repository_with_sources(session_a, owner, tmp_path / "repo") + AnalysisJobService(session_a, owner.id).submit(record.id) + record_id = record.id + + worker_a = AnalysisWorker( + session_factory, + worker_id="worker-a", + lease_seconds=60, + clock=lambda: now[0], + ) + job_a = worker_a._claim(session_a) + assert job_a is not None + + now[0] += timedelta(seconds=61) + sweeper = AnalysisWorker( + session_factory, + worker_id="sweeper", + lease_seconds=60, + clock=lambda: now[0], + ) + assert sweeper.sweep_stale() == 1 + + now[0] += timedelta(seconds=3) + worker_b = AnalysisWorker( + session_factory, + worker_id="worker-b", + lease_seconds=60, + clock=lambda: now[0], + ) + job_b = worker_b._claim(session_b) + assert job_b is not None + assert job_b.id == job_a.id + + # Worker A completes local stage work, but its guarded checkpoint sees + # worker B's ownership and abandons without retrying or mutating the row. + assert list(worker_a._execute_stages(session_a, job_a)) == [] + with session_factory() as verification: + claimed = verification.get(AnalysisJob, job_b.id) + assert claimed.status == "running" + assert claimed.worker_id == "worker-b" + assert claimed.attempt == 2 + assert verification.scalar(select(func.count()).select_from(RiSnapshot)) == 0 + + list(worker_b._execute_stages(session_b, job_b)) + finally: + session_a.close() + session_b.close() + + with session_factory() as session: + job = session.scalars(select(AnalysisJob).where(AnalysisJob.repository_id == record_id)).one() + assert job.status == "completed" + assert job.attempt == 2 + assert job.snapshot_id is not None + assert session.scalar(select(func.count()).select_from(RiSnapshot)) == 1 + assert session.get(RiSnapshot, job.snapshot_id).state == "completed" + + +def test_exception_from_reclaimed_attempt_does_not_overwrite_replacement(session_factory, tmp_path, monkeypatch): + now = [datetime(2026, 1, 1, tzinfo=UTC)] + session_a = session_factory() + session_b = session_factory() + try: + owner = _owner(session_a) + record = _repository_with_sources(session_a, owner, tmp_path / "repo") + AnalysisJobService(session_a, owner.id).submit(record.id) + record_id = record.id + + worker_a = AnalysisWorker( + session_factory, + worker_id="worker-a", + lease_seconds=60, + clock=lambda: now[0], + ) + job_a = worker_a._claim(session_a) + assert job_a is not None + + now[0] += timedelta(seconds=61) + sweeper = AnalysisWorker( + session_factory, + worker_id="sweeper", + lease_seconds=60, + clock=lambda: now[0], + ) + assert sweeper.sweep_stale() == 1 + + now[0] += timedelta(seconds=3) + worker_b = AnalysisWorker( + session_factory, + worker_id="worker-b", + lease_seconds=60, + clock=lambda: now[0], + ) + job_b = worker_b._claim(session_b) + assert job_b is not None + assert job_b.id == job_a.id + + def _boom(_ctx): + raise RuntimeError("old attempt failed locally") + + monkeypatch.setattr(worker_a, "_stage_open_snapshot", _boom) + assert list(worker_a._execute_stages(session_a, job_a)) == [] + + with session_factory() as verification: + replacement = verification.get(AnalysisJob, job_b.id) + assert replacement.status == "running" + assert replacement.worker_id == "worker-b" + assert replacement.attempt == 2 + assert replacement.error_code == "RI-JOB-STALE" + assert replacement.error_message == "Analysis worker lease expired before the job completed." + assert verification.scalar(select(func.count()).select_from(RiSnapshot)) == 0 + + list(worker_b._execute_stages(session_b, job_b)) + finally: + session_a.close() + session_b.close() + + with session_factory() as session: + job = session.scalars(select(AnalysisJob).where(AnalysisJob.repository_id == record_id)).one() + assert job.status == "completed" + assert job.attempt == 2 + assert job.snapshot_id is not None + assert session.scalar(select(func.count()).select_from(RiSnapshot)) == 1 + assert session.get(RiSnapshot, job.snapshot_id).state == "completed" diff --git a/apps/backend/tests/test_api_assertions.py b/apps/backend/tests/test_api_assertions.py new file mode 100644 index 00000000..771a29f4 --- /dev/null +++ b/apps/backend/tests/test_api_assertions.py @@ -0,0 +1,46 @@ +import pytest +from httpx import Response + +from tests.api_assertions import assert_error_response + + +def _error_response(payload: dict[str, object], request_id: str = "request-id") -> Response: + return Response(status_code=422, json=payload, headers={"X-Request-ID": request_id}) + + +def _valid_error_payload() -> dict[str, object]: + return { + "code": "validation_error", + "message": "A validation error occurred.", + "details": None, + "request_id": "request-id", + } + + +def test_assert_error_response_accepts_the_exact_public_envelope(): + error = assert_error_response(_error_response(_valid_error_payload()), 422, "validation_error") + + assert error.details is None + + +def test_assert_error_response_rejects_a_missing_envelope_field(): + payload = _valid_error_payload() + del payload["details"] + + with pytest.raises(AssertionError, match="fields must be exactly"): + assert_error_response(_error_response(payload), 422, "validation_error") + + +def test_assert_error_response_rejects_an_unexpected_envelope_field(): + payload = _valid_error_payload() + payload["traceback"] = "sensitive stack trace" + + with pytest.raises(AssertionError, match="fields must be exactly"): + assert_error_response(_error_response(payload), 422, "validation_error") + + +def test_assert_error_response_rejects_a_request_id_mismatch(): + with pytest.raises(AssertionError, match="request_id must match"): + assert_error_response( + _error_response(_valid_error_payload(), request_id="different-id"), 422, "validation_error" + ) diff --git a/apps/backend/tests/test_approve_email_script.py b/apps/backend/tests/test_approve_email_script.py new file mode 100644 index 00000000..c66c2921 --- /dev/null +++ b/apps/backend/tests/test_approve_email_script.py @@ -0,0 +1,91 @@ +"""scripts/approve_email.py (#374): the v1 admin mechanism for adding to the +registration allowlist that replaced invite codes (#341). Exercises `main()` +in-process against the same test database the `client` fixture configures, +the same idiom test_list_waitlist_script.py uses for its own script. +""" + +import sys + +import pytest + +from scripts.approve_email import main + + +def _run(monkeypatch: pytest.MonkeyPatch, *args: str) -> int: + monkeypatch.setattr(sys, "argv", ["approve_email.py", *args]) + return main() + + +def test_approves_a_new_email_with_note_and_added_by(client, monkeypatch, capsys): + exit_code = _run(monkeypatch, "--email", "jane@example.com", "--note", "waitlist: 2026", "--added-by", "parth") + assert exit_code == 0 + assert "Approved jane@example.com" in capsys.readouterr().out + + from sqlalchemy import select + + from app.core.database import SessionLocal + from app.models.approved_email import ApprovedEmail + + with SessionLocal() as session: + approval = session.scalars(select(ApprovedEmail).where(ApprovedEmail.email == "jane@example.com")).one() + assert approval.note == "waitlist: 2026" + assert approval.added_by == "parth" + assert approval.used_at is None + + +def test_normalizes_email_case_and_whitespace(client, monkeypatch): + _run(monkeypatch, "--email", " Jane@Example.COM ") + + from sqlalchemy import select + + from app.core.database import SessionLocal + from app.models.approved_email import ApprovedEmail + + with SessionLocal() as session: + approval = session.scalars(select(ApprovedEmail).where(ApprovedEmail.email == "jane@example.com")).first() + assert approval is not None + + +def test_defaults_added_by_to_the_current_os_user(client, monkeypatch): + monkeypatch.setattr("scripts.approve_email.getpass.getuser", lambda: "fake-os-user") + _run(monkeypatch, "--email", "nodefault@example.com") + + from sqlalchemy import select + + from app.core.database import SessionLocal + from app.models.approved_email import ApprovedEmail + + with SessionLocal() as session: + approval = session.scalars(select(ApprovedEmail).where(ApprovedEmail.email == "nodefault@example.com")).one() + assert approval.added_by == "fake-os-user" + + +def test_approving_the_same_email_twice_is_a_harmless_no_op(client, monkeypatch, capsys): + assert _run(monkeypatch, "--email", "twice@example.com") == 0 + capsys.readouterr() + + exit_code = _run(monkeypatch, "--email", "TWICE@example.com") # same address, different case + assert exit_code == 0 + assert "already approved" in capsys.readouterr().out + + from sqlalchemy import func, select + + from app.core.database import SessionLocal + from app.models.approved_email import ApprovedEmail + + with SessionLocal() as session: + count = session.scalar( + select(func.count()).select_from(ApprovedEmail).where(ApprovedEmail.email == "twice@example.com") + ) + assert count == 1 + + +def test_a_freshly_approved_email_actually_satisfies_registration(client, monkeypatch): + """End-to-end proof the script's output is exactly what AuthService.register() + checks -- not a parallel table nobody actually reads.""" + _run(monkeypatch, "--email", "endtoend@example.com") + + response = client.post( + "/auth/register", json={"email": "endtoend@example.com", "password": "correct-horse-battery-staple"} + ) + assert response.status_code == 201, response.text diff --git a/apps/backend/tests/test_approved_emails_migration.py b/apps/backend/tests/test_approved_emails_migration.py new file mode 100644 index 00000000..4909ab91 --- /dev/null +++ b/apps/backend/tests/test_approved_emails_migration.py @@ -0,0 +1,168 @@ +"""Migration-level coverage for #374, revision 0016_approved_emails. + +Runs against SQLite by default and against a real, disposable PostgreSQL +database when ``PARTHA_TEST_PG_URL`` is set -- the same fixture idiom as +test_repository_lineage_migration.py's ``lineage_migration_db``. +""" + +import uuid +from pathlib import Path + +import pytest +from alembic import command +from alembic.config import Config +from sqlalchemy import create_engine, inspect, select +from sqlalchemy.engine import make_url + +BACKEND_ROOT = Path(__file__).resolve().parents[1] +PG_URL = __import__("os").environ.get("PARTHA_TEST_PG_URL") + +SEEDED_EMAIL = "parthrohit60@gmail.com" + + +def _alembic_config() -> Config: + cfg = Config(str(BACKEND_ROOT / "alembic.ini")) + cfg.set_main_option("script_location", str(BACKEND_ROOT / "alembic")) + return cfg + + +def _database_url(tmp_path) -> str: + if not PG_URL: + return f"sqlite:///{tmp_path / 'approved-emails-migration.db'}" + admin_url = make_url(PG_URL) + database_name = f"partha_approved_emails_migration_{uuid.uuid4().hex}" + admin_engine = create_engine(admin_url, isolation_level="AUTOCOMMIT") + try: + with admin_engine.connect() as connection: + connection.exec_driver_sql(f'CREATE DATABASE "{database_name}"') + finally: + admin_engine.dispose() + return admin_url.set(database=database_name).render_as_string(hide_password=False) + + +def _drop_pg_database(database_url: str) -> None: + if not PG_URL: + return + admin_url = make_url(PG_URL) + target = make_url(database_url) + admin_engine = create_engine(admin_url, isolation_level="AUTOCOMMIT") + try: + with admin_engine.connect() as connection: + connection.exec_driver_sql(f'DROP DATABASE IF EXISTS "{target.database}" WITH (FORCE)') + finally: + admin_engine.dispose() + + +@pytest.fixture() +def approved_emails_migration_db(tmp_path, monkeypatch): + database_url = _database_url(tmp_path) + monkeypatch.setenv("DATABASE_URL", database_url) + monkeypatch.setenv("CORS_ORIGINS", "http://testserver") + # See tests/conftest.py's `client` fixture: "test", not the default + # "development", so AuthService's dev-only allowlist bypass (#384) never + # applies here either. + monkeypatch.setenv("APP_ENV", "test") + from app.core import config + + config.get_settings.cache_clear() + engine = create_engine(database_url) + try: + yield database_url, engine + finally: + engine.dispose() + config.get_settings.cache_clear() + _drop_pg_database(database_url) + + +def test_fresh_database_creates_approved_emails_with_the_expected_shape(approved_emails_migration_db): + database_url, engine = approved_emails_migration_db + command.upgrade(_alembic_config(), "head") + + inspector = inspect(engine) + assert "approved_emails" in inspector.get_table_names() + # invite_tokens is left in place as a historical audit record -- not + # dropped by this migration. + assert "invite_tokens" in inspector.get_table_names() + + columns = {column["name"] for column in inspector.get_columns("approved_emails")} + assert columns == {"id", "email", "note", "added_by", "created_at", "used_at", "used_by_user_id"} + + unique_constraints = inspector.get_unique_constraints("approved_emails") + assert any(set(uc["column_names"]) == {"email"} for uc in unique_constraints) or any( + set(index["column_names"]) == {"email"} and index["unique"] + for index in inspector.get_indexes("approved_emails") + ) + + +def test_the_product_owner_is_pre_approved_by_the_migration_itself(approved_emails_migration_db): + database_url, engine = approved_emails_migration_db + command.upgrade(_alembic_config(), "head") + + from sqlalchemy.orm import sessionmaker + + from app.models.approved_email import ApprovedEmail + + Session = sessionmaker(bind=engine) + with Session() as session: + seeded = session.scalars(select(ApprovedEmail).where(ApprovedEmail.email == SEEDED_EMAIL)).one() + assert seeded.used_at is None + assert seeded.used_by_user_id is None + assert seeded.added_by == "migration:0016_approved_emails" + assert seeded.note is not None and "product owner" in seeded.note.lower() + + +def test_downgrade_then_reupgrade_round_trips_cleanly_and_reseeds(approved_emails_migration_db): + database_url, engine = approved_emails_migration_db + cfg = _alembic_config() + command.upgrade(cfg, "head") + command.downgrade(cfg, "0015_oauth_identities") + + inspector = inspect(engine) + assert "approved_emails" not in inspector.get_table_names() + + command.upgrade(cfg, "head") + inspector = inspect(engine) + assert "approved_emails" in inspector.get_table_names() + + from sqlalchemy.orm import sessionmaker + + from app.models.approved_email import ApprovedEmail + + Session = sessionmaker(bind=engine) + with Session() as session: + matches = session.scalars(select(ApprovedEmail).where(ApprovedEmail.email == SEEDED_EMAIL)).all() + assert len(matches) == 1 + + +def test_registration_actually_works_against_a_freshly_migrated_database(approved_emails_migration_db, monkeypatch): + """End-to-end proof this migration's schema is what the live app + actually uses, not just a shape check -- the seeded owner email can + register through the real endpoint on a database built by nothing but + Alembic (no AUTO_CREATE_TABLES).""" + database_url, engine = approved_emails_migration_db + monkeypatch.setenv("AUTO_CREATE_TABLES", "false") + command.upgrade(_alembic_config(), "head") + + from app.core import config + from app.core.schema_sync import stamp_head + + config.get_settings.cache_clear() + import app.core.database as database + + settings = config.get_settings() + database.settings = settings + database.engine.dispose() + database.engine = database.create_engine(settings.database_url, pool_pre_ping=True) + database.SessionLocal.configure(bind=database.engine) + stamp_head(database.engine) + + from fastapi.testclient import TestClient + + from app.main import create_app + + with TestClient(create_app()) as client: + response = client.post( + "/auth/register", json={"email": SEEDED_EMAIL, "password": "correct-horse-battery-staple"} + ) + assert response.status_code == 201, response.text + assert response.json()["user"]["email"] == SEEDED_EMAIL diff --git a/apps/backend/tests/test_architecture_relationships.py b/apps/backend/tests/test_architecture_relationships.py new file mode 100644 index 00000000..80846333 --- /dev/null +++ b/apps/backend/tests/test_architecture_relationships.py @@ -0,0 +1,487 @@ +from __future__ import annotations + +import io +import shutil +import zipfile + +from sqlalchemy import event + +from app.analysis.architecture import ArchitectureAnalyzer +from app.extraction.manifests import DependencyManifestExtractor +from app.extraction.pipeline import ExtractionPipeline +from app.extraction.typescript import TypeScriptExtractor +from app.intelligence.classification import RoleClassifier +from app.intelligence.query_service import ( + ARCHITECTURE_EVIDENCE_BATCH_SIZE, + ARCHITECTURE_FACT_DIAGNOSTIC_CODES, + SnapshotQueryService, +) +from app.intelligence.resolution import RelationshipResolver +from app.intelligence.snapshot_store import Evidence, Revision, SnapshotStore +from app.models.repository import RepositoryRecord + +from tests.analysis_helpers import run_analysis_jobs + + +def _archive(files: dict[str, bytes]) -> bytes: + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as archive: + for path, content in files.items(): + archive.writestr(path, content) + return buffer.getvalue() + + +def _upload(auth_client, files: dict[str, bytes], *, analyse: bool = True) -> dict: + response = auth_client.post( + "/repositories/upload", + files={"file": ("architecture.zip", _archive(files), "application/zip")}, + ) + assert response.status_code == 201, response.text + repository = response.json() + # /start enqueues durably; draining the worker persists the legacy + # intelligence the architecture read endpoints consume and seals a snapshot. + # ``analyse=False`` leaves the job queued so a test can exercise the genuine + # "no sealed snapshot yet" architecture response. + assert auth_client.post(f"/analysis/{repository['id']}/start").status_code == 200 + if analyse: + assert run_analysis_jobs() == 1 + return repository + + +def _persist_snapshot( + repository_id: str, + sources: dict[str, bytes], + *, + snapshot_sources: dict[str, bytes] | None = None, + extra_unrendered_diagnostics: int = 0, +) -> str: + from app.core.database import SessionLocal + + pipeline = ExtractionPipeline([TypeScriptExtractor(), DependencyManifestExtractor()]) + runs = pipeline.run(snapshot_sources or sources) + producer_version_set = sorted( + {run.producer for run in runs} + | {"relationship-resolver@1.1.0", f"{RoleClassifier.name}@{RoleClassifier.version}"} + ) + + with SessionLocal() as session: + repository = session.get(RepositoryRecord, repository_id) + assert repository is not None + store = SnapshotStore(session) + snapshot = store.begin( + repository_id=repository.id, + revision=Revision(repository.revision_kind, repository.revision_value, repository.revision_ref), + producer_version_set=producer_version_set, + ) + for run in runs: + for node in run.result.nodes: + store.add_node( + snapshot, + node_kind=node.node_kind, + stable_key=node.stable_key, + name=node.name, + language=node.language, + properties=node.properties, + set_array_keys=( + frozenset({"decorators"}) + if node.properties and "decorators" in node.properties + else frozenset() + ), + evidence=[_evidence(item, run.producer_name, run.producer_version) for item in node.evidence], + ) + for observation in run.result.observations: + store.add_observation( + snapshot, + observed_kind=observation.observed_kind, + subject_kind=observation.subject_kind, + subject_key=observation.subject_key, + referent_text=observation.referent_text, + ordinal=observation.ordinal, + evidence=_evidence(observation.evidence, run.producer_name, run.producer_version), + ) + for diagnostic in run.result.diagnostics: + store.add_diagnostic( + snapshot, + code=diagnostic.code, + category=diagnostic.category, + severity=diagnostic.severity, + message=diagnostic.message, + producer=run.producer, + path=diagnostic.path, + span=diagnostic.span, + subject=diagnostic.subject, + details=diagnostic.details, + ) + RelationshipResolver(store).resolve(snapshot) + RoleClassifier(store).classify(snapshot) + for index in range(extra_unrendered_diagnostics): + store.add_diagnostic( + snapshot, + code="RI-TS-PARSE", + category="syntax extraction", + severity="info", + message=f"Irrelevant parser diagnostic {index}.", + producer="relationship-resolver@1.1.0", + path=f"src/generated/{index}.ts", + span=(1, 1), + ) + return store.seal(snapshot).snapshot_id + + +def _evidence(item, extractor: str, version: str) -> Evidence: + return Evidence( + path=item.path, + start_line=item.start_line, + end_line=item.end_line, + extractor=extractor, + extractor_version=version, + logical_line_count=item.logical_line_count, + granularity=item.granularity, + ) + + +def test_architecture_edges_come_from_resolved_snapshot_evidence(auth_client): + sources = { + "README.md": b"# Architecture fixture\n", + "package.json": b'{\n "dependencies": {\n "lodash": "4.17.21"\n }\n}\n', + "src/alpha/index.ts": ( + b"import { beta } from '../beta';\n" + b"import { helper } from './util';\n" + b"import React from 'react';\n" + b"export const alpha = beta() + helper();\n" + ), + "src/alpha/util.ts": b"export function helper() { return 1; }\n", + "src/beta/index.ts": b"export function beta() { return 1; }\n", + "src/lonely/index.ts": b"export const lonely = 1;\n", + "src/ambiguous/index.ts": b"import '../shared';\nexport const ambiguous = 1;\n", + "src/unresolved/index.ts": b"import '../missing';\nexport const unresolved = 1;\n", + "src/shared.ts": b"export const first = 1;\n", + "src/shared.tsx": b"export const second = 2;\n", + "src/alpha/package.json": b'{\n "dependencies": {\n "react": "18.3.0"\n }\n}\n', + } + repository = _upload(auth_client, sources) + snapshot_id = _persist_snapshot(repository["id"], sources) + + # Prove the architecture request consumes persisted metadata/snapshot facts, + # not the repository working tree. + from app.core.database import SessionLocal + + with SessionLocal() as session: + record = session.get(RepositoryRecord, repository["id"]) + assert record is not None + local_path = record.local_path + shutil.rmtree(local_path) + + response = auth_client.get(f"/analysis/{repository['id']}/architecture") + assert response.status_code == 200, response.text + architecture = response.json() + nodes = {node["id"]: node for node in architecture["nodes"]} + + # Both modules have the same persisted role (entrypoint); neither is collapsed. + assert "entrypoint" in nodes["module:alpha"]["tags"] + assert "entrypoint" in nodes["module:beta"]["tags"] + assert architecture["relationshipSnapshotId"] == snapshot_id + + import_edges = [ + edge + for edge in architecture["edges"] + if edge["source"] == "module:alpha" and edge["target"] == "module:beta" and edge["predicate"] == "imports" + ] + assert len(import_edges) == 1 + edge = import_edges[0] + assert edge["truthClass"] == "inferred" + assert edge["evidence"] == [ + { + "snapshotId": snapshot_id, + "factId": edge["evidence"][0]["factId"], + "path": "src/alpha/index.ts", + "startLine": 1, + "endLine": 1, + } + ] + assert "module:beta" in nodes["module:alpha"]["dependencies"] + assert "module:alpha" in nodes["module:beta"]["dependents"] + assert nodes["module:alpha"]["relationshipState"] == "connected" + assert nodes["module:beta"]["relationshipState"] == "connected" + assert any( + item["source"] == "module:alpha" and item["target"] == "module:beta" and item["predicate"] == "calls" + for item in architecture["edges"] + ) + assert not any(item["source"] == item["target"] for item in architecture["edges"]) + assert not any(item["code"] == "ARCH-REL-ENDPOINT-UNMAPPED" for item in architecture["diagnostics"]) + + dependency_edges = [ + item for item in architecture["edges"] if item["source"] == "module:alpha" and item["target"] == "dep:npm:react" + ] + assert {item["predicate"] for item in dependency_edges} == {"imports", "depends_on"} + assert not any(item["target"] == "dep:npm:lodash" for item in architecture["edges"]) + assert "dep:npm:lodash" not in nodes + assert "dep:npm:react" in nodes + root_scope_diagnostic = next( + item + for item in architecture["diagnostics"] + if item["code"] == "ARCH-REL-REPO-SCOPED" and item["path"] == "package.json" and item["severity"] == "info" + ) + assert root_scope_diagnostic["nodeIds"] is None + assert nodes["module:lonely"]["relationshipState"] == "no-observed-relationships" + assert nodes["module:documentation"]["relationshipState"] == "not-extracted" + + diagnostics = architecture["diagnostics"] + assert any(item["code"] == "RI-RES-AMBIGUOUS" and item["path"] == "src/ambiguous/index.ts" for item in diagnostics) + assert any( + item["code"] == "RI-RES-UNRESOLVED" and item["path"] == "src/unresolved/index.ts" for item in diagnostics + ) + assert nodes["module:ambiguous"]["relationshipState"] == "unresolved" + assert nodes["module:unresolved"]["relationshipState"] == "unresolved" + assert not any(edge["source"] == "module:ambiguous" for edge in architecture["edges"]) + assert not any(edge["source"] == "module:unresolved" for edge in architecture["edges"]) + + evidence_response = auth_client.get(f"/intelligence/v1/snapshots/{snapshot_id}/evidence?limit=100") + assert evidence_response.status_code == 200 + assert any( + item["factKind"] == "edge" + and item["factId"] == edge["evidence"][0]["factId"] + and item["path"] == edge["evidence"][0]["path"] + and item["startLine"] == edge["evidence"][0]["startLine"] + for item in evidence_response.json()["data"] + ) + + +def test_architecture_maps_every_snapshot_file_to_a_module(auth_client): + """Modules are derived from the sealed snapshot's own file set (#95), so a + file the snapshot observes always maps to some module — there is no longer + a legacy-intelligence file list that can disagree with what was sealed.""" + + sources = { + "README.md": b"# Mapping fixture\n", + "src/beta/index.ts": b"export function beta() { return 1; }\n", + } + repository = _upload(auth_client, sources) + _persist_snapshot( + repository["id"], + sources, + snapshot_sources={ + **sources, + "unmapped.ts": b"import { beta } from './src/beta';\nexport const use = beta();\n", + }, + ) + + response = auth_client.get(f"/analysis/{repository['id']}/architecture") + + assert response.status_code == 200 + architecture = response.json() + assert not any(item["code"] == "ARCH-REL-ENDPOINT-UNMAPPED" for item in architecture["diagnostics"]) + nodes = {node["id"]: node for node in architecture["nodes"]} + assert "module:unmapped.ts" in nodes + import_edges = [ + edge + for edge in architecture["edges"] + if edge["source"] == "module:unmapped.ts" and edge["target"] == "module:beta" and edge["predicate"] == "imports" + ] + assert len(import_edges) == 1 + assert import_edges[0]["evidence"][0]["path"] == "unmapped.ts" + + +def test_architecture_entrypoint_role_is_not_classified_as_frontend(auth_client): + """#396: an entrypoint file (main/app/index) previously mapped to node + type "frontend" regardless of language -- a Python or backend entrypoint + is not a frontend just because it's where execution starts.""" + + sources = {"index.ts": b"export function main() { return 1; }\n"} + repository = _upload(auth_client, sources) + _persist_snapshot(repository["id"], sources) + + response = auth_client.get(f"/analysis/{repository['id']}/architecture") + + assert response.status_code == 200 + nodes = {node["id"]: node for node in response.json()["nodes"]} + module = nodes["module:index.ts"] + assert module["type"] == "entrypoint" + assert module["type"] != "frontend" + + +def test_architecture_excludes_manifest_and_lockfile_paths_from_modules(auth_client): + """#396: package.json/pyproject.toml/lockfiles already surface as + dependency evidence -- grouping them into an architecture module too + misrepresents them as part of the system's own structure.""" + + sources = { + "src/beta/index.ts": b"export function beta() { return 1; }\n", + "package.json": b'{"name": "fixture", "dependencies": {}}\n', + "package-lock.json": b'{"name": "fixture", "lockfileVersion": 3, "packages": {}}\n', + } + repository = _upload(auth_client, sources) + _persist_snapshot(repository["id"], sources) + + response = auth_client.get(f"/analysis/{repository['id']}/architecture") + + assert response.status_code == 200 + node_ids = {node["id"] for node in response.json()["nodes"]} + assert not any("package.json" in node_id for node_id in node_ids) + assert not any("package-lock.json" in node_id for node_id in node_ids) + assert "module:beta" in node_ids + + +def test_architecture_does_not_flag_a_module_for_external_or_platform_references(auth_client): + """A reference into a Node builtin / third-party package is not an unmapped + *architecture* relationship: it must not turn a module red or appear in the + diagnostics list. A genuine in-repo gap in a sibling module still does.""" + + sources = { + "src/pure/index.ts": ( + b"import { readFileSync } from 'fs';\nexport const load = (p: string) => readFileSync(p);\n" + ), + "src/broken/index.ts": b"import '../nowhere';\nexport const broken = 1;\n", + } + repository = _upload(auth_client, sources) + _persist_snapshot(repository["id"], sources) + + architecture = auth_client.get(f"/analysis/{repository['id']}/architecture").json() + nodes = {node["id"]: node for node in architecture["nodes"]} + diagnostics = architecture["diagnostics"] + + # 'fs' + readFileSync() are the language platform: not a coverage gap. + assert nodes["module:pure"]["relationshipState"] != "unresolved" + assert not any(item["code"] == "RI-RES-UNRESOLVED" and item["path"] == "src/pure/index.ts" for item in diagnostics) + # '../nowhere' resolves to nothing in-repo: still a real gap. + assert nodes["module:broken"]["relationshipState"] == "unresolved" + assert any(item["code"] == "RI-RES-UNRESOLVED" and item["path"] == "src/broken/index.ts" for item in diagnostics) + + +def test_architecture_module_name_for_a_bare_file_is_not_title_cased(): + """#396: a top-level file with no directory nesting is grouped by its own + filename (e.g. "unmapped.ts", see test_architecture_maps_every_snapshot_ + file_to_a_module above) -- Title Case corrupts its extension + ("unmapped.ts" -> "Unmapped.Ts"). A role-based grouping's plain word slug + (e.g. "services") keeps its existing, readable Title Case.""" + + assert ArchitectureAnalyzer._module_display_name("module:unmapped.ts") == "unmapped.ts" + assert ArchitectureAnalyzer._module_display_name("module:services") == "Services" + + +def test_architecture_language_comes_from_files_when_source_has_no_symbols(auth_client): + sources = {"script.py": b"answer = 42\n"} + repository = _upload(auth_client, sources) + _persist_snapshot(repository["id"], sources) + + response = auth_client.get(f"/analysis/{repository['id']}/architecture") + + assert response.status_code == 200 + assert response.json()["summary"]["language"] == "Python" + + +def test_architecture_fact_query_excludes_irrelevant_large_snapshot_records(auth_client): + """Architecture reads relationship facts, not every stored snapshot record.""" + + sources = { + "src/alpha/index.ts": b"import { beta } from '../beta';\nexport const alpha = beta;\n", + "src/beta/index.ts": b"export function beta() { return 1; }\n", + } + snapshot_sources = { + **sources, + **{f"src/generated/{index}.ts": f"export const generated{index} = {index};\n".encode() for index in range(64)}, + } + repository = _upload(auth_client, sources) + _persist_snapshot( + repository["id"], + sources, + snapshot_sources=snapshot_sources, + extra_unrendered_diagnostics=64, + ) + + from app.core.database import SessionLocal + + with SessionLocal() as session: + record = session.get(RepositoryRecord, repository["id"]) + assert record is not None + facts = SnapshotQueryService(session, record.owner_id).architecture_facts(record.id) + + assert facts is not None + # Only predicates a consumer actually renders are loaded. + assert {edge.predicate for edge in facts.edges} == {"imports"} + + node_keys = {node.stable_key for node in facts.nodes} + # Every file node is loaded. Architecture builds its module inventory and + # primary language from these, so bounding them to edge endpoints would + # silently drop unconnected files from the architecture response. + assert {f"file:{path}" for path in snapshot_sources} <= node_keys + # Symbol nodes are the bulk of a large snapshot and are loaded only when a + # rendered edge references them. + endpoint_keys = {key for edge in facts.edges for key in (edge.subject_key, edge.object_key)} + assert {node.stable_key for node in facts.nodes if node.node_kind == "symbol"} <= endpoint_keys + + assert set(facts.node_evidence) <= {node.id for node in facts.nodes} + assert set(facts.edge_evidence) == {edge.id for edge in facts.edges} + # Diagnostics no consumer renders are never hydrated. + assert all(item.code in ARCHITECTURE_FACT_DIAGNOSTIC_CODES for item in facts.diagnostics) + assert not any(item.code == "RI-TS-PARSE" for item in facts.diagnostics) + # Covered paths are the paths carrying non-inventory extraction evidence. + assert set(sources) <= facts.covered_paths + + response = auth_client.get(f"/analysis/{repository['id']}/architecture") + assert response.status_code == 200, response.text + assert not any(item["code"] == "RI-TS-PARSE" for item in response.json()["diagnostics"]) + + +def test_architecture_fact_query_batches_relevant_evidence_ids(auth_client): + """Architecture evidence reads stay below the configured SQL parameter bound.""" + + sources = { + "src/target.ts": b"export const target = 1;\n", + **{ + f"src/importers/{index}.ts": ( + b"import { target } from '../target';\n" + f"export const importer{index} = target;\n".encode() + ) + for index in range(ARCHITECTURE_EVIDENCE_BATCH_SIZE + 1) + }, + } + repository = _upload(auth_client, sources, analyse=False) + _persist_snapshot(repository["id"], sources) + + from app.core.database import SessionLocal + + with SessionLocal() as session: + record = session.get(RepositoryRecord, repository["id"]) + assert record is not None + evidence_query_parameter_counts: list[int] = [] + + def record_evidence_batch(_conn, _cursor, statement, parameters, _context, _executemany): + if "FROM ri_evidence" not in statement: + return + if "ri_evidence.node_ref IN" not in statement and "ri_evidence.edge_ref IN" not in statement: + return + evidence_query_parameter_counts.append(len(parameters)) + + event.listen(session.bind, "before_cursor_execute", record_evidence_batch) + try: + facts = SnapshotQueryService(session, record.owner_id).architecture_facts(record.id) + finally: + event.remove(session.bind, "before_cursor_execute", record_evidence_batch) + + assert facts is not None + assert len(facts.edges) == ARCHITECTURE_EVIDENCE_BATCH_SIZE + 1 + # Every source file is loaded regardless of batch size; the batching applies + # to the evidence lookups, not to which facts the consumer can see. + assert {f"file:{path}" for path in sources} <= {node.stable_key for node in facts.nodes} + assert len(facts.node_evidence) <= len(facts.nodes) + assert len(facts.edge_evidence) == len(facts.edges) + # Node and edge evidence each need more than one batch at this size, and no + # single statement may exceed the snapshot parameter plus one full batch of + # ids. Exact remainders depend on how many nodes the snapshot holds, so the + # bound is asserted rather than a hardcoded shape. + assert len(evidence_query_parameter_counts) == 4 + assert sorted(evidence_query_parameter_counts)[-2:] == [ARCHITECTURE_EVIDENCE_BATCH_SIZE + 1] * 2 + assert max(evidence_query_parameter_counts) <= ARCHITECTURE_EVIDENCE_BATCH_SIZE + 1 + + +def test_architecture_without_a_sealed_snapshot_returns_404(auth_client): + # Leave the analysis job queued (worker not drained) so no snapshot is sealed: + # this exercises the genuine "no sealed snapshot" case, which durable analysis + # otherwise reaches only in the window before the worker runs. Architecture must + # 404 the same way Dependencies/Review/Insights already do (#217) rather than + # falling back to a graph built from unsealed repository metadata. + repository = _upload(auth_client, {"src/lonely/index.ts": b"export const lonely = 1;\n"}, analyse=False) + + response = auth_client.get(f"/analysis/{repository['id']}/architecture") + + assert response.status_code == 404 diff --git a/apps/backend/tests/test_auth.py b/apps/backend/tests/test_auth.py new file mode 100644 index 00000000..42695d38 --- /dev/null +++ b/apps/backend/tests/test_auth.py @@ -0,0 +1,563 @@ +import uuid + +import pytest + +from tests.api_assertions import assert_error_response +from tests.conftest import approve_email + +REGISTER = {"email": "alice@example.com", "password": "correct-horse-battery"} +COOKIE = "partha_refresh" + + +def _register(client, email="alice@example.com", password="correct-horse-battery", skip_approval=False): + if not skip_approval: + approve_email(email, f"test:{email}") + return client.post("/auth/register", json={"email": email, "password": password}) + + +def _current_refresh_cookie(client) -> str | None: + return client.cookies.get(COOKIE) + + +def _refresh_with(client, raw: str): + """Call /auth/refresh presenting exactly this token, bypassing the jar. + + The jar is cleared first because httpx would otherwise merge its own + (rotated) cookie into the request alongside the explicit header. + """ + client.cookies.clear() + return client.post("/auth/refresh", headers={"Cookie": f"{COOKIE}={raw}"}) + + +# --- register ----------------------------------------------------------------- + + +def test_register_returns_token_and_sets_refresh_cookie(client): + response = _register(client) + + assert response.status_code == 201 + body = response.json() + assert body["tokenType"] == "bearer" + assert body["accessToken"] + assert body["user"]["email"] == "alice@example.com" + + set_cookie = response.headers["set-cookie"] + assert COOKIE in set_cookie + assert "HttpOnly" in set_cookie + assert "Path=/auth" in set_cookie + assert "SameSite=lax" in set_cookie.lower() or "samesite=lax" in set_cookie.lower() + + +def test_register_duplicate_email_conflicts(client): + assert _register(client).status_code == 201 + + duplicate = _register(client) + assert_error_response(duplicate, 409, "conflict_error") + + +def test_register_rejects_short_password(client): + response = _register(client, password="short") + error = assert_error_response(response, 422, "request_validation_error") + assert error.details is not None + assert "errors" in error.details + + +def test_register_with_an_approved_email_succeeds(client): + """Baseline: a pre-approved email lets registration through (#374).""" + approve_email("alice@example.com") + + response = _register(client, skip_approval=True) + + assert response.status_code == 201 + assert response.json()["user"]["email"] == "alice@example.com" + + +def test_register_rejects_an_email_that_was_never_approved(client): + # A baseline first user is registered here so #388's first-user + # bootstrap (see below) has already closed before this assertion runs -- + # otherwise this registration would BE the first-ever one on a fresh + # database and succeed via bootstrap instead of exercising the plain + # rejection this test is actually about. + assert _register(client).status_code == 201 + + response = _register(client, email="bob@example.com", skip_approval=True) + + error = assert_error_response(response, 422, "validation_error") + assert "hasn't been approved" in error.message + assert "waitlist" in error.message.lower() + + +def test_first_ever_registration_becomes_the_owner_without_approval(client): + """#388: the very first account on a fresh instance is auto-approved + without needing anyone to have pre-approved it -- otherwise a genuine + self-hoster running their own copy of PARTHA has no way to ever + register at all, since nobody is pre-approved on a fresh database + except this project's own owner (seeded by the #374 migration, not + relevant to a self-hoster's own instance). + + The `client` fixture itself runs as APP_ENV=test (see conftest.py) -- + a non-development environment -- so this exercises the real + cross-environment bootstrap, not #384's dev-only bypass.""" + response = _register(client, skip_approval=True) + + assert response.status_code == 201 + assert response.json()["user"]["email"] == "alice@example.com" + + +def test_second_registration_after_the_first_owner_still_needs_approval(client): + """The bootstrap window closes the instant the first registration + commits: a second, different unapproved email on the same instance is + rejected exactly as it would be without #388 at all.""" + assert _register(client, skip_approval=True).status_code == 201 + + response = _register(client, email="bob@example.com", skip_approval=True) + + error = assert_error_response(response, 422, "validation_error") + assert "hasn't been approved" in error.message + + +def test_first_ever_registration_works_in_production_too(client): + """Not #384's dev-only bypass, and not specific to the test fixture's + own APP_ENV=test -- #388's bootstrap has no environment check at all. + Constructed the same way test_development_bypasses_the_allowlist_for_an_ + unapproved_email builds a non-development AuthService directly against + the same (still-empty) database the `client` fixture just created.""" + from sqlalchemy import select + + from app.auth.service import AuthService + from app.core.config import get_settings + from app.core.database import SessionLocal + from app.models.approved_email import ApprovedEmail + + prod_settings = get_settings().model_copy(update={"app_env": "production"}) + + with SessionLocal() as db: + user, access_token, refresh_token = AuthService(db, prod_settings).register( + "self-hoster@example.com", "correct-horse-battery" + ) + assert user.email == "self-hoster@example.com" + assert access_token + assert refresh_token + + bootstrap_approval = db.scalars( + select(ApprovedEmail).where(ApprovedEmail.email == "self-hoster@example.com") + ).one() + assert bootstrap_approval.added_by == "first-user-bootstrap" + assert bootstrap_approval.used_at is not None + assert bootstrap_approval.used_by_user_id == user.id + + +def test_first_user_bootstrap_ignores_the_seed_placeholder_row(client): + """A real, Alembic-migrated deployment always has the credential-less + SEED_USER_ID placeholder row (app/models/user.py) in `users` before + anyone has ever registered -- migration 0002 seeds it on every fresh + database, unlike this test's own `create_all`-based fixture DB. #388's + "first user" check must not mistake that permanent system row for an + already-claimed instance, or bootstrap would never fire on a real + deployment at all.""" + from app.core.database import SessionLocal + from app.models.user import SEED_USER_EMAIL, SEED_USER_ID, User + + with SessionLocal() as db: + db.add(User(id=SEED_USER_ID, email=SEED_USER_EMAIL, password_hash=None)) + db.commit() + + response = _register(client, skip_approval=True) + + assert response.status_code == 201 + + +def test_development_bypasses_the_allowlist_for_an_unapproved_email(client): + """#384: local development must stay exactly as frictionless as it was + before the allowlist existed. The `client` fixture itself runs as + APP_ENV=test (see conftest.py) specifically so this doesn't leak into + the rest of the suite -- this test builds its own AuthService against + an app_env="development" copy of the real settings to exercise the + bypass directly, the same construction + test_register_commit_time_collision_is_reported_as_conflict uses.""" + from sqlalchemy import select + + from app.auth.service import AuthService + from app.core.config import get_settings + from app.core.database import SessionLocal + from app.models.approved_email import ApprovedEmail + + dev_settings = get_settings().model_copy(update={"app_env": "development"}) + + with SessionLocal() as db: + user, access_token, refresh_token = AuthService(db, dev_settings).register( + "never-approved@example.com", "correct-horse-battery" + ) + assert user.email == "never-approved@example.com" + assert access_token + assert refresh_token + + # A real, persisted ApprovedEmail row was created -- not a special + # in-memory-only path -- so the rest of register()'s audit trail + # (used_at/used_by_user_id) behaves identically to a real approval. + auto_approval = db.scalars( + select(ApprovedEmail).where(ApprovedEmail.email == "never-approved@example.com") + ).one() + assert auto_approval.added_by == "dev-bypass" + assert auto_approval.used_at is not None + assert auto_approval.used_by_user_id == user.id + + +def test_development_bypass_does_not_apply_outside_development(client): + """The same unapproved email that succeeds under app_env="development" + (previous test) must still be rejected under every other environment + value -- this is a narrowly-scoped dev convenience, not a relaxation of + the check itself. A baseline user is registered first so #388's + first-user bootstrap (which, unlike the dev-only bypass, applies in + every one of these environments) has already closed before the loop + runs -- otherwise the first iteration would succeed via bootstrap + rather than exercising the dev-bypass boundary this test is about.""" + from app.auth.service import AuthService + from app.core.config import get_settings + from app.core.database import SessionLocal + from app.core.exceptions import ValidationServiceError + + assert _register(client).status_code == 201 + + for env in ("test", "staging", "production"): + settings = get_settings().model_copy(update={"app_env": env}) + with SessionLocal() as db: + with pytest.raises(ValidationServiceError, match="hasn't been approved"): + AuthService(db, settings).register(f"never-approved-{env}@example.com", "correct-horse-battery") + + +def test_register_still_succeeds_after_the_approved_email_is_used_once(client): + """Approval is not single-use (#374): re-registering the SAME email a + second time is rejected by the ordinary email-uniqueness conflict, not + by the allowlist itself -- and a genuinely different, still-unused + approval for a different email is completely unaffected by the first + registration.""" + approve_email("shared-approval@example.com") + first = client.post( + "/auth/register", json={"email": "shared-approval@example.com", "password": "correct-horse-battery"} + ) + assert first.status_code == 201 + + duplicate = client.post( + "/auth/register", json={"email": "shared-approval@example.com", "password": "another-password-entirely"} + ) + assert_error_response(duplicate, 409, "conflict_error") + + from app.core.database import SessionLocal + from app.models.approved_email import ApprovedEmail + from sqlalchemy import select + + with SessionLocal() as db: + approval = db.scalars(select(ApprovedEmail).where(ApprovedEmail.email == "shared-approval@example.com")).one() + assert approval.used_at is not None + assert approval.used_by_user_id == first.json()["user"]["id"] + + +def test_register_commit_time_collision_is_reported_as_conflict(client, monkeypatch): + """Two concurrent registrations for the same email can both pass the + existence check before either writes; the unique constraint is the real + guard, and its IntegrityError must be turned into the same 409 the normal + duplicate path returns - never a 500, and the session must stay usable. + + Deterministic by construction: rather than racing real threads (which + would need a production-code hook to land the interleaving reliably), the + "losing" session's own `flush()` is intercepted to run a second, + completely independent session's successful registration first - the + exact database-level interleaving a real race produces - before the + original flush proceeds and collides on the unique email constraint. + Intercepting flush rather than commit: register() flushes the new user + row before commit (so `user.id` exists to stamp onto the approval's + `used_by_user_id`), so that flush is where the loser's write lock is + actually acquired and where the collision actually surfaces -- + intercepting commit instead would have the loser already holding that + lock when the "concurrent" winner tries to write, deadlocking the single + test process against itself rather than reproducing the race. + """ + from sqlalchemy import select + + from app.auth.service import AuthService + from app.core.config import get_settings + from app.core.database import SessionLocal + from app.core.exceptions import ConflictServiceError + from app.models.user import User + + winner_email = "Racer@Example.com" + loser_email = " racer@EXAMPLE.com " # mixed case + whitespace: same normalized address + normalized = "racer@example.com" + settings = get_settings() + + # One approval covers both attempts: it's the same normalized address, + # and approval isn't consumed by use (#374) -- only the email-uniqueness + # constraint this test is actually about does that job. + approve_email(normalized) + + loser_session = SessionLocal() + try: + loser_service = AuthService(loser_session, settings) + + # The loser's own existence check, run here before the winner exists, + # is exactly what a real concurrent request would see: nothing yet. + assert loser_session.scalars(select(User).where(User.email == normalized)).first() is None + + original_flush = loser_session.flush + + def flush_after_concurrent_winner_lands(*args, **kwargs): + winner_session = SessionLocal() + try: + AuthService(winner_session, settings).register(winner_email, "correct-horse-battery") + finally: + winner_session.close() + return original_flush(*args, **kwargs) + + monkeypatch.setattr(loser_session, "flush", flush_after_concurrent_winner_lands) + + with pytest.raises(ConflictServiceError) as exc_info: + loser_service.register(loser_email, "another-password-entirely") + assert exc_info.value.message == "An account with this email already exists." + assert exc_info.value.status_code == 409 + + # The session is usable after the rollback: a fresh query on it works + # and sees the winner's row (not the loser's, which never committed). + after_rollback = loser_session.scalars(select(User).where(User.email == normalized)).one() + assert after_rollback.email == normalized + finally: + loser_session.close() + + # Exactly one user for the normalized email, confirmed from a clean session. + verify_session = SessionLocal() + try: + matches = verify_session.scalars(select(User).where(User.email == normalized)).all() + assert len(matches) == 1 + finally: + verify_session.close() + + +# --- login -------------------------------------------------------------------- + + +def test_login_works_and_normalizes_email_case(client): + _register(client) + + response = client.post("/auth/login", json={"email": "ALICE@Example.COM", "password": REGISTER["password"]}) + assert response.status_code == 200 + token = response.json()["accessToken"] + + me = client.get("/auth/me", headers={"Authorization": f"Bearer {token}"}) + assert me.status_code == 200 + assert me.json()["email"] == "alice@example.com" + + +def test_login_wrong_password_and_unknown_email_are_indistinguishable(client): + _register(client) + + wrong_password = client.post("/auth/login", json={"email": REGISTER["email"], "password": "not-the-password"}) + unknown_email = client.post("/auth/login", json={"email": "nobody@example.com", "password": "whatever-here"}) + + wrong_error = assert_error_response(wrong_password, 401, "unauthorized") + unknown_error = assert_error_response(unknown_email, 401, "unauthorized") + assert wrong_error.model_dump(exclude={"request_id"}) == unknown_error.model_dump(exclude={"request_id"}) + + +def test_user_without_credential_cannot_login(client): + # Rows created outside registration (the seed user, pre-auth dev users) + # have password_hash NULL; login against them must fail like any bad + # credential rather than crashing or succeeding. + from app.core.database import SessionLocal + from app.models.user import User + + db = SessionLocal() + try: + db.add(User(id=str(uuid.uuid4()), email="legacy@example.com", password_hash=None)) + db.commit() + finally: + db.close() + + response = client.post("/auth/login", json={"email": "legacy@example.com", "password": "anything-at-all"}) + assert response.status_code == 401 + assert response.json()["code"] == "unauthorized" + + +# --- access tokens ------------------------------------------------------------ + + +def test_me_requires_a_token(client): + assert_error_response(client.get("/auth/me"), 401, "unauthorized") + + +def test_me_rejects_garbage_token(client): + response = client.get("/auth/me", headers={"Authorization": "Bearer not-a-real-token"}) + assert response.status_code == 401 + + +def test_expired_access_token_is_rejected(client): + user_id = _register(client).json()["user"]["id"] + + from app.auth.security import create_access_token + from app.core.config import get_settings + + expired = create_access_token(user_id, get_settings(), ttl_seconds=-10) + response = client.get("/auth/me", headers={"Authorization": f"Bearer {expired}"}) + assert response.status_code == 401 + + +# --- refresh rotation --------------------------------------------------------- + + +def test_refresh_rotates_and_new_token_works(client): + _register(client) + first_cookie = _current_refresh_cookie(client) + + refreshed = client.post("/auth/refresh") + assert refreshed.status_code == 200 + second_cookie = _current_refresh_cookie(client) + assert second_cookie and second_cookie != first_cookie + + me = client.get("/auth/me", headers={"Authorization": f"Bearer {refreshed.json()['accessToken']}"}) + assert me.status_code == 200 + + +def test_refresh_reuse_revokes_the_whole_family(client): + _register(client) + stolen = _current_refresh_cookie(client) + + assert client.post("/auth/refresh").status_code == 200 + successor = _current_refresh_cookie(client) + + # Replaying the rotated (stolen) token fails... + assert _refresh_with(client, stolen).status_code == 401 + + # ...and takes the legitimate successor down with it. + assert _refresh_with(client, successor).status_code == 401 + + +def test_refresh_without_cookie_is_unauthorized(client): + assert_error_response(client.post("/auth/refresh"), 401, "unauthorized") + + +def test_logout_revokes_and_is_idempotent(client): + _register(client) + cookie_before = _current_refresh_cookie(client) + + assert client.post("/auth/logout").status_code == 204 + + assert _refresh_with(client, cookie_before).status_code == 401 + assert client.post("/auth/logout").status_code == 204 + + +def test_logout_rate_limit_error_uses_the_standard_envelope(client, monkeypatch): + # Logout is a public, idempotent route. Set its real middleware budget to + # zero so the first ASGI request deterministically exercises its documented + # 429 response without changing the route or relying on timing. + settings = client.app.state.rate_limit_settings + monkeypatch.setattr( + client.app.state, + "rate_limit_settings", + settings.model_copy(update={"rate_limit_default_per_minute": 0}), + ) + + response = client.post("/auth/logout") + + error = assert_error_response(response, 429, "rate_limited") + assert error.details is not None + assert error.details["retryAfterSeconds"] >= 1 + assert int(response.headers["Retry-After"]) >= 1 + + +# --- storage hygiene ---------------------------------------------------------- + + +def test_no_plaintext_credentials_or_raw_tokens_in_database(client): + _register(client) + raw_refresh = _current_refresh_cookie(client) + + from sqlalchemy import select + + from app.core.database import SessionLocal + from app.models.refresh_token import RefreshToken + from app.models.user import User + + db = SessionLocal() + try: + user = db.scalars(select(User).where(User.email == REGISTER["email"])).one() + assert user.password_hash.startswith("$argon2id$") + assert REGISTER["password"] not in user.password_hash + + hashes = db.scalars(select(RefreshToken.token_hash)).all() + assert hashes, "expected at least one refresh token row" + assert raw_refresh not in hashes + assert all(len(value) == 64 for value in hashes) # sha256 hex, never the raw token + finally: + db.close() + + +# --- signing-secret strength -------------------------------------------------- + + +def test_auth_secret_key_is_required_and_strong_outside_dev(): + from pydantic import ValidationError + + from app.core.config import Settings + + # Production also requires an AI encryption key; supply a valid one so this + # test isolates the auth-secret behaviour rather than tripping that check. + from cryptography.fernet import Fernet + + ai_key = Fernet.generate_key().decode() + + # Non-dev refuses to start without a secret... + with pytest.raises(ValidationError): + Settings(app_env="production", ai_encryption_key=ai_key) + # ...and with a weak one. + with pytest.raises(ValidationError): + Settings(app_env="production", auth_secret_key="too-short", ai_encryption_key=ai_key) + # A sufficiently long secret is accepted. + strong = "s" * 32 + assert Settings(app_env="production", auth_secret_key=strong, ai_encryption_key=ai_key).auth_secret_key == strong + # Development stays lenient: an empty secret resolves to the dev default. + assert Settings(app_env="development", auth_secret_key="").auth_secret_key + + +# --- interaction with protected routes ---------------------------------------- + + +def test_bearer_token_attributes_repository_routes_to_that_user(client): + token = _register(client).json()["accessToken"] + user_id = client.get("/auth/me", headers={"Authorization": f"Bearer {token}"}).json()["id"] + + from app.core.database import SessionLocal + from app.models.repository import RepositoryRecord + + db = SessionLocal() + try: + db.add( + RepositoryRecord( + id=str(uuid.uuid4()), + owner_id=user_id, + name="alice-repo", + source="upload", + local_path="/tmp/alice-repo", + status="completed", + ) + ) + db.commit() + finally: + db.close() + + with_token = client.get("/repositories", headers={"Authorization": f"Bearer {token}"}) + assert with_token.status_code == 200 + assert with_token.json()["total"] == 1 + + +def test_invalid_bearer_on_protected_routes_is_rejected(client): + # Presenting a bad token is an authentication attempt; it must never fall + # back to any anonymous or seed identity. + response = client.get("/repositories", headers={"Authorization": "Bearer garbage"}) + assert response.status_code == 401 + + +def test_anonymous_repository_access_is_rejected(client): + # E1.3 (#63) removed the pre-auth fallback: protected routes now require a + # valid token, so an anonymous request is unauthorized rather than served + # an empty seed-user view. + response = client.get("/repositories") + assert response.status_code == 401 diff --git a/apps/backend/tests/test_auth_concurrency.py b/apps/backend/tests/test_auth_concurrency.py new file mode 100644 index 00000000..d1963e4c --- /dev/null +++ b/apps/backend/tests/test_auth_concurrency.py @@ -0,0 +1,208 @@ +import os +import threading +import uuid +from datetime import UTC, datetime, timedelta + +import pytest + +from app.core.exceptions import UnauthorizedError + +PG_URL = os.environ.get("PARTHA_TEST_PG_URL") + + +def _make_refresh_token(db, user_id: str, family_id: str | None = None) -> str: + from app.auth.security import hash_refresh_token, new_refresh_token + from app.models.refresh_token import RefreshToken + + raw = new_refresh_token() + db.add( + RefreshToken( + id=str(uuid.uuid4()), + user_id=user_id, + token_hash=hash_refresh_token(raw), + family_id=family_id or str(uuid.uuid4()), + expires_at=datetime.now(UTC) + timedelta(days=1), + ) + ) + db.commit() + return raw + + +def test_claim_token_is_single_use(client): + """The atomic claim lets exactly one caller mark a token used. + + This is the primitive the whole rotation race depends on: two requests can + both read used_at IS NULL, but only one UPDATE can affect the row. Proven + deterministically here (sequential) on whatever engine the suite runs. + """ + from app.auth.service import AuthService + from app.core.config import get_settings + from app.core.database import SessionLocal + from app.models.refresh_token import RefreshToken + from app.models.user import User + + db = SessionLocal() + try: + user = User(id=str(uuid.uuid4()), email="claim@example.com") + db.add(user) + db.commit() + _make_refresh_token(db, user.id) + token_id = db.query(RefreshToken).one().id + + service = AuthService(db, get_settings()) + now = datetime.now(UTC) + assert service._claim_token(token_id, now) is True # the winner + assert service._claim_token(token_id, now) is False # WHERE used_at IS NULL rejects the rest + finally: + db.close() + + +def test_refresh_loser_revokes_family_making_winners_successor_unusable(client): + """Deterministic companion to the real-Postgres test below: proves the + *service-level rule* that a lost claim revokes the whole family - including + the legitimate successor the winner just received - without needing + threads or a real row lock. This is strict fail-closed replay protection + by design (see Finding B in the PR description): any concurrent + presentation of the same refresh token is treated as possible theft, so + the winner is deliberately logged out too rather than silently kept alive. + """ + from sqlalchemy import select + + from app.auth.security import hash_refresh_token + from app.auth.service import AuthService + from app.core.config import get_settings + from app.core.database import SessionLocal + from app.models.refresh_token import RefreshToken + + from tests.conftest import approve_email + + settings = get_settings() + approve_email("family-revoke@example.com", "test:family-revoke") + register = client.post( + "/auth/register", + json={"email": "family-revoke@example.com", "password": "correct-horse-battery"}, + ) + raw = register.cookies.get("partha_refresh") + + db = SessionLocal() + try: + service = AuthService(db, settings) + _, _, successor = service.refresh(raw) + + # A second presentation of the now-used token is exactly what a losing + # concurrent request sees: reuse -> the whole family is revoked. + with pytest.raises(UnauthorizedError): + service.refresh(raw) + + # The winner's own successor is now unusable too - proving the rule, + # not just that *a* rejection happened. + with pytest.raises(UnauthorizedError): + service.refresh(successor) + + original = db.scalars(select(RefreshToken).where(RefreshToken.token_hash == hash_refresh_token(raw))).one() + family_rows = db.scalars(select(RefreshToken).where(RefreshToken.family_id == original.family_id)).all() + assert len(family_rows) >= 2, "expected at least the original token and its successor" + assert all(row.revoked_at is not None for row in family_rows), "every row in the family must be revoked" + assert not any(row.used_at is None and row.revoked_at is None for row in family_rows), ( + "no unused, unrevoked token should remain in the family" + ) + finally: + db.close() + + +@pytest.mark.skipif(not PG_URL, reason="set PARTHA_TEST_PG_URL to run the Postgres concurrency test") +def test_concurrent_refresh_on_postgres_mints_one_successor(): + """Two threads refreshing the same token against a real Postgres: exactly + one wins. The loser blocks on the row lock, sees the used_at guard fail, and + is rejected — SQLite serializes writes and cannot exercise this. + + Beyond just "one ok, one rejected", this also proves the *final* state: + the winner's successor is captured and then, from a fresh session after + both threads finish, shown to be unusable, and every row in the family is + confirmed revoked - so a real concurrent claim loss is proven to revoke + the family for real, not just in the deterministic companion test above. + """ + from sqlalchemy import create_engine, select + from sqlalchemy.orm import sessionmaker + + from app.auth.service import AuthService + from app.core.config import Settings + from app.models.base import Base + from app.models.refresh_token import RefreshToken + from app.models.user import User + + engine = create_engine(PG_URL) + Base.metadata.create_all(engine) + Session = sessionmaker(bind=engine, expire_on_commit=False) + settings = Settings(app_env="test") + + setup = Session() + try: + user = User(id=str(uuid.uuid4()), email=f"pg-{uuid.uuid4().hex}@example.com") + setup.add(user) + setup.commit() + raw = _make_refresh_token(setup, user.id) + user_id = user.id + family_id = setup.query(RefreshToken).filter(RefreshToken.user_id == user_id).one().family_id + finally: + setup.close() + + results: list[str] = [] + winner_successors: list[str] = [] + results_lock = threading.Lock() + start = threading.Barrier(2) + + def worker() -> None: + session = Session() + outcome = "error" + try: + start.wait(timeout=10) + _, _, successor = AuthService(session, settings).refresh(raw) + outcome = "ok" + with results_lock: + winner_successors.append(successor) + except UnauthorizedError: + outcome = "rejected" + finally: + session.close() + with results_lock: + results.append(outcome) + + threads = [threading.Thread(target=worker) for _ in range(2)] + for thread in threads: + thread.start() + for thread in threads: + thread.join(timeout=15) + + stuck = [thread for thread in threads if thread.is_alive()] + + try: + # A stuck thread or an unexpected exception (outcome left as "error") + # must fail the test loudly, never be read as an expected rejection. + assert not stuck, f"{len(stuck)} worker thread(s) did not finish within the timeout" + assert sorted(results) == ["ok", "rejected"], results + assert len(winner_successors) == 1, "exactly one thread should have minted a successor" + + verify = Session() + try: + with pytest.raises(UnauthorizedError): + AuthService(verify, settings).refresh(winner_successors[0]) + + family_rows = verify.scalars(select(RefreshToken).where(RefreshToken.family_id == family_id)).all() + assert len(family_rows) >= 2, "expected at least the original token and the winner's successor" + assert all(row.revoked_at is not None for row in family_rows), ( + "every row in the family must be revoked after a real concurrent replay" + ) + assert not any(row.used_at is None and row.revoked_at is None for row in family_rows), ( + "no second usable successor should remain in the family" + ) + finally: + verify.close() + finally: + cleanup = Session() + try: + cleanup.query(RefreshToken).filter(RefreshToken.user_id == user_id).delete() + cleanup.query(User).filter(User.id == user_id).delete() + cleanup.commit() + finally: + cleanup.close() diff --git a/apps/backend/tests/test_authentication_explanation.py b/apps/backend/tests/test_authentication_explanation.py new file mode 100644 index 00000000..de621941 --- /dev/null +++ b/apps/backend/tests/test_authentication_explanation.py @@ -0,0 +1,522 @@ +from __future__ import annotations + +import shutil + +from app.extraction.manifests import DependencyManifestExtractor +from app.extraction.pipeline import ExtractionPipeline +from app.extraction.python import PythonExtractor +from app.intelligence.classification import RoleClassifier +from app.intelligence.resolution import RelationshipResolver +from app.intelligence.snapshot_store import Evidence, Revision, SnapshotStore +from app.models.repository import RepositoryRecord + +from tests.analysis_helpers import run_analysis_jobs + +# A genuinely connected authentication path (route -> handler -> guard -> +# service -> model, every hop a resolved edge) alongside unrelated noise that +# must never be claimed as authentication: an unrelated `/health` route, a +# generic `Depends(get_database)`, and disconnected `PaymentService` / +# `AuditModel` symbols that merely share a role-classifier suffix. +_AUTH_SOURCES = { + "README.md": b"# auth fixture\n", + "src/dependencies.py": ( + b"from src.services import UserService\n\n\n" + b"def get_current_user(token: str) -> dict:\n" + b" return UserService(token)\n\n\n" + b"def get_database() -> str:\n" + b" return 'db-session'\n" + ), + "src/services.py": ( + b"from src.models import UserModel\n\n\n" + b"def UserService(token: str) -> dict:\n" + b" return UserModel(token)\n\n\n" + b"def PaymentService(amount: int) -> int:\n" + b" return amount\n" + ), + "src/models.py": ( + b"def UserModel(token: str) -> dict:\n" + b" return {'token': token}\n\n\n" + b"def AuditModel(event: str) -> dict:\n" + b" return {'event': event}\n" + ), + "src/routes.py": ( + b"from fastapi import FastAPI, Depends\n" + b"from src.dependencies import get_current_user, get_database\n\n" + b"app = FastAPI()\n\n\n" + b'@app.get("/me")\n' + b"def read_me(user=Depends(get_current_user)):\n" + b" return user\n\n\n" + b'@app.get("/health")\n' + b"def health_check(db=Depends(get_database)):\n" + b" return {'status': 'ok'}\n" + ), +} + + +def _upload(auth_client, files: dict[str, bytes], *, analyse: bool = True) -> dict: + import io + import zipfile + + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as archive: + for path, content in files.items(): + archive.writestr(path, content) + response = auth_client.post( + "/repositories/upload", + files={"file": ("auth.zip", buffer.getvalue(), "application/zip")}, + ) + assert response.status_code == 201, response.text + repository = response.json() + assert auth_client.post(f"/analysis/{repository['id']}/start").status_code == 200 + if analyse: + assert run_analysis_jobs() == 1 + return repository + + +def _evidence(item, extractor: str, version: str) -> Evidence: + return Evidence( + path=item.path, + start_line=item.start_line, + end_line=item.end_line, + extractor=extractor, + extractor_version=version, + logical_line_count=item.logical_line_count, + granularity=item.granularity, + ) + + +def _persist_snapshot(repository_id: str, sources: dict[str, bytes]) -> str: + from app.core.database import SessionLocal + + pipeline = ExtractionPipeline([PythonExtractor(), DependencyManifestExtractor()]) + runs = pipeline.run(sources) + producer_version_set = sorted( + {run.producer for run in runs} + | { + f"{RelationshipResolver.name}@{RelationshipResolver.version}", + f"{RoleClassifier.name}@{RoleClassifier.version}", + } + ) + + with SessionLocal() as session: + repository = session.get(RepositoryRecord, repository_id) + assert repository is not None + store = SnapshotStore(session) + snapshot = store.begin( + repository_id=repository.id, + revision=Revision(repository.revision_kind, repository.revision_value, repository.revision_ref), + producer_version_set=producer_version_set, + ) + for run in runs: + for node in run.result.nodes: + store.add_node( + snapshot, + node_kind=node.node_kind, + stable_key=node.stable_key, + name=node.name, + language=node.language, + properties=node.properties, + set_array_keys=( + frozenset({"decorators"}) + if node.properties and "decorators" in node.properties + else frozenset() + ), + evidence=[_evidence(item, run.producer_name, run.producer_version) for item in node.evidence], + ) + for observation in run.result.observations: + store.add_observation( + snapshot, + observed_kind=observation.observed_kind, + subject_kind=observation.subject_kind, + subject_key=observation.subject_key, + referent_text=observation.referent_text, + ordinal=observation.ordinal, + evidence=_evidence(observation.evidence, run.producer_name, run.producer_version), + ) + for diagnostic in run.result.diagnostics: + store.add_diagnostic( + snapshot, + code=diagnostic.code, + category=diagnostic.category, + severity=diagnostic.severity, + message=diagnostic.message, + producer=run.producer, + path=diagnostic.path, + span=diagnostic.span, + subject=diagnostic.subject, + details=diagnostic.details, + ) + RelationshipResolver(store).resolve(snapshot) + RoleClassifier(store).classify(snapshot) + return store.seal(snapshot).snapshot_id + + +def test_authentication_explanation_survives_filesystem_deletion(auth_client): + """The consumer performs no filesystem read: proof #2 of the acceptance + criteria. Deleting the extracted working directory must not affect the + response, because it is built exclusively from persisted snapshot facts.""" + + repository = _upload(auth_client, _AUTH_SOURCES) + snapshot_id = _persist_snapshot(repository["id"], _AUTH_SOURCES) + + from app.core.database import SessionLocal + + with SessionLocal() as session: + record = session.get(RepositoryRecord, repository["id"]) + assert record is not None + local_path = record.local_path + shutil.rmtree(local_path) + + response = auth_client.get(f"/analysis/{repository['id']}/architecture/authentication") + + assert response.status_code == 200, response.text + body = response.json() + assert body["status"] == "ready" + assert body["snapshotId"] == snapshot_id + + +def test_authentication_explanation_includes_the_connected_path(auth_client): + """The real route -> handler -> guard -> service -> model chain is + included, and every claim/relationship carries valid evidence.""" + + repository = _upload(auth_client, _AUTH_SOURCES) + _persist_snapshot(repository["id"], _AUTH_SOURCES) + + response = auth_client.get(f"/analysis/{repository['id']}/architecture/authentication") + assert response.status_code == 200, response.text + body = response.json() + + claims_by_kind: dict[str, list[dict]] = {} + for claim in body["claims"]: + claims_by_kind.setdefault(claim["kind"], []).append(claim) + + assert {claim["name"] for claim in claims_by_kind.get("route", [])} == {"/me"} + assert {claim["name"] for claim in claims_by_kind.get("middleware", [])} == {"get_current_user"} + assert {claim["name"] for claim in claims_by_kind.get("service", [])} == {"UserService"} + assert {claim["name"] for claim in claims_by_kind.get("model", [])} == {"UserModel"} + + # Every displayed claim resolves to a valid evidence span in the stored revision. + for claim in body["claims"]: + assert claim["evidence"], f"claim {claim['name']!r} has no evidence" + for citation in claim["evidence"]: + assert citation["snapshotId"] == body["snapshotId"] + assert citation["startLine"] >= 1 + assert citation["endLine"] >= citation["startLine"] + assert citation["path"] + + # Middleware/service/model claims are inferred, never presented as guaranteed fact. + middleware_claim = claims_by_kind["middleware"][0] + assert middleware_claim["confidence"] == "heuristic" + route_claim = claims_by_kind["route"][0] + assert route_claim["confidence"] == "observed" + + relationship_pairs = {(r["subject"], r["predicate"], r["object"]) for r in body["relationships"]} + assert ("/me", "routes_to", "read_me") in relationship_pairs + assert ("read_me", "injects", "get_current_user") in relationship_pairs + assert ("get_current_user", "calls", "UserService") in relationship_pairs + assert ("UserService", "calls", "UserModel") in relationship_pairs + for relationship in body["relationships"]: + assert relationship["evidence"] + + assert len(body["chains"]) == 1 + chain = body["chains"][0] + assert chain["route"] == "/me" + assert [hop["predicate"] for hop in chain["hops"]] == ["routes_to", "injects", "calls", "calls"] + + +def test_authentication_explanation_keeps_shared_hops_in_every_chain(auth_client): + """Two guarded routes may converge on the same guard/service/model path. + + The flat relationship list is de-duplicated, but each per-route chain must + still contain the complete shared path. + """ + + sources = { + **_AUTH_SOURCES, + "src/routes.py": ( + b"from fastapi import FastAPI, Depends\n" + b"from src.dependencies import get_current_user\n\n" + b"app = FastAPI()\n\n\n" + b'@app.get("/me")\n' + b"def read_me(user=Depends(get_current_user)):\n" + b" return user\n\n\n" + b'@app.get("/account")\n' + b"def read_account(user=Depends(get_current_user)):\n" + b" return user\n" + ), + } + repository = _upload(auth_client, sources) + _persist_snapshot(repository["id"], sources) + + response = auth_client.get(f"/analysis/{repository['id']}/architecture/authentication") + assert response.status_code == 200, response.text + body = response.json() + + chains_by_route = {chain["route"]: chain for chain in body["chains"]} + assert set(chains_by_route) == {"/me", "/account"} + for chain in chains_by_route.values(): + assert [hop["predicate"] for hop in chain["hops"]] == [ + "routes_to", + "injects", + "calls", + "calls", + ] + + relationship_keys = [(item["subject"], item["predicate"], item["object"]) for item in body["relationships"]] + assert relationship_keys.count(("get_current_user", "calls", "UserService")) == 1 + assert relationship_keys.count(("UserService", "calls", "UserModel")) == 1 + + +def test_authentication_explanation_excludes_unrelated_route_and_dependency(auth_client): + """`/health` and its generic `Depends(get_database)` are never authentication.""" + + repository = _upload(auth_client, _AUTH_SOURCES) + _persist_snapshot(repository["id"], _AUTH_SOURCES) + + response = auth_client.get(f"/analysis/{repository['id']}/architecture/authentication") + assert response.status_code == 200, response.text + body = response.json() + + names = {claim["name"] for claim in body["claims"]} + assert "/health" not in names + assert "get_database" not in names + assert "health_check" not in names + + for relationship in body["relationships"]: + assert relationship["subject"] not in {"/health", "get_database", "health_check"} + assert relationship["object"] not in {"/health", "get_database", "health_check"} + + +def test_authentication_explanation_excludes_unrelated_service_and_model(auth_client): + """`PaymentService`/`AuditModel` share a role-classifier suffix with the + real auth path but are never called from it, so they must not appear.""" + + repository = _upload(auth_client, _AUTH_SOURCES) + _persist_snapshot(repository["id"], _AUTH_SOURCES) + + response = auth_client.get(f"/analysis/{repository['id']}/architecture/authentication") + assert response.status_code == 200, response.text + body = response.json() + + names = {claim["name"] for claim in body["claims"]} + assert "PaymentService" not in names + assert "AuditModel" not in names + + for relationship in body["relationships"]: + assert relationship["subject"] not in {"PaymentService", "AuditModel"} + assert relationship["object"] not in {"PaymentService", "AuditModel"} + + +def test_authentication_explanation_excludes_public_route_without_a_guard(auth_client): + """A route whose only dependency is non-authentication is not claimed, + even though it is a perfectly resolved `injects` edge.""" + + sources = { + "src/dependencies.py": b"def get_database() -> str:\n return 'db-session'\n", + "src/routes.py": ( + b"from fastapi import FastAPI, Depends\n" + b"from src.dependencies import get_database\n\n" + b"app = FastAPI()\n\n\n" + b'@app.get("/health")\n' + b"def health_check(db=Depends(get_database)):\n" + b" return {'status': 'ok'}\n" + ), + } + repository = _upload(auth_client, sources) + _persist_snapshot(repository["id"], sources) + + response = auth_client.get(f"/analysis/{repository['id']}/architecture/authentication") + assert response.status_code == 200 + body = response.json() + assert body["claims"] == [] + assert body["relationships"] == [] + assert body["chains"] == [] + # get_database resolves fine as an `injects` edge -- it just is not + # classified `auth_dependency`, so nothing surfaces. This is a "filtered + # out" empty result, not an extraction failure: prove the edge is real. + from app.core.database import SessionLocal + from app.intelligence.query_service import SnapshotQueryService + + with SessionLocal() as session: + record = session.get(RepositoryRecord, repository["id"]) + assert record is not None + query_service = SnapshotQueryService(session, record.owner_id) + facts = query_service.architecture_facts(record.id) + assert facts is not None + assert any(edge.predicate == "injects" and edge.object_key.endswith("get_database") for edge in facts.edges) + + +def test_authentication_explanation_missing_snapshot_is_honest(auth_client): + """A repository with no sealed snapshot yet reports status=missing_snapshot, + distinguishable from a genuinely empty (analysed, zero-claim) result.""" + + repository = _upload(auth_client, {"README.md": b"# empty\n"}, analyse=False) + + response = auth_client.get(f"/analysis/{repository['id']}/architecture/authentication") + + assert response.status_code == 200 + body = response.json() + assert body["status"] == "missing_snapshot" + assert body["snapshotId"] is None + assert body["claims"] == [] + assert any(diagnostic["code"] == "AUTH-NO-SNAPSHOT" for diagnostic in body["diagnostics"]) + + +def test_authentication_explanation_no_auth_is_distinguishable_from_unparsed(auth_client): + """A repository with genuinely no auth constructs returns zero claims and + no diagnostics: absence is not confused with an unsupported construct.""" + + sources = {"src/plain.py": b"def add(a, b):\n return a + b\n"} + repository = _upload(auth_client, sources) + _persist_snapshot(repository["id"], sources) + + response = auth_client.get(f"/analysis/{repository['id']}/architecture/authentication") + assert response.status_code == 200 + body = response.json() + assert body["status"] == "ready" + assert body["claims"] == [] + assert body["relationships"] == [] + assert body["chains"] == [] + assert body["diagnostics"] == [] + + +def test_authentication_explanation_unresolved_dependency_is_a_visible_diagnostic(auth_client): + """A ``Depends(x)`` whose target cannot be resolved (undefined/ambiguous) + surfaces as a visible diagnostic rather than being silently dropped.""" + + sources = { + "src/routes.py": ( + b"from fastapi import FastAPI, Depends\n\n" + b"app = FastAPI()\n\n\n" + b'@app.get("/me")\n' + b"def read_me(user=Depends(get_current_user)):\n" + b" return user\n" + ), + } + repository = _upload(auth_client, sources) + _persist_snapshot(repository["id"], sources) + + response = auth_client.get(f"/analysis/{repository['id']}/architecture/authentication") + assert response.status_code == 200 + body = response.json() + assert any(diagnostic["code"] == "RI-RES-UNRESOLVED" for diagnostic in body["diagnostics"]) + assert body["claims"] == [] + + +def test_authentication_explanation_is_owner_scoped(auth_client, make_auth_headers): + repository = _upload(auth_client, _AUTH_SOURCES) + _persist_snapshot(repository["id"], _AUTH_SOURCES) + + other = make_auth_headers("other-owner@example.com") + response = auth_client.get( + f"/analysis/{repository['id']}/architecture/authentication", + headers=other["headers"], + ) + assert response.status_code == 404 + + +def test_authentication_explanation_evidence_binds_to_exact_snapshot(auth_client): + """Re-persisting a new snapshot for the same repository must not let an old + snapshot's facts leak into the current explanation (#95 wrong-revision + rejection): every citation names the current snapshot only.""" + + repository = _upload(auth_client, _AUTH_SOURCES) + first_snapshot_id = _persist_snapshot(repository["id"], _AUTH_SOURCES) + + response = auth_client.get(f"/analysis/{repository['id']}/architecture/authentication") + body = response.json() + assert body["snapshotId"] == first_snapshot_id + for claim in body["claims"]: + for citation in claim["evidence"]: + assert citation["snapshotId"] == first_snapshot_id + for relationship in body["relationships"]: + for citation in relationship["evidence"]: + assert citation["snapshotId"] == first_snapshot_id + + +def test_authentication_explanation_evidence_fact_ids_resolve_to_real_facts(auth_client): + """Every evidence ``factId`` must name a node or edge that genuinely exists + in the returned snapshot — not merely a non-empty string.""" + + from app.core.database import SessionLocal + from app.intelligence.query_service import SnapshotQueryService + + repository = _upload(auth_client, _AUTH_SOURCES) + _persist_snapshot(repository["id"], _AUTH_SOURCES) + + response = auth_client.get(f"/analysis/{repository['id']}/architecture/authentication") + body = response.json() + + with SessionLocal() as session: + record = session.get(RepositoryRecord, repository["id"]) + assert record is not None + query_service = SnapshotQueryService(session, record.owner_id) + facts = query_service.architecture_facts(record.id) + assert facts is not None + node_keys = {node.stable_key for node in facts.nodes} + edge_ids = {edge.edge_id for edge in facts.edges} + source_paths = { + evidence_item.path for evidence_list in facts.node_evidence.values() for evidence_item in evidence_list + } | {evidence_item.path for evidence_list in facts.edge_evidence.values() for evidence_item in evidence_list} + + for claim in body["claims"]: + for citation in claim["evidence"]: + assert citation["factId"] in node_keys + assert citation["path"] in source_paths + for relationship in body["relationships"]: + for citation in relationship["evidence"]: + assert citation["factId"] in edge_ids + assert citation["path"] in source_paths + + +# A real, connected auth chain in production source alongside a structurally +# identical one whose every file lives under tests/ -- reproducing the +# 2026-08-20 audit finding that a benchmark test fixture was picked as "the" +# authentication flow for PARTHA's own repository (#337). +_AUTH_SOURCES_WITH_TEST_FIXTURE = { + **_AUTH_SOURCES, + "tests/fixtures/dependencies.py": ( + b"from tests.fixtures.services import UserService\n\n\n" + b"def get_current_user(token: str) -> dict:\n" + b" return UserService(token)\n" + ), + "tests/fixtures/services.py": (b"def UserService(token: str) -> dict:\n return {'token': token}\n"), + "tests/fixtures/routes.py": ( + b"from fastapi import FastAPI, Depends\n" + b"from tests.fixtures.dependencies import get_current_user\n\n" + b"app = FastAPI()\n\n\n" + b'@app.get("/me")\n' + b"def read_me(user=Depends(get_current_user)):\n" + b" return user\n" + ), +} + + +def test_authentication_explanation_excludes_test_fixture_routes(auth_client): + """A route defined only in a test/fixture path must never be claimed as + this repository's authentication flow, even when it is itself a + structurally valid guarded route (#337).""" + + repository = _upload(auth_client, _AUTH_SOURCES_WITH_TEST_FIXTURE) + _persist_snapshot(repository["id"], _AUTH_SOURCES_WITH_TEST_FIXTURE) + + response = auth_client.get(f"/analysis/{repository['id']}/architecture/authentication") + assert response.status_code == 200, response.text + body = response.json() + + for claim in body["claims"]: + for citation in claim["evidence"]: + assert not citation["path"].startswith("tests/"), ( + f"claim {claim['name']!r} is backed by a test-fixture path {citation['path']!r}" + ) + + # The real route from src/routes.py is still reported -- this excludes + # the fixture, it does not suppress genuine findings. Both "/me" routes + # share the same display name, so the count (not just the name set) + # is what proves the fixture's duplicate was actually dropped. + route_claims = [claim for claim in body["claims"] if claim["kind"] == "route"] + assert len(route_claims) == 1 + assert route_claims[0]["name"] == "/me" + assert len(body["chains"]) == 1 + assert body["chains"][0]["route"] == "/me" + assert all(hop["evidence"][0]["path"].startswith("src/") for hop in body["chains"][0]["hops"]) diff --git a/apps/backend/tests/test_canonical_hash.py b/apps/backend/tests/test_canonical_hash.py new file mode 100644 index 00000000..97ef55cd --- /dev/null +++ b/apps/backend/tests/test_canonical_hash.py @@ -0,0 +1,171 @@ +import pytest + +from app.intelligence import canonical + + +def test_rfc_identity_and_config_vectors_are_reproduced_exactly(): + config = { + "max_file_bytes": 524288, + "extractors": ["typescript-ast", "python-ast", "repository-inventory"], + "resolvers": ["route-resolver", "import-resolver", "reference-resolver"], + "classifiers": ["architecture-classifier"], + } + assert canonical.compute_config_hash({}) == ( + "sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a" + ) + assert ( + canonical.compute_config_hash( + config, + set_array_keys=frozenset({"extractors", "resolvers", "classifiers"}), + ) + == "sha256:48e96ba328a03db38556f22d2831d171b82e1ce9287c575328de4bc249da1abe" + ) + + edge_id = canonical.compute_edge_id( + "src/auth/service.ts::AuthService.login", + "calls", + "src/auth/tokens.ts::issueToken", + ) + assert edge_id == "edge:sha256:90594a4734e993838e2db11f9d3bb5ede0cab2f1c70730ca8c4ab407c93bd69e" + + evidence = { + "path": "src/auth/service.ts", + "start_line": 41, + "end_line": 41, + "extractor": "typescript-ast", + "extractor_version": "1.0.0", + } + observation_id = canonical.compute_observation_id( + revision_kind="git", + revision_value="9f1d0c7a2b6e4c5d8f3a1b0c7d9e2f4a6b8c0d1e", + observed_kind="call", + subject_kind="symbol", + subject_key="src/auth/service.ts::AuthService.login", + referent_text="issueToken", + ordinal=1, + evidence=evidence, + ) + assert observation_id == "obs:sha256:ad475fae87c8121b76673172a560885335661f68ea90e4b5fa661be9b884f24a" + + assertion_id = canonical.compute_assertion_id( + subject_kind="module", + subject_key="mod:app/services", + predicate="classified_as", + value={"classification": "business-logic-layer", "confidence": "heuristic"}, + truth_class="inferred", + producer="architecture-classifier", + producer_version="1.0.0", + derived_from=[ + { + "kind": "edge", + "edge_id": "edge:sha256:e20b7e1135e0535ffb7c19cb2066a0645d9e980d2071816ddfc967431b774807", + }, + {"kind": "node", "stable_key": "mod:app/services"}, + ], + ) + assert assertion_id == ("assertion:sha256:c081743f9923c3f5036ebda30de9be443deb58002b6bfa1102f388e64a024d57") + + +def test_config_arrays_preserve_order_unless_explicitly_declared_as_sets(): + first = canonical.compute_config_hash({"pipeline": ["parse", "resolve"]}) + reversed_order = canonical.compute_config_hash({"pipeline": ["resolve", "parse"]}) + assert first != reversed_order + + set_first = canonical.compute_config_hash( + {"extractors": ["python", "typescript", "python"]}, + set_array_keys=frozenset({"extractors"}), + ) + set_second = canonical.compute_config_hash( + {"extractors": ["typescript", "python"]}, + set_array_keys=frozenset({"extractors"}), + ) + assert set_first == set_second + assert canonical.compute_config_hash( + {"include_path": r"src\.\features"}, + path_keys=frozenset({"include_path"}), + ) == canonical.compute_config_hash( + {"include_path": "src/features"}, + path_keys=frozenset({"include_path"}), + ) + + +def test_paths_unicode_and_unsupported_numbers_are_normalized_or_rejected(): + assert canonical.normalize_repo_path(r"src\.\auth\..\main.py") == "src/main.py" + assert canonical.normalize_stable_key("file", "file:src/./main.py") == "file:src/main.py" + assert canonical.canonical_json_bytes({"name": "e\u0301"}) == canonical.canonical_json_bytes({"name": "\u00e9"}) + with pytest.raises(canonical.PathEscapeError): + canonical.normalize_repo_path("../../etc/passwd") + with pytest.raises(canonical.PathEscapeError): + canonical.normalize_repo_path("/etc/passwd") + with pytest.raises(canonical.CanonicalizationError): + canonical.canonical_json_bytes({"confidence": 0.9}) + with pytest.raises(canonical.CanonicalizationError): + canonical.canonical_json_bytes({"e\u0301": 1, "\u00e9": 2}) + with pytest.raises(canonical.CanonicalizationError): + canonical.canonical_json_bytes({"too_large": 2**53}) + + +def test_jcs_object_keys_use_utf16_code_unit_order(): + # U+1F600 sorts before U+E000 under JCS UTF-16 ordering (D83D < E000), + # although Python code-point ordering would place U+E000 first. + assert canonical.canonical_json_bytes({"\ue000": 1, "\U0001f600": 2}) == ('{"\U0001f600":2,"\ue000":1}'.encode()) + + +def test_canonical_graph_hash_is_independent_of_insertion_order_and_volatile_fields(): + evidence_a = { + "path": "src/a.py", + "start_line": 1, + "end_line": 2, + "extractor": "python-ast", + "extractor_version": "1.0.0", + } + evidence_b = {**evidence_a, "start_line": 5, "end_line": 5} + nodes = [ + { + "node_kind": "repository", + "stable_key": "repo:root", + "truth_class": "observed", + "evidence": [evidence_a], + }, + { + "node_kind": "file", + "stable_key": "file:src/a.py", + "truth_class": "observed", + "evidence": [evidence_b, evidence_a], + }, + ] + edge_id = canonical.compute_edge_id("repo:root", "contains", "file:src/a.py") + edges = [ + { + "edge_id": edge_id, + "subject_kind": "repository", + "subject_key": "repo:root", + "predicate": "contains", + "object_kind": "file", + "object_key": "file:src/a.py", + "truth_class": "resolved", + "producer": "inventory-resolver", + "producer_version": "1.0.0", + "evidence": [evidence_b, evidence_a], + "derived_from": [{"kind": "node", "stable_key": "file:src/a.py"}], + "database_id": 99, + "created_at": "volatile and ignored by the record builder", + } + ] + arguments = { + "revision_kind": "git", + "revision_value": "0123456789abcdef0123456789abcdef01234567", + "producer_version_set": ["inventory-resolver@1.0.0", "python-ast@1.0.0"], + "config_hash": canonical.compute_config_hash({}), + "assertions": [], + "observations": [], + "diagnostics": [], + } + first = canonical.compute_canonical_graph_hash(nodes=nodes, edges=edges, **arguments) + second = canonical.compute_canonical_graph_hash( + nodes=list(reversed(nodes)), + edges=[{**edges[0], "evidence": list(reversed(edges[0]["evidence"]))}], + **arguments, + ) + assert first == second + assert first.startswith("sha256:") and len(first) == 71 diff --git a/apps/backend/tests/test_database_url_normalization.py b/apps/backend/tests/test_database_url_normalization.py new file mode 100644 index 00000000..151a30cb --- /dev/null +++ b/apps/backend/tests/test_database_url_normalization.py @@ -0,0 +1,48 @@ +"""DATABASE_URL scheme normalization (#340). + +Managed Postgres providers (Render among them) hand back a bare +postgres(ql):// connection string, but the only driver this project installs +is psycopg 3 -- create_engine on a bare postgresql:// URL raises +ModuleNotFoundError for psycopg2, which is never installed. Settings must +normalize the scheme so a provisioned connection string works unmodified. +""" + +import pytest + +from app.core.config import Settings + + +def _settings(database_url: str) -> Settings: + return Settings( + database_url=database_url, + app_env="test", + auth_secret_key="x" * 32, + ai_encryption_key="", + ) + + +@pytest.mark.parametrize( + "given,expected", + [ + ("postgresql://user:pass@host:5432/db", "postgresql+psycopg://user:pass@host:5432/db"), + ("postgres://user:pass@host:5432/db", "postgresql+psycopg://user:pass@host:5432/db"), + ("postgresql+psycopg://user:pass@host:5432/db", "postgresql+psycopg://user:pass@host:5432/db"), + ("sqlite:///./.local/partha.db", "sqlite:///./.local/partha.db"), + ], +) +def test_database_url_normalizes_to_the_installed_driver(given: str, expected: str) -> None: + assert _settings(given).database_url == expected + + +def test_normalized_url_actually_resolves_to_the_installed_psycopg_driver() -> None: + from sqlalchemy import create_engine + + settings = _settings("postgresql://user:pass@host:5432/db") + engine = create_engine(settings.database_url) + + assert engine.dialect.driver == "psycopg" + + +def test_unsupported_scheme_is_still_rejected() -> None: + with pytest.raises(ValueError, match="Unsupported database URL scheme"): + _settings("mysql://user:pass@host:3306/db") diff --git a/apps/backend/tests/test_dependency_declaration_merge.py b/apps/backend/tests/test_dependency_declaration_merge.py new file mode 100644 index 00000000..76940a78 --- /dev/null +++ b/apps/backend/tests/test_dependency_declaration_merge.py @@ -0,0 +1,269 @@ +"""Regression coverage for #156: multi-manifest dependencies must not fail sealing. + +``SnapshotStore.add_node`` rejects a second write for a stable key whose +``properties`` differ from the first. ``DependencyManifestExtractor`` embeds +per-declaration facts (``manifest_path``, ``version``, ...) straight into a +dependency node's ``properties``, so the same dependency name declared in more +than one manifest used to fail the entire analysis job. ``AnalysisWorker`` +now merges same-producer dependency nodes sharing a stable key into one node +with a ``declarations`` list before persistence. +""" + +from __future__ import annotations + +import io +import json +import zipfile + +from sqlalchemy import select + +from app.core.database import SessionLocal +from app.extraction.base import ExtractedEvidence, ExtractedNode, ExtractionResult +from app.extraction.pipeline import ProducedExtraction +from app.models.snapshot import RiEdge, RiEvidence, RiNode, RiSnapshot +from app.workers.analysis_worker import AnalysisWorker +from tests.analysis_helpers import run_analysis_jobs + +_PRODUCER = ("dependency-manifest", "1.2.0") + + +def _node( + stable_key: str, *, name: str, manifest_path: str, version: str, dependency_type: str = "production", line: int = 1 +) -> ExtractedNode: + return ExtractedNode( + node_kind="dependency", + stable_key=stable_key, + name=name, + language=None, + evidence=( + ExtractedEvidence(path=manifest_path, start_line=line, end_line=line, logical_line_count=max(line, 1)), + ), + properties={ + "ecosystem": stable_key.split(":")[1], + "version": version, + "dependency_type": dependency_type, + "manifest_path": manifest_path, + "workspace_path": manifest_path.rsplit("/", 1)[0] if "/" in manifest_path else ".", + }, + ) + + +def _produced(*nodes: ExtractedNode, producer: tuple[str, str] = _PRODUCER) -> ProducedExtraction: + return ProducedExtraction(producer[0], producer[1], ExtractionResult(nodes=tuple(nodes))) + + +# --- Unit coverage of the merge itself -------------------------------------- + + +def test_merge_combines_declarations_from_two_manifests(): + produced = ( + _produced(_node("dep:npm:react", name="react", manifest_path="apps/frontend/package.json", version="^18.3.0")), + _produced(_node("dep:npm:react", name="react", manifest_path="apps/admin/package.json", version="^18.3.0")), + ) + + merged = AnalysisWorker._merge_dependency_declarations(produced) + + dependency_nodes = [node for item in merged for node in item.result.nodes if node.node_kind == "dependency"] + assert len(dependency_nodes) == 1 + node = dependency_nodes[0] + assert node.stable_key == "dep:npm:react" + assert len(node.evidence) == 2 + assert {declaration["manifest_path"] for declaration in node.properties["declarations"]} == { + "apps/frontend/package.json", + "apps/admin/package.json", + } + + +def test_merge_preserves_conflicting_versions_rather_than_dropping_one(): + produced = ( + _produced( + _node("dep:pypi:requests", name="requests", manifest_path="apps/backend/pyproject.toml", version="==2.31.0") + ), + _produced( + _node( + "dep:pypi:requests", name="requests", manifest_path="services/worker/requirements.txt", version=">=2.32" + ) + ), + ) + + merged = AnalysisWorker._merge_dependency_declarations(produced) + + node = next(node for item in merged for node in item.result.nodes if node.node_kind == "dependency") + versions = {declaration["version"] for declaration in node.properties["declarations"]} + assert versions == {"==2.31.0", ">=2.32"} + + +def test_merge_combines_two_sections_of_the_same_manifest_file(): + produced = ( + _produced( + _node( + "dep:npm:lodash", + name="lodash", + manifest_path="package.json", + version="^4.17.21", + dependency_type="production", + ), + _node( + "dep:npm:lodash", + name="lodash", + manifest_path="package.json", + version="^4.17.21", + dependency_type="development", + line=2, + ), + ), + ) + + merged = AnalysisWorker._merge_dependency_declarations(produced) + + node = next(node for item in merged for node in item.result.nodes if node.node_kind == "dependency") + types = {declaration["dependency_type"] for declaration in node.properties["declarations"]} + assert types == {"production", "development"} + + +def test_merge_is_a_no_op_for_a_single_declaration_shape(): + produced = (_produced(_node("dep:npm:react", name="react", manifest_path="package.json", version="^18.3.0")),) + + merged = AnalysisWorker._merge_dependency_declarations(produced) + + node = next(node for item in merged for node in item.result.nodes if node.node_kind == "dependency") + assert node.properties["declarations"] == [ + { + "name": "react", + "version": "^18.3.0", + "dependency_type": "production", + "manifest_path": "package.json", + "workspace_path": ".", + "start_line": 1, + "end_line": 1, + "extractor": "dependency-manifest", + "extractor_version": "1.2.0", + } + ] + + +def test_merge_leaves_non_dependency_nodes_untouched(): + file_node = ExtractedNode( + node_kind="file", + stable_key="file:src/app.py", + name="app.py", + language="python", + evidence=(ExtractedEvidence(path="src/app.py", start_line=1, end_line=1, logical_line_count=1),), + ) + produced = (ProducedExtraction("python-extractor", "1.0.0", ExtractionResult(nodes=(file_node,))),) + + merged = AnalysisWorker._merge_dependency_declarations(produced) + + assert merged == produced + + +def test_merge_leaves_a_cross_producer_stable_key_collision_unmerged(): + """A different producer disagreeing about the same key is a real conflict. + + This case does not arise from today's extractors, but the merge must not + paper over it if it ever does: leaving it untouched preserves + ``SnapshotStore.add_node``'s existing conflict rejection for a genuine + identity disagreement, as opposed to an ordinary multi-manifest + declaration from the same producer. + """ + + produced = ( + _produced(_node("dep:npm:react", name="react", manifest_path="package.json", version="^18.3.0")), + _produced( + _node("dep:npm:react", name="react", manifest_path="other.json", version="^18.3.0"), + producer=("other-extractor", "9.9.9"), + ), + ) + + merged = AnalysisWorker._merge_dependency_declarations(produced) + + assert merged == produced + + +# --- End-to-end coverage through the real analysis pipeline ----------------- + + +def _archive(files: dict[str, bytes]) -> bytes: + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as archive: + for path, content in files.items(): + archive.writestr(path, content) + return buffer.getvalue() + + +def _upload_and_analyse(auth_client, sources: dict[str, bytes]) -> str: + response = auth_client.post( + "/repositories/upload", + files={"file": ("repo.zip", _archive(sources), "application/zip")}, + ) + assert response.status_code == 201, response.text + repository_id = response.json()["id"] + assert auth_client.post(f"/analysis/{repository_id}/start").status_code == 200 + assert run_analysis_jobs() == 1 + return repository_id + + +def test_a_dependency_declared_in_two_manifests_still_seals_a_snapshot(auth_client): + repository_id = _upload_and_analyse( + auth_client, + { + "README.md": b"# repo\n", + "apps/backend/pyproject.toml": b'[project]\ndependencies = [\n "requests==2.31.0",\n]\n', + "services/worker/requirements.txt": b"requests>=2.32\n", + }, + ) + + with SessionLocal() as session: + snapshot = session.scalars(select(RiSnapshot).where(RiSnapshot.repository_id == repository_id)).first() + assert snapshot is not None + assert snapshot.state == "completed" + assert snapshot.canonical_graph_hash is not None + + node = session.scalars( + select(RiNode).where(RiNode.snapshot_id == snapshot.snapshot_id, RiNode.stable_key == "dep:pypi:requests") + ).first() + assert node is not None + versions = {declaration["version"] for declaration in node.properties["declarations"]} + assert versions == {"==2.31.0", ">=2.32"} + + edge = session.scalars( + select(RiEdge).where( + RiEdge.snapshot_id == snapshot.snapshot_id, + RiEdge.subject_key == "repo:root", + RiEdge.predicate == "depends_on", + RiEdge.object_key == "dep:pypi:requests", + ) + ).first() + assert edge is not None + edge_evidence = session.scalars( + select(RiEvidence).where(RiEvidence.snapshot_id == snapshot.snapshot_id, RiEvidence.edge_ref == edge.id) + ).all() + assert {item.path for item in edge_evidence} == { + "apps/backend/pyproject.toml", + "services/worker/requirements.txt", + } + + +def test_analysis_and_sealed_snapshot_surfaces_recover_once_sealed(auth_client): + repository_id = _upload_and_analyse( + auth_client, + { + "README.md": b"# repo\n", + "apps/frontend/package.json": json.dumps({"dependencies": {"react": "^18.3.0"}}).encode(), + "apps/admin/package.json": json.dumps({"dependencies": {"react": "^18.3.0"}}).encode(), + }, + ) + + status = auth_client.get(f"/analysis/{repository_id}/status").json() + assert status["status"] == "completed", status + + architecture = auth_client.get(f"/analysis/{repository_id}/architecture") + assert architecture.status_code == 200, architecture.text + + review = auth_client.get(f"/analysis/{repository_id}/review") + assert review.status_code == 200, review.text + + insights = auth_client.get(f"/analysis/{repository_id}/insights") + assert insights.status_code == 200, insights.text + assert insights.json()["metrics"][2]["id"] == "nodes.dependencies.total" + assert insights.json()["metrics"][2]["value"] == 1 diff --git a/apps/backend/tests/test_dependency_graph_v2.py b/apps/backend/tests/test_dependency_graph_v2.py new file mode 100644 index 00000000..615ce952 --- /dev/null +++ b/apps/backend/tests/test_dependency_graph_v2.py @@ -0,0 +1,209 @@ +"""Public Dependency Graph v2 contract and provenance tests (#158). + +Mirrors the shape of tests/test_engineering_review_v2.py: Dependency Graph is +now a sealed-snapshot consumer with no legacy fallback, the same as +Architecture/Review/Insights. +""" + +from __future__ import annotations + +import io +import json +import zipfile + +from tests.analysis_helpers import run_analysis_jobs + + +def _archive(files: dict[str, bytes]) -> bytes: + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as archive: + for path, content in files.items(): + archive.writestr(path, content) + return buffer.getvalue() + + +def _upload(auth_client, files: dict[str, bytes], *, analyse: bool = True) -> dict: + response = auth_client.post( + "/repositories/upload", + files={"file": ("dependency-v2.zip", _archive(files), "application/zip")}, + ) + assert response.status_code == 201, response.text + repository = response.json() + if analyse: + assert auth_client.post(f"/analysis/{repository['id']}/start").status_code == 200 + assert run_analysis_jobs() == 1 + return repository + + +_SOURCES_WITH_DEPENDENCIES = { + "README.md": b"# dependency fixture\n", + "apps/frontend/package.json": json.dumps( + {"dependencies": {"react": "^18.3.0"}, "devDependencies": {"vitest": "^1.0.0"}} + ).encode(), +} +_SOURCES_WITHOUT_DEPENDENCIES = {"README.md": b"# no manifests here\n"} + + +def test_dependencies_requires_authentication(client): + response = client.get("/analysis/11111111-1111-1111-1111-111111111111/dependencies") + assert response.status_code == 401 + + +def test_dependencies_is_owner_scoped(auth_client, make_auth_headers): + repository = _upload(auth_client, _SOURCES_WITH_DEPENDENCIES) + intruder = make_auth_headers("dependencies-intruder@example.com") + + response = auth_client.get(f"/analysis/{repository['id']}/dependencies", headers=intruder["headers"]) + + assert response.status_code == 404 + + +def test_dependencies_without_a_sealed_snapshot_returns_404(auth_client): + repository = _upload(auth_client, _SOURCES_WITH_DEPENDENCIES, analyse=False) + + response = auth_client.get(f"/analysis/{repository['id']}/dependencies") + + assert response.status_code == 404 + + +def test_dependencies_contract_is_snapshot_bound_and_deterministic(auth_client): + repository = _upload(auth_client, _SOURCES_WITH_DEPENDENCIES) + + first = auth_client.get(f"/analysis/{repository['id']}/dependencies") + second = auth_client.get(f"/analysis/{repository['id']}/dependencies") + + assert first.status_code == 200, first.text + assert first.json() == second.json() + body = first.json() + assert body["schemaVersion"] == "dependency-graph.v2" + assert body["repositoryId"] == repository["id"] + assert body["revisionValue"] == repository["revision"]["value"] + assert body["snapshotSchemaVersion"] == "ri.v1" + assert body["canonicalGraphHash"].startswith("sha256:") + assert body["manifestDigest"].startswith("sha256:") + assert body["provenance"]["source"] == "ri.v1" + assert body["provenance"]["snapshotId"] == body["snapshotId"] + assert body["vulnerabilityAssessment"] == {"status": "not_computed"} + assert body["outdatedAssessment"] == {"status": "not_computed"} + + +def test_dependencies_reports_real_nodes_edges_and_declarations(auth_client): + repository = _upload(auth_client, _SOURCES_WITH_DEPENDENCIES) + + body = auth_client.get(f"/analysis/{repository['id']}/dependencies").json() + + nodes = {node["id"]: node for node in body["nodes"]} + assert set(nodes) == {"dep:npm:react", "dep:npm:vitest"} + react = nodes["dep:npm:react"] + assert react["name"] == "react" + assert react["version"] == "^18.3.0" + assert react["type"] == "production" + assert react["ecosystem"] == "npm" + assert react["declarations"] == [ + { + "name": "react", + "manifestPath": "apps/frontend/package.json", + "workspacePath": "apps/frontend", + "startLine": 1, + "endLine": 1, + "extractor": "dependency-manifest", + "extractorVersion": "1.2.0", + "ecosystem": "npm", + "version": "^18.3.0", + "type": "production", + } + ] + assert nodes["dep:npm:vitest"]["type"] == "development" + + assert body["totalDependencies"] == 2 + assert body["manifestCount"] == 1 + assert len(body["edges"]) == 2 + for edge in body["edges"]: + assert edge["source"] == "repo:root" + assert edge["target"] in nodes + assert edge["type"] == "depends-on" + + +def test_dependencies_evidence_backed_edges_open_through_the_evidence_endpoint(auth_client): + """A dependency declaration citation must resolve like any other evidence (#95/#154 pattern).""" + + repository = _upload(auth_client, _SOURCES_WITH_DEPENDENCIES) + body = auth_client.get(f"/analysis/{repository['id']}/dependencies").json() + declaration = next(node for node in body["nodes"] if node["id"] == "dep:npm:react")["declarations"][0] + + source = auth_client.get( + f"/analysis/{repository['id']}/evidence", + params={ + "snapshotId": body["snapshotId"], + "factId": "dep:npm:react", + "path": declaration["manifestPath"], + "startLine": declaration["startLine"], + "endLine": declaration["endLine"], + }, + ) + assert source.status_code == 200, source.text + assert source.json()["status"] == "ready" + + +def test_dependencies_a_genuine_zero_dependency_snapshot_is_a_200_not_a_404(auth_client): + repository = _upload(auth_client, _SOURCES_WITHOUT_DEPENDENCIES) + + response = auth_client.get(f"/analysis/{repository['id']}/dependencies") + + assert response.status_code == 200 + body = response.json() + assert body["nodes"] == [] + assert body["edges"] == [] + assert body["totalDependencies"] == 0 + assert body["manifestCount"] == 0 + + +def test_dependencies_diagnostics_pass_through_and_stay_distinguishable_from_zero_results(auth_client): + repository = _upload( + auth_client, + { + "README.md": b"# malformed fixture\n", + "apps/broken/package.json": b"{", + }, + ) + + body = auth_client.get(f"/analysis/{repository['id']}/dependencies").json() + + assert body["nodes"] == [] + assert body["diagnostics"] == [ + { + "code": "RI-SRC-MALFORMED", + "category": "malformed source", + "severity": "error", + "message": "dependency manifest could not be parsed or has an unsupported structure", + "path": "apps/broken/package.json", + "producer": "dependency-manifest@1.2.0", + "details": None, + } + ] + assert body["manifestCount"] == 1 + + +def test_dependencies_across_two_manifests_are_preserved_as_separate_declarations(auth_client): + """Regression coverage that #158 actually depends on the #156 fix.""" + + repository = _upload( + auth_client, + { + "README.md": b"# monorepo fixture\n", + "apps/backend/pyproject.toml": b'[project]\ndependencies = [\n "requests==2.31.0",\n]\n', + "services/worker/requirements.txt": b"requests>=2.32\n", + }, + ) + + body = auth_client.get(f"/analysis/{repository['id']}/dependencies").json() + + node = next(item for item in body["nodes"] if item["id"] == "dep:pypi:requests") + assert node["version"] is None # conflicting versions: never silently pick one + assert node["type"] == "production" + assert {declaration["version"] for declaration in node["declarations"]} == {"==2.31.0", ">=2.32"} + assert {declaration["manifestPath"] for declaration in node["declarations"]} == { + "apps/backend/pyproject.toml", + "services/worker/requirements.txt", + } + assert body["manifestCount"] == 2 diff --git a/apps/backend/tests/test_documentation_api.py b/apps/backend/tests/test_documentation_api.py index 7250aa3f..4bcd9a6a 100644 --- a/apps/backend/tests/test_documentation_api.py +++ b/apps/backend/tests/test_documentation_api.py @@ -1,6 +1,9 @@ import io import zipfile +from tests.analysis_helpers import run_analysis_jobs +from tests.api_assertions import assert_error_response + def _zip_bytes(files: dict[str, str]) -> bytes: buffer = io.BytesIO() @@ -10,8 +13,8 @@ def _zip_bytes(files: dict[str, str]) -> bytes: return buffer.getvalue() -def _import_sample(client) -> str: - response = client.post( +def _import_sample(auth_client, *, analyse: bool = True) -> str: + response = auth_client.post( "/repositories/upload", files={ "file": ( @@ -19,7 +22,10 @@ def _import_sample(client) -> str: _zip_bytes( { "sample/package.json": '{"dependencies":{"react":"^18.0.0"}}', - "sample/src/main.tsx": "import React from 'react';\n", + "sample/src/main.tsx": ( + "import React from 'react';\n" + "const router = createBrowserRouter([{ path: '/users', element: null }]);\n" + ), "sample/README.md": "# Sample\n", } ), @@ -28,31 +34,197 @@ def _import_sample(client) -> str: }, ) assert response.status_code == 201 - return response.json()["id"] + repository_id = response.json()["id"] + if analyse: + assert auth_client.post(f"/analysis/{repository_id}/start").status_code == 200 + assert run_analysis_jobs() == 1 + return repository_id + + +def _generate(auth_client, repository_id: str, fmt: str): + return auth_client.post("/documentation/generate", json={"repositoryId": repository_id, "format": fmt}) + + +def _upload_and_analyse(auth_client, files: dict[str, str]) -> str: + response = auth_client.post( + "/repositories/upload", + files={"file": ("architecture.zip", _zip_bytes(files), "application/octet-stream")}, + ) + assert response.status_code == 201, response.text + repository_id = response.json()["id"] + assert auth_client.post(f"/analysis/{repository_id}/start").status_code == 200 + assert run_analysis_jobs() == 1 + return repository_id -def _generate(client, repository_id: str, fmt: str): - return client.post("/documentation/generate", json={"repositoryId": repository_id, "format": fmt}) +def _markdown_section(markdown: str, heading: str) -> str: + marker = f"## {heading}" + start = markdown.index(marker) + rest = markdown[start + len(marker) :] + next_marker = rest.find("\n## ") + return rest if next_marker == -1 else rest[:next_marker] -def test_documentation_markdown_has_structured_headings(client): - repository_id = _import_sample(client) +def test_documentation_markdown_has_structured_headings(auth_client): + repository_id = _import_sample(auth_client) - response = _generate(client, repository_id, "markdown") + response = _generate(auth_client, repository_id, "markdown") assert response.status_code == 200 content = response.json()["content"] assert "## Overview" in content assert "## Architecture" in content + assert "## Folder Structure" in content + assert "## API" in content + assert "## Environment" in content + assert "## Deployment" in content + assert "## Contribution" in content + assert "react: ^18.0.0 (package.json)" in content + assert "Observed route: /users" in content + body = response.json() + assert body["source"] == "ri.v1" + assert body["snapshotSchemaVersion"] == "ri.v1" + assert body["snapshotId"] + assert body["revisionKind"] == "upload" + assert body["revisionValue"].startswith("sha256:") + assert "Sealed ri.v1 snapshot" in content -def test_documentation_html_renders_real_elements(client): - repository_id = _import_sample(client) +def test_documentation_html_renders_real_elements(auth_client): + repository_id = _import_sample(auth_client) - response = _generate(client, repository_id, "html") + response = _generate(auth_client, repository_id, "html") assert response.status_code == 200 content = response.json()["content"] assert content.startswith("") assert "

Overview

" in content assert "" in content + + +def test_documentation_returns_the_standard_error_for_a_missing_repository(auth_client): + response = _generate(auth_client, "00000000-0000-0000-0000-000000000000", "markdown") + + assert_error_response(response, 404, "not_found") + + +def test_documentation_returns_404_without_a_sealed_snapshot(auth_client): + repository_id = _import_sample(auth_client, analyse=False) + + response = _generate(auth_client, repository_id, "markdown") + + error = assert_error_response(response, 404, "not_found") + assert "sealed Repository Intelligence snapshot" in error.message + + +def test_documentation_architecture_groups_modules_into_layers_from_snapshot_facts(auth_client): + repository_id = _upload_and_analyse( + auth_client, + { + "src/controllers/user_controller.ts": ( + "import { getUser } from '../services/user_service';\nexport const handler = () => getUser();\n" + ), + "src/services/user_service.ts": "export function getUser() { return 1; }\n", + }, + ) + + response = _generate(auth_client, repository_id, "markdown") + + assert response.status_code == 200 + architecture = _markdown_section(response.json()["content"], "Architecture") + assert "Presentation" in architecture + assert "Business Logic" in architecture + assert "Presentation → Business Logic: 1 observed relationship(s)" in architecture + + +def test_documentation_architecture_reports_shared_layer_for_unclassified_repository(auth_client): + repository_id = _upload_and_analyse( + auth_client, + { + "assets/logo.svg": "\n", + "data/sample.csv": "a,b\n1,2\n", + }, + ) + + response = _generate(auth_client, repository_id, "markdown") + + assert response.status_code == 200 + architecture = _markdown_section(response.json()["content"], "Architecture") + assert "Shared" in architecture + assert "No cross-layer relationships observed in the supported snapshot facts." in architecture + + +def test_documentation_deployment_section_matches_real_deployment_yaml_only(auth_client): + repository_id = _upload_and_analyse( + auth_client, + { + "docker-compose.yml": "services:\n api:\n image: sample\n", + ".github/workflows/ci.yml": "name: CI\non: push\n", + "Dockerfile": "FROM python:3.12\n", + "config/app.yaml": "debug: true\n", + }, + ) + + response = _generate(auth_client, repository_id, "markdown") + + assert response.status_code == 200 + deployment = _markdown_section(response.json()["content"], "Deployment") + assert "docker-compose.yml" in deployment + assert ".github/workflows/ci.yml" in deployment + assert "Dockerfile" in deployment + assert "config/app.yaml" not in deployment + + +def test_documentation_api_section_excludes_test_fixture_routes(auth_client): + """Reproduces the 2026-08-20 audit finding that generated API + documentation listed benchmark test-fixture routes (e.g. /config, + /test) undistinguished from real API routes (#337).""" + + repository_id = _upload_and_analyse( + auth_client, + { + "src/routes.py": ( + "from fastapi import FastAPI\n\n" + "app = FastAPI()\n\n\n" + '@app.get("/users")\n' + "def list_users():\n" + " return []\n" + ), + "tests/fixtures/routes.py": ( + "from fastapi import FastAPI\n\n" + "app = FastAPI()\n\n\n" + '@app.get("/config")\n' + "def config():\n" + " return {}\n\n\n" + '@app.get("/test")\n' + "def test_route():\n" + " return {}\n" + ), + }, + ) + + response = auth_client.post( + "/documentation/generate", + json={"repositoryId": repository_id, "format": "markdown", "sections": ["api"]}, + ) + + assert response.status_code == 200, response.text + api_section = _markdown_section(response.json()["content"], "API") + assert "/users" in api_section + assert "src/routes.py" in api_section + assert "/config" not in api_section + assert "/test" not in api_section + assert "tests/fixtures/routes.py" not in api_section + + +def test_documentation_is_owner_scoped(auth_client, make_auth_headers): + repository_id = _import_sample(auth_client) + other = make_auth_headers("other-docs@example.com") + + response = auth_client.post( + "/documentation/generate", + headers=other["headers"], + json={"repositoryId": repository_id, "format": "markdown"}, + ) + + assert_error_response(response, 404, "not_found") diff --git a/apps/backend/tests/test_engineering_review_v2.py b/apps/backend/tests/test_engineering_review_v2.py new file mode 100644 index 00000000..8adf0c12 --- /dev/null +++ b/apps/backend/tests/test_engineering_review_v2.py @@ -0,0 +1,659 @@ +"""Public Engineering Review v2 contract and provenance tests (#154).""" + +from __future__ import annotations + +import io +import json +import zipfile +from collections import Counter +from dataclasses import dataclass + +from app.intelligence.snapshot_store import Evidence, Revision, SnapshotStore +from app.models.repository import RepositoryRecord + +from tests.analysis_helpers import run_analysis_jobs + +_FINDING_SOURCES = { + "README.md": b"# review fixture\n", + "src/index.ts": (b"import { missing } from './missing';\nexport const value = missing();\n"), +} +_EMPTY_SOURCES = { + "README.md": b"# review fixture with no supported diagnostics\n", +} + + +def _archive(files: dict[str, bytes]) -> bytes: + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as archive: + for path, content in files.items(): + archive.writestr(path, content) + return buffer.getvalue() + + +def _upload(auth_client, files: dict[str, bytes], *, analyse: bool = True) -> dict: + response = auth_client.post( + "/repositories/upload", + files={"file": ("review-v2.zip", _archive(files), "application/zip")}, + ) + assert response.status_code == 201, response.text + repository = response.json() + if analyse: + assert auth_client.post(f"/analysis/{repository['id']}/start").status_code == 200 + assert run_analysis_jobs() == 1 + return repository + + +@dataclass(frozen=True) +class _FileDiagnostic: + """A file-level diagnostic, as the extractors emit them: path, but no span.""" + + path: str + details: dict[str, object] | None = None + object_key: str | None = None + + @property + def subject_key(self) -> str: + return f"file:{self.path}" + + +def _assert_forbidden_score_fields(value) -> None: + forbidden = { + "score", + "scores", + "overallScore", + "categoryScore", + "grade", + "healthPercentage", + "roadmap", + "trend", + } + if isinstance(value, dict): + assert not (set(value) & forbidden) + for nested in value.values(): + _assert_forbidden_score_fields(nested) + elif isinstance(value, list): + for nested in value: + _assert_forbidden_score_fields(nested) + + +def test_review_requires_authentication(client): + response = client.get("/analysis/11111111-1111-1111-1111-111111111111/review") + assert response.status_code == 401 + + +def test_review_is_owner_scoped(auth_client, make_auth_headers): + repository = _upload(auth_client, _FINDING_SOURCES) + intruder = make_auth_headers("review-intruder@example.com") + + response = auth_client.get( + f"/analysis/{repository['id']}/review", + headers=intruder["headers"], + ) + + assert response.status_code == 404 + + +def test_review_without_a_sealed_snapshot_returns_404(auth_client): + repository = _upload(auth_client, _FINDING_SOURCES, analyse=False) + + response = auth_client.get(f"/analysis/{repository['id']}/review") + + assert response.status_code == 404 + + +def test_review_contract_has_no_scores_and_is_deterministic(auth_client): + repository = _upload(auth_client, _FINDING_SOURCES) + + first = auth_client.get(f"/analysis/{repository['id']}/review") + second = auth_client.get(f"/analysis/{repository['id']}/review") + + assert first.status_code == 200, first.text + assert first.json() == second.json() + body = first.json() + _assert_forbidden_score_fields(body) + assert body["schemaVersion"] == "engineering-review.v2" + assert body["repositoryId"] == repository["id"] + assert body["revisionValue"] == repository["revision"]["value"] + assert body["snapshotSchemaVersion"] == "ri.v1" + assert body["canonicalGraphHash"].startswith("sha256:") + assert body["manifestDigest"].startswith("sha256:") + assert body["provenance"]["source"] == "ri.v1" + assert body["assessmentStatus"] == "partially_assessed" + assert len({item["id"] for item in body["findings"]}) == len(body["findings"]) + + +def test_every_public_finding_has_same_snapshot_evidence_that_opens(auth_client): + repository = _upload(auth_client, _FINDING_SOURCES) + review = auth_client.get(f"/analysis/{repository['id']}/review").json() + + assert review["findings"], "fixture must produce a supported resolver diagnostic" + # Resolver diagnostics carry their own span, so every finding here must be + # span-exact. A file-scoped finding in this fixture would mean support + # selection had fallen back to whole-file evidence. + assert review["summary"]["fileScopedFindingCount"] == 0 + assert review["summary"]["evidenceBackedFindingCount"] == len(review["findings"]) + for finding in review["findings"]: + assert finding["supportStatus"] == "supported" + assert finding["snapshotId"] == review["snapshotId"] + assert finding["evidence"]["snapshotId"] == review["snapshotId"] + assert finding["factId"] == finding["evidence"]["factId"] + assert finding["evidenceId"] == finding["evidence"]["evidenceId"] + source = auth_client.get( + f"/analysis/{repository['id']}/evidence", + params={ + "snapshotId": finding["snapshotId"], + "factId": finding["factId"], + "path": finding["path"], + "startLine": finding["startLine"], + "endLine": finding["endLine"], + }, + ) + assert source.status_code == 200, source.text + assert source.json()["status"] == "ready" + + +def test_review_wrong_repository_evidence_fails_closed(auth_client): + repository_a = _upload(auth_client, _FINDING_SOURCES) + finding = auth_client.get(f"/analysis/{repository_a['id']}/review").json()["findings"][0] + repository_b = _upload(auth_client, {"README.md": b"# other revision\n"}) + + response = auth_client.get( + f"/analysis/{repository_b['id']}/evidence", + params={ + "snapshotId": finding["snapshotId"], + "factId": finding["factId"], + "path": finding["path"], + "startLine": finding["startLine"], + "endLine": finding["endLine"], + }, + ) + + assert response.status_code == 404 + + +def test_review_category_matrix_and_honest_empty_finding_state(auth_client): + repository = _upload(auth_client, _EMPTY_SOURCES) + body = auth_client.get(f"/analysis/{repository['id']}/review").json() + + assert body["findings"] == [] + assert body["summary"]["evidenceBackedFindingCount"] == 0 + assert "0 evidence-backed findings" in body["summary"]["message"] + categories = {item["id"]: item for item in body["categories"]} + assert set(categories) == { + "architecture_boundaries", + "relationship_resolution", + "source_extraction", + "dependency_declarations", + "security_vulnerability_scanning", + "authentication_evidence", + "repository_structure", + "analysis_integrity", + } + assert categories["analysis_integrity"]["state"] == "assessed" + assert categories["dependency_declarations"]["state"] == "insufficient_evidence" + assert categories["security_vulnerability_scanning"]["state"] == "not_assessed" + assert all("score" not in item["explanation"].lower() for item in categories.values()) + + +# --- unresolved-import disposition: stdlib and declared dependencies never +# become a finding, but a genuine gap still does (#412) -------------------- + + +def _relationship_findings(body: dict) -> list[dict]: + return [item for item in body["findings"] if item["category"] == "relationship_resolution"] + + +def test_a_bare_stdlib_import_is_not_a_finding(auth_client): + """`import os` / `from pathlib import Path` are language, not a gap. + + The Django-boilerplate pattern this reproduces: config files that are + almost entirely stdlib imports, none of which live in the scanned repo + and none of which should ever have resolved to an in-repo edge. + """ + + repository = _upload( + auth_client, + { + "README.md": b"# stdlib-only fixture\n", + # No calls to any bare imported name here on purpose: that would + # trip the separate, unrelated "calls has no resolvable target" + # gap (#393) this fixture isn't testing. + "app.py": b"import os\nimport sys\nfrom pathlib import Path\n\nBASE_DIR = Path\n", + }, + ) + + body = auth_client.get(f"/analysis/{repository['id']}/review").json() + + assert _relationship_findings(body) == [] + + +def test_an_import_declared_in_requirements_txt_is_not_a_finding(auth_client): + repository = _upload( + auth_client, + { + "README.md": b"# declared dependency fixture\n", + "requirements.txt": b"requests==2.34.2\n", + "app.py": b"import requests\n\nrequests.get('https://api.example.com/health')\n", + }, + ) + + body = auth_client.get(f"/analysis/{repository['id']}/review").json() + + assert _relationship_findings(body) == [] + + +def test_an_import_that_is_neither_stdlib_nor_declared_is_still_a_finding(auth_client): + """A real gap: not the language, not in requirements.txt, not same-repo.""" + + repository = _upload( + auth_client, + { + "README.md": b"# undeclared dependency fixture\n", + "requirements.txt": b"requests==2.34.2\n", + "app.py": b"import totallymadeupdependency\n", + }, + ) + + body = auth_client.get(f"/analysis/{repository['id']}/review").json() + + findings = _relationship_findings(body) + assert len(findings) == 1 + assert findings[0]["path"] == "app.py" + + +def test_django_boilerplate_config_files_produce_no_import_noise(auth_client): + """The real pattern that motivated this fix: a Django project's own + config boilerplate (asgi.py/wsgi.py/settings.py/urls.py/manage.py) is + almost entirely stdlib imports plus a declared Django dependency -- + real analysis of a repo like this previously surfaced hundreds of + "imports has no resolvable target" findings for exactly this pattern. + """ + + repository = _upload( + auth_client, + { + "requirements.txt": b"Django==5.0.6\ngunicorn==22.0.0\n", + "manage.py": ( + b"import os\nimport sys\n\n\n" + b"def main():\n" + b' os.environ.setdefault("DJANGO_SETTINGS_MODULE", "server.config.settings")\n' + b" sys.exit(0)\n" + ), + "server/config/__init__.py": b"", + "server/config/asgi.py": ( + b"import os\n\nfrom django.core.asgi import get_asgi_application\n\n" + b'os.environ.setdefault("DJANGO_SETTINGS_MODULE", "server.config.settings")\n' + ), + "server/config/wsgi.py": ( + b"import os\n\nfrom django.core.wsgi import get_wsgi_application\n\n" + b'os.environ.setdefault("DJANGO_SETTINGS_MODULE", "server.config.settings")\n' + ), + "server/config/settings.py": (b"import os\nimport sys\nfrom pathlib import Path\n\nBASE_DIR = Path\n"), + "server/config/urls.py": b"from django.contrib import admin\n\nroutes = admin\n", + }, + ) + + body = auth_client.get(f"/analysis/{repository['id']}/review").json() + + import_findings = [ + item for item in _relationship_findings(body) if item["explanation"] == "imports has no resolvable target" + ] + assert import_findings == [] + + +def test_a_genuinely_broken_relative_import_is_still_a_finding(auth_client): + """A relative import has no package root at all -- it can never be an + external dependency, so it must never be suppressed as one (#412).""" + + repository = _upload( + auth_client, + { + "README.md": b"# broken relative import fixture\n", + # `missing` is never called here on purpose: calling it would also + # trip the separate, unrelated "calls has no resolvable target" + # gap (#393), which is not what this test is isolating. + "src/index.ts": b"import { missing } from './missing';\nexport const unused = 1;\n", + }, + ) + + body = auth_client.get(f"/analysis/{repository['id']}/review").json() + + findings = _relationship_findings(body) + assert len(findings) == 1 + assert findings[0]["path"] == "src/index.ts" + + +def _seal_snapshot_with_file_diagnostic(auth_client, *, granularity: str) -> dict: + """Seal a snapshot whose only rule-matching diagnostic records no span. + + ``RI-SRC-MALFORMED`` and ``RI-LIMIT-SKIP`` are file-level by construction: + the extractors emit them with a path and no span. The stored evidence for + that path decides whether such a diagnostic can be published at all, so the + fixture controls its granularity directly. + """ + + repository = _upload(auth_client, _EMPTY_SOURCES, analyse=False) + + from app.core.database import SessionLocal + + with SessionLocal() as session: + record = session.get(RepositoryRecord, repository["id"]) + assert record is not None + store = SnapshotStore(session) + snapshot = store.begin( + repository_id=record.id, + revision=Revision(record.revision_kind, record.revision_value, record.revision_ref), + schema_version="ri.v1", + producer_version_set=["typescript-extractor@1.0.0", "repository-inventory@1.1.0"], + ) + store.add_node( + snapshot, + node_kind="repository", + stable_key="repo:root", + name="repository", + language=None, + evidence=[ + Evidence( + path="README.md", + start_line=1, + end_line=1, + logical_line_count=2, + extractor="repository-inventory", + extractor_version="1.1.0", + granularity="file", + ) + ], + ) + store.add_node( + snapshot, + node_kind="file", + stable_key="file:src/app.ts", + name="app.ts", + language="TypeScript", + evidence=[ + Evidence( + path="src/app.ts", + # Deliberately not line 1: if support selection ever borrows + # this span for a span-less diagnostic, the finding would + # point at lines the diagnostic never named. + start_line=10, + end_line=12, + logical_line_count=40, + extractor="typescript-extractor", + extractor_version="1.0.0", + granularity=granularity, + ) + ], + ) + store.add_diagnostic( + snapshot, + code="RI-SRC-MALFORMED", + category="malformed source", + severity="error", + message="file is not valid UTF-8 and could not be decoded", + producer="typescript-extractor@1.0.0", + path="src/app.ts", + span=None, + subject="file:src/app.ts", + ) + store.seal(snapshot) + + response = auth_client.get(f"/analysis/{repository['id']}/review") + assert response.status_code == 200, response.text + return response.json() + + +def test_a_span_less_diagnostic_never_borrows_a_line_addressed_evidence_span(auth_client): + """A span-less diagnostic must not be published at someone else's span. + + The only evidence for the path is a line-addressed span the diagnostic never + named. Publishing it would invent a location and still label the finding + `supported`, so the diagnostic has to be omitted and counted instead. + """ + + body = _seal_snapshot_with_file_diagnostic(auth_client, granularity="span") + + assert body["findings"] == [] + assert body["summary"]["evidenceBackedFindingCount"] == 0 + assert body["summary"]["omittedUnsupportedDiagnosticCount"] >= 1 + assert body["summary"]["fileScopedFindingCount"] == 0 + + +def test_a_file_level_diagnostic_is_published_as_file_scoped_not_as_span_exact(auth_client): + """File-granularity evidence supports the finding, and says so.""" + + body = _seal_snapshot_with_file_diagnostic(auth_client, granularity="file") + + assert len(body["findings"]) == 1 + finding = body["findings"][0] + assert finding["diagnosticCode"] == "RI-SRC-MALFORMED" + # The span is the file's own evidence record, and the support status states + # that it covers the file rather than the exact defect. + assert finding["supportStatus"] == "file_scoped" + assert finding["path"] == "src/app.ts" + assert (finding["startLine"], finding["endLine"]) == (10, 12) + assert body["summary"]["fileScopedFindingCount"] == 1 + assert body["summary"]["evidenceBackedFindingCount"] == 1 + + +def test_review_evidence_reads_stay_within_the_sql_parameter_bound(client): + """Review reads must be batched, not sized by the repository. + + Every id set the review binds grows with the number of diagnostics. Binding + them in one statement makes a large snapshot exceed SQLite's per-statement + parameter cap, so a review of a big repository fails with a database error + instead of returning. The id set here is deliberately larger than two + batches: an unbatched read would bind all of it in one statement and exceed + the asserted bound, so the assertion fails if the batching is removed. + + The ``client`` fixture is taken only to create the schema these reads run + against, so the test does not depend on another test having run first. + """ + + from sqlalchemy import event + + from app.core.database import SessionLocal + from app.intelligence.query_service import SNAPSHOT_IN_CLAUSE_BATCH_SIZE, SnapshotQueryService + from app.review.review_service import EngineeringReviewBuilder + + identifier_count = SNAPSHOT_IN_CLAUSE_BATCH_SIZE * 2 + 1 + diagnostics = [_FileDiagnostic(path=f"src/module-{index:05d}.ts") for index in range(identifier_count)] + + parameter_counts: list[int] = [] + + def record_parameters(_conn, _cursor, _statement, parameters, _context, executemany): + if not executemany and parameters is not None: + parameter_counts.append(len(parameters)) + + with SessionLocal() as session: + engine = session.get_bind() + event.listen(engine, "before_cursor_execute", record_parameters) + try: + builder = EngineeringReviewBuilder(SnapshotQueryService(session, "unused-owner")) + # No snapshot has this id, so the reads return nothing. What matters + # is the shape of the statements the builder issues to find that out. + builder._evidence_by_fact("snap_absent", diagnostics) + finally: + event.remove(engine, "before_cursor_execute", record_parameters) + + assert parameter_counts, "expected to observe the evidence lookup's SQL parameters" + # One batch of ids plus the snapshot id and any other fixed predicates. + assert max(parameter_counts) <= SNAPSHOT_IN_CLAUSE_BATCH_SIZE + 8 + # And the work was actually spread across batches rather than skipped. + assert len(parameter_counts) >= 3 + + +def test_assessment_status_is_derived_from_the_category_states(auth_client): + """The overall status must summarise the matrix, not assert a constant.""" + + from app.review.review_service import _overall_assessment_status + + body = _seal_snapshot_with_file_diagnostic(auth_client, granularity="file") + summary = body["summary"] + states = Counter(category["state"] for category in body["categories"]) + + assert body["assessmentStatus"] == _overall_assessment_status(states) + assert states["assessed"] == summary["assessedCategories"] + assert states["not_assessed"] == summary["notAssessedCategories"] + # A matrix containing a not_assessed category cannot report full assessment. + assert summary["notAssessedCategories"] >= 1 + assert body["assessmentStatus"] != "assessed" + + +def test_overall_assessment_status_never_overstates_coverage(): + from app.review.review_service import _overall_assessment_status + + assert _overall_assessment_status(Counter({"assessed": 8})) == "assessed" + assert _overall_assessment_status(Counter({"assessed": 4, "not_assessed": 4})) == "partially_assessed" + assert _overall_assessment_status(Counter({"not_assessed": 8})) == "not_assessed" + assert ( + _overall_assessment_status(Counter({"insufficient_evidence": 2, "not_assessed": 6})) == "insufficient_evidence" + ) + + +def _multi_finding_sources(finding_count: int) -> dict[str, bytes]: + """Build a fixture producing exactly ``finding_count`` findings. + + Each module below contributes two ``RI-RES-UNRESOLVED`` findings (the + unresolved import specifier and the unresolved call reference), verified + against the same source pattern as ``_FINDING_SOURCES``. ``finding_count`` + must be even. + """ + + assert finding_count % 2 == 0, "finding_count must be even (2 findings per module)" + sources: dict[str, bytes] = {"README.md": b"# review pagination fixture\n"} + for index in range(finding_count // 2): + sources[f"src/module-{index:03d}.ts"] = ( + f"import {{ missing_{index} }} from './missing-{index}';\nexport const value_{index} = missing_{index}();\n" + ).encode() + return sources + + +def test_review_default_page_is_bounded_and_reports_total(auth_client): + repository = _upload(auth_client, _multi_finding_sources(60)) + + body = auth_client.get(f"/analysis/{repository['id']}/review").json() + + assert body["pagination"]["offset"] == 0 + assert body["pagination"]["limit"] == 50 + assert body["pagination"]["total"] == 60 + assert len(body["findings"]) == 50 + # The matrix and severity summary always describe the whole snapshot, + # never just the returned page. + assert body["summary"]["evidenceBackedFindingCount"] == 60 + + +def test_review_pagination_offset_and_limit_are_respected(auth_client): + repository = _upload(auth_client, _multi_finding_sources(60)) + + first_page = auth_client.get(f"/analysis/{repository['id']}/review", params={"limit": 20}).json() + second_page = auth_client.get(f"/analysis/{repository['id']}/review", params={"offset": 20, "limit": 20}).json() + last_page = auth_client.get(f"/analysis/{repository['id']}/review", params={"offset": 40, "limit": 20}).json() + past_the_end = auth_client.get(f"/analysis/{repository['id']}/review", params={"offset": 60, "limit": 20}).json() + + assert len(first_page["findings"]) == 20 + assert len(second_page["findings"]) == 20 + assert len(last_page["findings"]) == 20 + assert past_the_end["findings"] == [] + assert past_the_end["pagination"] == {"offset": 60, "limit": 20, "total": 60} + + # Pages are disjoint and, concatenated, reconstruct the full sorted set. + first_ids = [item["id"] for item in first_page["findings"]] + second_ids = [item["id"] for item in second_page["findings"]] + last_ids = [item["id"] for item in last_page["findings"]] + assert len(set(first_ids) & set(second_ids) & set(last_ids)) == 0 + full = auth_client.get(f"/analysis/{repository['id']}/review", params={"limit": 200}).json() + assert first_ids + second_ids + last_ids == [item["id"] for item in full["findings"]] + + +def test_review_limit_is_capped_and_offset_cannot_be_negative(auth_client): + repository = _upload(auth_client, _FINDING_SOURCES) + + too_large = auth_client.get(f"/analysis/{repository['id']}/review", params={"limit": 500}) + negative_offset = auth_client.get(f"/analysis/{repository['id']}/review", params={"offset": -1}) + + assert too_large.status_code == 422 + assert negative_offset.status_code == 422 + + +def test_review_diagnostic_code_filter_narrows_findings_without_changing_the_matrix(auth_client): + repository = _upload(auth_client, _FINDING_SOURCES) + unfiltered = auth_client.get(f"/analysis/{repository['id']}/review").json() + code = unfiltered["findings"][0]["diagnosticCode"] + + filtered = auth_client.get( + f"/analysis/{repository['id']}/review", params={"diagnosticCode": "RI-NO-SUCH-CODE"} + ).json() + matched = auth_client.get(f"/analysis/{repository['id']}/review", params={"diagnosticCode": code}).json() + + assert filtered["findings"] == [] + assert filtered["pagination"]["total"] == 0 + assert all(item["diagnosticCode"] == code for item in matched["findings"]) + assert matched["pagination"]["total"] == len( + [item for item in unfiltered["findings"] if item["diagnosticCode"] == code] + ) + # Filtering the findings page never changes the whole-snapshot matrix or + # severity summary -- those stay a complete, honest description of the + # sealed snapshot regardless of what the caller is currently viewing. + assert filtered["categories"] == unfiltered["categories"] + assert filtered["summary"] == unfiltered["summary"] + + +def test_review_export_is_never_truncated_by_pagination(auth_client): + """The PDF/JSON export path must keep receiving every finding. + + Only the interactive `/review` route paginates; `AnalysisService. + engineering_review` defaults `offset`/`limit` to ``None`` for every other + caller (export, AI context), so a review with more findings than the + default page size must still export in full. + """ + + repository = _upload(auth_client, _multi_finding_sources(60)) + + exported = auth_client.post( + "/export", + json={"repositoryId": repository["id"], "target": "review", "format": "json"}, + ) + + assert exported.status_code == 200, exported.text + payload = json.loads(exported.json()["content"]) + assert len(payload["findings"]) == 60 + assert payload["pagination"] == {"offset": 0, "limit": 60, "total": 60} + + +def test_review_rejects_an_unsupported_snapshot_schema(auth_client): + repository = _upload(auth_client, _EMPTY_SOURCES, analyse=False) + + from app.core.database import SessionLocal + + with SessionLocal() as session: + record = session.get(RepositoryRecord, repository["id"]) + assert record is not None + store = SnapshotStore(session) + snapshot = store.begin( + repository_id=record.id, + revision=Revision(record.revision_kind, record.revision_value, record.revision_ref), + schema_version="ri.v99", + producer_version_set=["repository-inventory@1.1.0"], + ) + store.add_node( + snapshot, + node_kind="repository", + stable_key="repo:root", + name="repository", + language=None, + evidence=[ + Evidence( + path="README.md", + start_line=1, + end_line=1, + logical_line_count=2, + extractor="repository-inventory", + extractor_version="1.1.0", + ) + ], + ) + store.seal(snapshot) + + response = auth_client.get(f"/analysis/{repository['id']}/review") + assert response.status_code == 422 diff --git a/apps/backend/tests/test_evidence_source.py b/apps/backend/tests/test_evidence_source.py new file mode 100644 index 00000000..1ad7e0e5 --- /dev/null +++ b/apps/backend/tests/test_evidence_source.py @@ -0,0 +1,454 @@ +from __future__ import annotations + +import io +import zipfile +from urllib.parse import urlencode + +from app.extraction.pipeline import ExtractionPipeline +from app.extraction.python import PythonExtractor +from app.intelligence.classification import RoleClassifier +from app.intelligence.resolution import RelationshipResolver +from app.intelligence.snapshot_store import Evidence, Revision, SnapshotStore +from app.models.repository import RepositoryRecord +from app.models.snapshot import RiEvidence + +from tests.analysis_helpers import run_analysis_jobs + +_SOURCES = { + "README.md": b"# evidence fixture\n", + "src/routes.py": ( + b"from fastapi import FastAPI, Depends\n" + b"from src.dependencies import get_current_user\n\n" + b"app = FastAPI()\n\n\n" + b'@app.get("/me")\n' + b"def read_me(user=Depends(get_current_user)):\n" + b" return user\n" + ), + "src/dependencies.py": b"def get_current_user(token: str) -> str:\n return token\n", +} +_ROUTE_FACT_ID = "src/routes.py::(anonymous:route#1)" + + +def _upload(auth_client, files: dict[str, bytes]) -> dict: + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as archive: + for path, content in files.items(): + archive.writestr(path, content) + response = auth_client.post( + "/repositories/upload", + files={"file": ("evidence.zip", buffer.getvalue(), "application/zip")}, + ) + assert response.status_code == 201, response.text + repository = response.json() + assert auth_client.post(f"/analysis/{repository['id']}/start").status_code == 200 + assert run_analysis_jobs() == 1 + return repository + + +def _evidence(item, extractor: str, version: str) -> Evidence: + return Evidence( + path=item.path, + start_line=item.start_line, + end_line=item.end_line, + extractor=extractor, + extractor_version=version, + logical_line_count=item.logical_line_count, + granularity=item.granularity, + ) + + +def _persist_snapshot(repository_id: str, sources: dict[str, bytes], *, schema_version: str | None = None) -> str: + from app.core.database import SessionLocal + + pipeline = ExtractionPipeline([PythonExtractor()]) + runs = pipeline.run(sources) + producer_version_set = sorted( + {run.producer for run in runs} + | { + f"{RelationshipResolver.name}@{RelationshipResolver.version}", + f"{RoleClassifier.name}@{RoleClassifier.version}", + } + ) + + with SessionLocal() as session: + repository = session.get(RepositoryRecord, repository_id) + assert repository is not None + store = SnapshotStore(session) + begin_kwargs = {} if schema_version is None else {"schema_version": schema_version} + snapshot = store.begin( + repository_id=repository.id, + revision=Revision(repository.revision_kind, repository.revision_value, repository.revision_ref), + producer_version_set=producer_version_set, + **begin_kwargs, + ) + for run in runs: + for node in run.result.nodes: + store.add_node( + snapshot, + node_kind=node.node_kind, + stable_key=node.stable_key, + name=node.name, + language=node.language, + properties=node.properties, + set_array_keys=( + frozenset({"decorators"}) + if node.properties and "decorators" in node.properties + else frozenset() + ), + evidence=[_evidence(item, run.producer_name, run.producer_version) for item in node.evidence], + ) + for observation in run.result.observations: + store.add_observation( + snapshot, + observed_kind=observation.observed_kind, + subject_kind=observation.subject_kind, + subject_key=observation.subject_key, + referent_text=observation.referent_text, + ordinal=observation.ordinal, + evidence=_evidence(observation.evidence, run.producer_name, run.producer_version), + ) + RelationshipResolver(store).resolve(snapshot) + RoleClassifier(store).classify(snapshot) + return store.seal(snapshot).snapshot_id + + +def _evidence_url( + repository_id: str, + snapshot_id: str, + path: str, + start_line: int, + end_line: int, + *, + fact_id: str = _ROUTE_FACT_ID, +) -> str: + return f"/analysis/{repository_id}/evidence?" + urlencode( + { + "snapshotId": snapshot_id, + "factId": fact_id, + "path": path, + "startLine": start_line, + "endLine": end_line, + } + ) + + +def test_evidence_source_returns_exact_cited_revision_content(auth_client): + repository = _upload(auth_client, _SOURCES) + snapshot_id = _persist_snapshot(repository["id"], _SOURCES) + + response = auth_client.get(_evidence_url(repository["id"], snapshot_id, "src/routes.py", 7, 7)) + assert response.status_code == 200, response.text + body = response.json() + + assert body["status"] == "ready" + assert body["snapshotId"] == snapshot_id + assert body["factId"] == _ROUTE_FACT_ID + assert body["revisionKind"] == "upload" + assert body["revisionValue"] == repository["revision"]["value"] + assert body["path"] == "src/routes.py" + assert body["startLine"] == 7 + assert body["endLine"] == 7 + assert body["content"] is not None + assert "def read_me" in body["content"] + + +def test_evidence_source_rejects_snapshot_from_another_repository(auth_client): + repository_a = _upload(auth_client, _SOURCES) + snapshot_a = _persist_snapshot(repository_a["id"], _SOURCES) + repository_b = _upload(auth_client, {"README.md": b"# other\n", "src/plain.py": b"x = 1\n"}) + + response = auth_client.get(_evidence_url(repository_b["id"], snapshot_a, "src/routes.py", 7, 7)) + assert response.status_code == 404 + + +def test_evidence_source_is_owner_scoped(auth_client, make_auth_headers): + repository = _upload(auth_client, _SOURCES) + snapshot_id = _persist_snapshot(repository["id"], _SOURCES) + + other = make_auth_headers("other-owner@example.com") + response = auth_client.get( + _evidence_url(repository["id"], snapshot_id, "src/routes.py", 7, 7), + headers=other["headers"], + ) + assert response.status_code == 404 + + +def test_evidence_source_checks_owner_before_required_query_validation(auth_client, make_auth_headers): + repository = _upload(auth_client, _SOURCES) + + other = make_auth_headers("other-owner-validation@example.com") + response = auth_client.get( + f"/analysis/{repository['id']}/evidence", + headers=other["headers"], + params={"path": "src/routes.py"}, + ) + assert response.status_code == 404 + + +def test_evidence_source_rejects_unrecognized_snapshot(auth_client): + repository = _upload(auth_client, _SOURCES) + response = auth_client.get(_evidence_url(repository["id"], "snap_does_not_exist", "src/routes.py", 7, 7)) + assert response.status_code == 404 + + +def test_evidence_source_rejects_unsealed_snapshot(auth_client): + from app.core.database import SessionLocal + + repository = _upload(auth_client, _SOURCES) + pipeline = ExtractionPipeline([PythonExtractor()]) + runs = pipeline.run(_SOURCES) + + with SessionLocal() as session: + record = session.get(RepositoryRecord, repository["id"]) + assert record is not None + store = SnapshotStore(session) + snapshot = store.begin( + repository_id=record.id, + revision=Revision(record.revision_kind, record.revision_value, record.revision_ref), + producer_version_set=sorted({run.producer for run in runs}), + ) + for run in runs: + for node in run.result.nodes: + store.add_node( + snapshot, + node_kind=node.node_kind, + stable_key=node.stable_key, + name=node.name, + language=node.language, + properties=node.properties, + set_array_keys=( + frozenset({"decorators"}) + if node.properties and "decorators" in node.properties + else frozenset() + ), + evidence=[_evidence(item, run.producer_name, run.producer_version) for item in node.evidence], + ) + building_snapshot_id = snapshot.snapshot_id + # Deliberately never sealed: `state` remains "building". + + response = auth_client.get(_evidence_url(repository["id"], building_snapshot_id, "src/routes.py", 1, 1)) + assert response.status_code == 404 + + +def test_evidence_source_rejects_unsupported_schema_version(auth_client): + repository = _upload(auth_client, _SOURCES) + # An unsupported schema version is set at snapshot construction time (the + # only legitimate way it could ever arise -- e.g. after a future schema + # migration), not by mutating an already-sealed snapshot: sealed rows are + # immutable through both the ORM and a dedicated bulk-mutation guard. + snapshot_id = _persist_snapshot(repository["id"], _SOURCES, schema_version="ri.v99") + + response = auth_client.get(_evidence_url(repository["id"], snapshot_id, "src/routes.py", 7, 7)) + assert response.status_code == 422 + + +def test_repository_revision_and_its_sealed_snapshots_cannot_diverge(auth_client): + """The revision-mismatch state ``EvidenceSourceService`` defends against + is not reachable in this schema: ``fk_ri_snapshots_repository_revision`` + is a composite foreign key from ``ri_snapshots(repository_id, + revision_kind, revision_value)`` to ``repositories(id, revision_kind, + revision_value)``, so the database itself refuses to let a repository's + revision change out from under a snapshot that cites it. This proves the + invariant at its strongest layer; ``EvidenceSourceService`` still checks + it explicitly as defense-in-depth (e.g. for a future schema that relaxes + this constraint), which is why the check remains in the service even + though this test cannot force it to trigger through the live API.""" + + import pytest + from sqlalchemy import update + from sqlalchemy.exc import IntegrityError as SAIntegrityError + + from app.core.database import SessionLocal + + repository = _upload(auth_client, _SOURCES) + _persist_snapshot(repository["id"], _SOURCES) + + with SessionLocal() as session: + # A Core-level statement bypasses the ORM's own immutable-revision + # validator (which would otherwise raise first) so the underlying + # database constraint itself is what is being proven here. SQLite + # enforces the foreign key immediately, at statement-execution time. + with pytest.raises(SAIntegrityError): + session.execute( + update(RepositoryRecord) + .where(RepositoryRecord.id == repository["id"]) + .values(revision_value="sha256:" + "f" * 64) + ) + session.commit() + + +def test_evidence_source_reports_unavailable_when_path_not_in_snapshot(auth_client): + repository = _upload(auth_client, _SOURCES) + snapshot_id = _persist_snapshot(repository["id"], _SOURCES) + + response = auth_client.get(_evidence_url(repository["id"], snapshot_id, "src/never_extracted.py", 1, 1)) + assert response.status_code == 200 + body = response.json() + assert body["status"] == "unavailable" + assert body["content"] is None + + +def test_evidence_source_reports_unavailable_for_forged_in_range_span(auth_client): + repository = _upload(auth_client, _SOURCES) + snapshot_id = _persist_snapshot(repository["id"], _SOURCES) + + # The path is genuine and this line exists, but (3, 3) is not one of the + # exact evidence spans persisted for src/routes.py. A manipulated link + # must not receive a verified badge for arbitrary source on a cited path. + response = auth_client.get(_evidence_url(repository["id"], snapshot_id, "src/routes.py", 3, 3)) + assert response.status_code == 200 + body = response.json() + assert body["status"] == "unavailable" + assert body["content"] is None + + +def test_evidence_source_reports_unavailable_when_fact_id_does_not_match_span(auth_client): + repository = _upload(auth_client, _SOURCES) + snapshot_id = _persist_snapshot(repository["id"], _SOURCES) + + response = auth_client.get( + _evidence_url( + repository["id"], + snapshot_id, + "src/routes.py", + 7, + 7, + fact_id="src/routes.py::read_me", + ) + ) + assert response.status_code == 200 + assert response.json()["status"] == "unavailable" + assert response.json()["content"] is None + + +def test_evidence_source_rejects_path_traversal(auth_client): + repository = _upload(auth_client, _SOURCES) + snapshot_id = _persist_snapshot(repository["id"], _SOURCES) + + response = auth_client.get(_evidence_url(repository["id"], snapshot_id, "../../../etc/passwd", 1, 1)) + # Whether caught by the evidence-membership check or the filesystem + # traversal guard, the exact file content must never be exposed as ready. + if response.status_code == 200: + assert response.json()["status"] == "unavailable" + else: + assert response.status_code == 422 + + +def test_evidence_source_second_layer_rejects_a_maliciously_stored_traversal_path(auth_client): + """Defense in depth: even if a bad path were ever stored as real evidence, + the filesystem-level traversal guard in ``RepositoryService.read_file`` + must still refuse to serve it.""" + + from sqlalchemy import insert, select + + from app.core.database import SessionLocal + from app.models.snapshot import RiNode + + repository = _upload(auth_client, _SOURCES) + snapshot_id = _persist_snapshot(repository["id"], _SOURCES) + + with SessionLocal() as session: + any_node = session.scalars(select(RiNode).where(RiNode.snapshot_id == snapshot_id).limit(1)).one() + # A sealed snapshot's facts are immutable through the ORM by design; + # a Core-level statement simulates a corrupted/malicious evidence row + # bypassing the extractor's own path normalization, purely to prove + # the independent filesystem-level guard still holds even if the + # evidence-membership check were ever fooled. Attached to a real node + # so only ``path`` is adversarial -- everything else about the row is + # a structurally valid evidence record. + session.execute( + insert(RiEvidence).values( + snapshot_id=snapshot_id, + node_ref=any_node.id, + edge_ref=None, + observation_ref=None, + path="../../../etc/passwd", + start_line=1, + end_line=1, + logical_line_count=1, + granularity="span", + extractor="test", + extractor_version="1.0.0", + ) + ) + session.commit() + + response = auth_client.get( + _evidence_url( + repository["id"], + snapshot_id, + "../../../etc/passwd", + 1, + 1, + fact_id=any_node.stable_key, + ) + ) + assert response.status_code == 422 + + +def test_evidence_source_rejects_invalid_line_range(auth_client): + repository = _upload(auth_client, _SOURCES) + snapshot_id = _persist_snapshot(repository["id"], _SOURCES) + + zero_start = auth_client.get(_evidence_url(repository["id"], snapshot_id, "src/routes.py", 0, 1)) + assert zero_start.status_code == 422 + + end_before_start = auth_client.get(_evidence_url(repository["id"], snapshot_id, "src/routes.py", 5, 2)) + assert end_before_start.status_code == 422 + + +def test_evidence_source_reports_unavailable_for_out_of_range_span(auth_client): + repository = _upload(auth_client, _SOURCES) + snapshot_id = _persist_snapshot(repository["id"], _SOURCES) + + response = auth_client.get(_evidence_url(repository["id"], snapshot_id, "src/routes.py", 1, 10_000)) + assert response.status_code == 200 + body = response.json() + assert body["status"] == "unavailable" + assert body["content"] is None + + +def test_evidence_source_reports_unavailable_when_source_deleted(auth_client): + import shutil + + from app.core.database import SessionLocal + + repository = _upload(auth_client, _SOURCES) + snapshot_id = _persist_snapshot(repository["id"], _SOURCES) + + with SessionLocal() as session: + record = session.get(RepositoryRecord, repository["id"]) + assert record is not None + local_path = record.local_path + shutil.rmtree(local_path) + + response = auth_client.get(_evidence_url(repository["id"], snapshot_id, "src/routes.py", 7, 7)) + assert response.status_code == 200 + body = response.json() + assert body["status"] == "unavailable" + assert body["content"] is None + + +def test_evidence_source_reports_unavailable_when_source_bytes_change(auth_client): + from pathlib import Path + + from app.core.database import SessionLocal + + repository = _upload(auth_client, _SOURCES) + snapshot_id = _persist_snapshot(repository["id"], _SOURCES) + + with SessionLocal() as session: + record = session.get(RepositoryRecord, repository["id"]) + assert record is not None + source_path = Path(record.local_path) / "src/routes.py" + source_path.write_text( + _SOURCES["src/routes.py"].decode("utf-8").replace('"/me"', '"/mutated"'), + encoding="utf-8", + ) + + response = auth_client.get(_evidence_url(repository["id"], snapshot_id, "src/routes.py", 7, 7)) + assert response.status_code == 200 + body = response.json() + assert body["status"] == "unavailable" + assert body["content"] is None + assert "do not match" in body["reason"] diff --git a/apps/backend/tests/test_export_api.py b/apps/backend/tests/test_export_api.py index bf4aa94c..aab53b20 100644 --- a/apps/backend/tests/test_export_api.py +++ b/apps/backend/tests/test_export_api.py @@ -3,6 +3,9 @@ import json import zipfile +from tests.analysis_helpers import run_analysis_jobs +from tests.api_assertions import assert_error_response + def _zip_bytes(files: dict[str, str]) -> bytes: buffer = io.BytesIO() @@ -12,8 +15,8 @@ def _zip_bytes(files: dict[str, str]) -> bytes: return buffer.getvalue() -def _import_sample(client) -> str: - response = client.post( +def _import_sample(auth_client, *, analyse: bool = True) -> str: + response = auth_client.post( "/repositories/upload", files={ "file": ( @@ -30,20 +33,24 @@ def _import_sample(client) -> str: }, ) assert response.status_code == 201 - return response.json()["id"] + repository_id = response.json()["id"] + if analyse: + assert auth_client.post(f"/analysis/{repository_id}/start").status_code == 200 + assert run_analysis_jobs() == 1 + return repository_id -def _export(client, repository_id: str, target: str, fmt: str): - return client.post( +def _export(auth_client, repository_id: str, target: str, fmt: str): + return auth_client.post( "/export", json={"repositoryId": repository_id, "target": target, "format": fmt}, ) -def test_export_review_json_parses(client): - repository_id = _import_sample(client) +def test_export_review_json_parses(auth_client): + repository_id = _import_sample(auth_client) - response = _export(client, repository_id, "review", "json") + response = _export(auth_client, repository_id, "review", "json") assert response.status_code == 200 body = response.json() @@ -51,14 +58,16 @@ def test_export_review_json_parses(client): assert body["mediaType"] == "application/json" assert body["filename"].endswith(".json") payload = json.loads(body["content"]) + assert payload["schemaVersion"] == "engineering-review.v2" assert "summary" in payload - assert isinstance(payload["summary"]["overallScore"], int) + assert "overallScore" not in payload["summary"] + assert "score" not in payload -def test_export_review_markdown_has_headings(client): - repository_id = _import_sample(client) +def test_export_review_markdown_has_headings(auth_client): + repository_id = _import_sample(auth_client) - response = _export(client, repository_id, "review", "markdown") + response = _export(auth_client, repository_id, "review", "markdown") assert response.status_code == 200 body = response.json() @@ -66,13 +75,14 @@ def test_export_review_markdown_has_headings(client): assert body["filename"].endswith(".md") assert "# Engineering Review" in body["content"] assert "## Executive Summary" in body["content"] - assert "| Overall Score |" in body["content"] + assert "## Category Assessment" in body["content"] + assert "Overall Score" not in body["content"] -def test_export_review_html_is_valid_structure(client): - repository_id = _import_sample(client) +def test_export_review_html_is_valid_structure(auth_client): + repository_id = _import_sample(auth_client) - response = _export(client, repository_id, "review", "html") + response = _export(auth_client, repository_id, "review", "html") assert response.status_code == 200 body = response.json() @@ -84,10 +94,10 @@ def test_export_review_html_is_valid_structure(client): assert "Engineering Review" in content -def test_export_review_pdf_starts_with_pdf_header(client): - repository_id = _import_sample(client) +def test_export_review_pdf_starts_with_pdf_header(auth_client): + repository_id = _import_sample(auth_client) - response = _export(client, repository_id, "review", "pdf") + response = _export(auth_client, repository_id, "review", "pdf") assert response.status_code == 200 body = response.json() @@ -97,50 +107,79 @@ def test_export_review_pdf_starts_with_pdf_header(client): assert base64.b64decode(body["content"])[:5] == b"%PDF-" -def test_export_json_supported_for_every_target(client): - repository_id = _import_sample(client) +def test_export_review_before_analysis_cannot_report_a_clean_result(auth_client): + repository_id = _import_sample(auth_client, analyse=False) + + for fmt in ("json", "markdown"): + response = _export(auth_client, repository_id, "review", fmt) + error = assert_error_response(response, 404, "not_found") + assert error.message == "No sealed Repository Intelligence snapshot is available for this repository." + + +def test_export_json_supported_for_every_target(auth_client): + repository_id = _import_sample(auth_client) for target in ("documentation", "architecture", "dependencies"): - response = _export(client, repository_id, target, "json") + response = _export(auth_client, repository_id, target, "json") assert response.status_code == 200, target body = response.json() assert body["encoding"] == "utf-8" json.loads(body["content"]) # must be valid JSON -def test_export_non_review_targets_support_all_formats(client): - repository_id = _import_sample(client) +def test_dependency_exports_report_assessments_as_not_computed(auth_client): + repository_id = _import_sample(auth_client) + + json_response = _export(auth_client, repository_id, "dependencies", "json") + markdown_response = _export(auth_client, repository_id, "dependencies", "markdown") + + assert json_response.status_code == 200 + payload = json.loads(json_response.json()["content"]) + assert payload["vulnerabilityAssessment"] == {"status": "not_computed"} + assert payload["outdatedAssessment"] == {"status": "not_computed"} + assert "vulnerabilities" not in payload + assert "outdated" not in payload + + assert markdown_response.status_code == 200 + report = markdown_response.json()["content"] + assert "| Vulnerability assessment | Not computed |" in report + assert "| Outdated-version assessment | Not computed |" in report + assert "outside the current analysis scope" in report + + +def test_export_non_review_targets_support_all_formats(auth_client): + repository_id = _import_sample(auth_client) for target in ("documentation", "architecture", "dependencies"): - markdown = _export(client, repository_id, target, "markdown") + markdown = _export(auth_client, repository_id, target, "markdown") assert markdown.status_code == 200, target assert markdown.json()["mediaType"] == "text/markdown" assert markdown.json()["content"].startswith("# "), target - html = _export(client, repository_id, target, "html") + html = _export(auth_client, repository_id, target, "html") assert html.status_code == 200, target assert html.json()["mediaType"] == "text/html" assert html.json()["content"].startswith(""), target assert "" in html.json()["content"], target - pdf = _export(client, repository_id, target, "pdf") + pdf = _export(auth_client, repository_id, target, "pdf") assert pdf.status_code == 200, target assert pdf.json()["encoding"] == "base64" assert pdf.json()["mediaType"] == "application/pdf" assert base64.b64decode(pdf.json()["content"])[:5] == b"%PDF-", target -def test_export_rejects_invalid_format(client): - repository_id = _import_sample(client) +def test_export_rejects_invalid_format(auth_client): + repository_id = _import_sample(auth_client) - response = _export(client, repository_id, "review", "xml") + response = _export(auth_client, repository_id, "review", "xml") - assert response.status_code == 422 - assert response.json()["code"] == "request_validation_error" + error = assert_error_response(response, 422, "request_validation_error") + assert error.details is not None + assert "errors" in error.details -def test_export_missing_repository_returns_not_found(client): - response = _export(client, "00000000-0000-0000-0000-000000000000", "review", "json") +def test_export_missing_repository_returns_not_found(auth_client): + response = _export(auth_client, "00000000-0000-0000-0000-000000000000", "review", "json") - assert response.status_code == 404 - assert response.json()["code"] == "not_found" + assert_error_response(response, 404, "not_found") diff --git a/apps/backend/tests/test_extraction_depth_integration.py b/apps/backend/tests/test_extraction_depth_integration.py new file mode 100644 index 00000000..9ff8db10 --- /dev/null +++ b/apps/backend/tests/test_extraction_depth_integration.py @@ -0,0 +1,365 @@ +"""End-to-end W1 extraction depth through the durable analysis job (#209). + +Unit tests prove each extractor in isolation. This proves the part that only +the real pipeline can: that a repository containing manifests, lockfiles, IaC, +and HTTP call sites seals **one** snapshot in which the new facts carry their +truth class and provenance, the dependency identities did not fork, and the +whole graph hashes to the same value on a repeated run. +""" + +from __future__ import annotations + +import io +import zipfile + +from sqlalchemy import select + +from app.core.database import SessionLocal +from app.extraction import production_extractors +from app.extraction.dependencies import merge_dependency_facts +from app.extraction.iac import IacExtractor +from app.extraction.lockfiles import LockfileExtractor +from app.extraction.pipeline import ExtractionPipeline +from app.models.snapshot import RiDiagnostic, RiEdge, RiEvidence, RiNode, RiObservation, RiSnapshot +from app.services.analysis_job_service import ANALYSIS_PRODUCER_VERSION_SET +from tests.analysis_helpers import run_analysis_jobs + +SOURCES: dict[str, bytes] = { + "package.json": b'{\n "name": "web",\n "dependencies": {\n "left-pad": "^1.3.0"\n }\n}\n', + "package-lock.json": ( + b"{\n" + b' "name": "web",\n' + b' "lockfileVersion": 3,\n' + b' "packages": {\n' + b' "": {\n "name": "web"\n },\n' + b' "node_modules/left-pad": {\n "version": "1.3.1"\n },\n' + # Pinned but never declared — the ordinary transitive shape. + b' "node_modules/util": {\n "version": "0.12.5",\n "dev": true\n },\n' + b' "node_modules/util/node_modules/left-pad": {\n "version": "1.2.0"\n }\n' + b" }\n" + b"}\n" + ), + "pyproject.toml": b'[project]\ndependencies = [\n "Requests_Toolbelt>=1.0",\n]\n', + "poetry.lock": ( + b'[[package]]\nname = "requests-toolbelt"\nversion = "1.0.0"\ncategory = "main"\n\n' + b'[metadata]\nlock-version = "2.0"\n' + ), + "docker-compose.yml": ( + b"services:\n api:\n image: python:3.13-slim\n worker:\n image: ${TAG}\nvolumes:\n pgdata:\n" + ), + "app/client.py": ( + b'import requests\n\n\ndef load_users():\n return requests.get("https://api.example.com/v1/users")\n' + ), + "src/api.ts": b'export async function ping() {\n return fetch("https://api.example.com/health");\n}\n', +} + + +def _archive(files: dict[str, bytes]) -> bytes: + """Build a byte-deterministic ZIP. + + An upload's revision identity is the SHA-256 of the archive bytes, and + ``writestr`` would otherwise stamp each entry with the current time — so a + default archive of identical files is a *different* revision every call. + Pinning the entry timestamp is what lets a test upload the same revision + twice on purpose. + """ + + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as archive: + for path, content in files.items(): + archive.writestr(zipfile.ZipInfo(path, date_time=(2026, 1, 1, 0, 0, 0)), content) + return buffer.getvalue() + + +def _upload_and_analyse(auth_client, sources: dict[str, bytes] | bytes) -> str: + payload = sources if isinstance(sources, bytes) else _archive(sources) + response = auth_client.post( + "/repositories/upload", + files={"file": ("repo.zip", payload, "application/zip")}, + ) + assert response.status_code == 201, response.text + repository_id = response.json()["id"] + assert auth_client.post(f"/analysis/{repository_id}/start").status_code == 200 + assert run_analysis_jobs() == 1 + return repository_id + + +def _snapshot(session, repository_id: str) -> RiSnapshot: + snapshot = session.scalars(select(RiSnapshot).where(RiSnapshot.repository_id == repository_id)).one() + assert snapshot.state == "completed" + return snapshot + + +def _nodes(session, snapshot, kind: str) -> dict[str, RiNode]: + return { + node.stable_key: node + for node in session.scalars( + select(RiNode).where(RiNode.snapshot_id == snapshot.snapshot_id, RiNode.node_kind == kind) + ) + } + + +def _triples(session, snapshot, predicate: str) -> set[tuple[str, str]]: + return { + (edge.subject_key, edge.object_key) + for edge in session.scalars( + select(RiEdge).where(RiEdge.snapshot_id == snapshot.snapshot_id, RiEdge.predicate == predicate) + ) + } + + +def test_the_planned_producer_set_declares_every_registered_extractor(): + """Submit and execution key on this set, so a new producer must be in it.""" + + assert f"{LockfileExtractor.name}@{LockfileExtractor.version}" in ANALYSIS_PRODUCER_VERSION_SET + assert f"{IacExtractor.name}@{IacExtractor.version}" in ANALYSIS_PRODUCER_VERSION_SET + assert list(ANALYSIS_PRODUCER_VERSION_SET) == sorted(ANALYSIS_PRODUCER_VERSION_SET) + assert len(set(ANALYSIS_PRODUCER_VERSION_SET)) == len(ANALYSIS_PRODUCER_VERSION_SET) + + +def test_a_repository_with_lockfiles_iac_and_http_clients_seals_one_snapshot(auth_client): + repository_id = _upload_and_analyse(auth_client, SOURCES) + + with SessionLocal() as session: + snapshot = _snapshot(session, repository_id) + assert snapshot.canonical_graph_hash is not None + # Sealing requires declared ⊇ observed, so every emitting producer here + # is one the submitted identity already fixed. + assert set(snapshot.actual_producers or ()) <= set(snapshot.producer_version_set) + + +def test_a_declaration_and_its_lockfile_pin_share_one_dependency_node(auth_client): + repository_id = _upload_and_analyse(auth_client, SOURCES) + + with SessionLocal() as session: + snapshot = _snapshot(session, repository_id) + dependencies = _nodes(session, snapshot, "dependency") + + left_pad = dependencies["dep:npm:left-pad"] + assert [item["version"] for item in left_pad.properties["declarations"]] == ["^1.3.0"] + # Two installed versions of one package remain two resolutions of the + # single logical identity, not a second dependency node. + assert sorted(item["resolved_version"] for item in left_pad.properties["resolutions"]) == ["1.2.0", "1.3.1"] + + # PEP 503 folds the manifest's ``Requests_Toolbelt`` and the lockfile's + # ``requests-toolbelt`` onto one key. + toolbelt = dependencies["dep:pypi:requests-toolbelt"] + assert len(toolbelt.properties["declarations"]) == 1 + assert [item["resolved_version"] for item in toolbelt.properties["resolutions"]] == ["1.0.0"] + assert not any(key.startswith("dep:pypi:requests_toolbelt") for key in dependencies) + + +def test_each_producer_is_credited_only_with_the_spans_it_read(auth_client): + repository_id = _upload_and_analyse(auth_client, SOURCES) + + with SessionLocal() as session: + snapshot = _snapshot(session, repository_id) + node = _nodes(session, snapshot, "dependency")["dep:npm:left-pad"] + evidence = { + (item.extractor, item.path) + for item in session.scalars( + select(RiEvidence).where(RiEvidence.snapshot_id == snapshot.snapshot_id, RiEvidence.node_ref == node.id) + ) + } + + assert evidence == { + ("dependency-manifest", "package.json"), + ("dependency-lockfile", "package-lock.json"), + } + + +def test_a_lockfile_pin_alone_never_claims_a_direct_dependency(auth_client): + repository_id = _upload_and_analyse(auth_client, SOURCES) + + with SessionLocal() as session: + snapshot = _snapshot(session, repository_id) + depends_on = {object_key for _, object_key in _triples(session, snapshot, "depends_on")} + + # ``util`` is pinned by the lockfile but declared by no manifest, so it + # is a real node with a real resolution and no direct-dependency edge: + # the repository never asked for it. + assert "dep:npm:left-pad" in depends_on + assert "dep:pypi:requests-toolbelt" in depends_on + assert "dep:npm:util" in _nodes(session, snapshot, "dependency") + assert "dep:npm:util" not in depends_on + resolutions = session.scalars( + select(RiObservation).where( + RiObservation.snapshot_id == snapshot.snapshot_id, + RiObservation.observed_kind == "resolution", + ) + ).all() + assert {item.referent_text for item in resolutions} == {"1.3.1", "1.2.0", "0.12.5", "1.0.0"} + + +def test_http_call_sites_resolve_to_one_language_neutral_service(auth_client): + repository_id = _upload_and_analyse(auth_client, SOURCES) + + with SessionLocal() as session: + snapshot = _snapshot(session, repository_id) + services = _nodes(session, snapshot, "service") + + # The Python and TypeScript call sites reach the same origin, so they + # must land on one node or the snapshot would not have sealed. + assert set(services) == {"svc:https://api.example.com"} + assert services["svc:https://api.example.com"].language is None + # Each edge is attributed to the symbol whose span contains the call, + # exactly like a generic ``calls`` reference. + assert _triples(session, snapshot, "calls_service") == { + ("app/client.py::load_users", "svc:https://api.example.com"), + ("src/api.ts::ping", "svc:https://api.example.com"), + } + + +def test_iac_resources_are_declared_by_the_repository_with_exact_spans(auth_client): + repository_id = _upload_and_analyse(auth_client, SOURCES) + + with SessionLocal() as session: + snapshot = _snapshot(session, repository_id) + resources = _nodes(session, snapshot, "iac_resource") + + assert set(resources) == { + "iac:docker-compose.yml::service/api", + "iac:docker-compose.yml::service/worker", + "iac:docker-compose.yml::volume/pgdata", + } + assert resources["iac:docker-compose.yml::service/api"].properties["image"] == "python:3.13-slim" + assert "image" not in resources["iac:docker-compose.yml::service/worker"].properties + assert _triples(session, snapshot, "declares") == {("repo:root", key) for key in resources} + api_evidence = session.scalars( + select(RiEvidence).where( + RiEvidence.snapshot_id == snapshot.snapshot_id, + RiEvidence.node_ref == resources["iac:docker-compose.yml::service/api"].id, + ) + ).all() + assert [(item.path, item.start_line, item.end_line) for item in api_evidence] == [("docker-compose.yml", 2, 2)] + + +def test_a_templated_iac_value_is_disclosed_at_the_resource_it_belongs_to(auth_client): + repository_id = _upload_and_analyse(auth_client, SOURCES) + + with SessionLocal() as session: + snapshot = _snapshot(session, repository_id) + diagnostics = [ + (item.code, item.producer, item.subject_key, item.span_start_line) + for item in session.scalars( + select(RiDiagnostic).where( + RiDiagnostic.snapshot_id == snapshot.snapshot_id, + RiDiagnostic.message == "templated IaC value is unsupported", + ) + ) + ] + + assert diagnostics == [ + ( + "RI-EXT-UNSUPPORTED", + f"{IacExtractor.name}@{IacExtractor.version}", + "iac:docker-compose.yml::service/worker", + 4, + ) + ] + + +def test_the_direct_dependency_graph_excludes_lockfile_only_packages(auth_client): + """The Dependency Graph claims direct declarations; a pin is not one.""" + + repository_id = _upload_and_analyse(auth_client, SOURCES) + + response = auth_client.get(f"/analysis/{repository_id}/dependencies") + assert response.status_code == 200, response.text + payload = response.json() + rendered = {node["id"] for node in payload["nodes"]} + assert payload["totalDependencies"] == len(rendered) + + assert "dep:npm:left-pad" in rendered + assert "dep:pypi:requests-toolbelt" in rendered + # ``util`` exists as a node because the lockfile pinned it, but nothing + # declared it, so it must not be presented as a direct dependency. + assert "dep:npm:util" not in rendered + with SessionLocal() as session: + assert "dep:npm:util" in _nodes(session, _snapshot(session, repository_id), "dependency") + + +def test_repeated_extraction_of_one_revision_is_byte_identical(auth_client): + """Nothing may depend on the order the source stream happened to arrive in. + + The same revision cannot be uploaded twice — an immutable revision is a + duplicate by design — so determinism is asserted over the extractor set the + worker runs, in both source orders. The sealed-hash half of this guarantee + is enforced by the golden benchmark's determinism gate. + """ + + repository_id = _upload_and_analyse(auth_client, SOURCES) + + def run(sources: dict[str, bytes]): + pipeline = ExtractionPipeline(production_extractors()) + return merge_dependency_facts(pipeline.run(sources)) + + forward = run(SOURCES) + reversed_order = run(dict(reversed(list(SOURCES.items())))) + + assert forward == run(SOURCES) + assert sorted( + (node.node_kind, node.stable_key, node.name, str(node.properties)) + for item in forward + for node in item.result.nodes + ) == sorted( + (node.node_kind, node.stable_key, node.name, str(node.properties)) + for item in reversed_order + for node in item.result.nodes + ) + + with SessionLocal() as session: + assert _snapshot(session, repository_id).canonical_graph_hash is not None + + +def test_an_unsupported_lockfile_revision_seals_a_disclosure_and_no_dependency(auth_client): + repository_id = _upload_and_analyse( + auth_client, + { + "README.md": b"# repo\n", + "package-lock.json": b'{\n "lockfileVersion": 1,\n "dependencies": {\n "left-pad": {\n "version": "1.3.0"\n }\n }\n}\n', + }, + ) + + with SessionLocal() as session: + snapshot = _snapshot(session, repository_id) + + assert _nodes(session, snapshot, "dependency") == {} + codes = [ + (item.code, item.producer) + for item in session.scalars( + select(RiDiagnostic).where( + RiDiagnostic.snapshot_id == snapshot.snapshot_id, + RiDiagnostic.subject_key == "file:package-lock.json", + ) + ) + ] + assert codes == [("RI-EXT-UNSUPPORTED", f"{LockfileExtractor.name}@{LockfileExtractor.version}")] + + +def test_an_oversized_lockfile_is_skipped_by_the_source_budget_not_parsed(auth_client): + padding = b" " * (600 * 1024) + repository_id = _upload_and_analyse( + auth_client, + { + "README.md": b"# repo\n", + "package-lock.json": b'{\n "lockfileVersion": 3,\n "packages": {\n "node_modules/left-pad": {\n "version": "1.3.0"\n }\n },\n "_pad": "' + + padding + + b'"\n}\n', + }, + ) + + with SessionLocal() as session: + snapshot = _snapshot(session, repository_id) + + assert _nodes(session, snapshot, "dependency") == {} + codes = { + item.code + for item in session.scalars( + select(RiDiagnostic).where( + RiDiagnostic.snapshot_id == snapshot.snapshot_id, + RiDiagnostic.path == "package-lock.json", + ) + ) + } + assert codes == {"RI-LIMIT-SKIP"} diff --git a/apps/backend/tests/test_frontend_hosting.py b/apps/backend/tests/test_frontend_hosting.py new file mode 100644 index 00000000..4c06fbfd --- /dev/null +++ b/apps/backend/tests/test_frontend_hosting.py @@ -0,0 +1,115 @@ +"""Single-service frontend hosting (#339): app.main mounts a built frontend +when one is present, and behaves exactly as before when one is not.""" + +from collections.abc import Generator +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + + +def _write_fake_build(dist_path: Path) -> None: + dist_path.mkdir(parents=True, exist_ok=True) + (dist_path / "index.html").write_text("spa shell", encoding="utf-8") + assets_path = dist_path / "assets" + assets_path.mkdir(parents=True, exist_ok=True) + (assets_path / "app.js").write_text("console.log('app');", encoding="utf-8") + + +@pytest.fixture() +def mounted_client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Generator[TestClient, None, None]: + """Same boot as the shared `client` fixture, except FRONTEND_DIST_PATH + points at a real, populated build directory instead of a missing one.""" + + dist_path = tmp_path / "dist" + _write_fake_build(dist_path) + + database_path = tmp_path / "partha-test.db" + storage_path = tmp_path / "storage" + monkeypatch.setenv("DATABASE_URL", f"sqlite:///{database_path}") + monkeypatch.setenv("STORAGE_PATH", str(storage_path)) + monkeypatch.setenv("AUTO_CREATE_TABLES", "true") + monkeypatch.setenv("CORS_ORIGINS", "http://testserver") + monkeypatch.setenv("ANALYSIS_WORKER_AUTOSTART", "false") + monkeypatch.setenv("FRONTEND_DIST_PATH", str(dist_path)) + + from app.core import config + + config.get_settings.cache_clear() + + import app.core.database as database + + settings = config.get_settings() + database.settings = settings + database.engine.dispose() + database.connect_args = {"check_same_thread": False} + database.engine = database.create_engine( + settings.database_url, pool_pre_ping=True, connect_args=database.connect_args + ) + database.SessionLocal.configure(bind=database.engine) + + from app.core.schema_sync import stamp_head + from app.main import create_app + from app.models.base import Base + + Base.metadata.create_all(bind=database.engine) + stamp_head(database.engine) + with TestClient(create_app()) as test_client: + yield test_client + + +def test_no_dist_directory_leaves_unmatched_routes_404ing(client: TestClient) -> None: + # The shared `client` fixture points FRONTEND_DIST_PATH at a directory + # that does not exist, matching a plain local-dev boot with no built + # frontend. Nothing should be mounted, and an arbitrary client-side + # route stays a normal 404 instead of silently becoming a 200. + response = client.get("/dashboard") + assert response.status_code == 404 + + +def test_health_route_is_never_shadowed_by_a_mounted_frontend(mounted_client: TestClient) -> None: + response = mounted_client.get("/health") + + assert response.status_code == 200 + assert response.json()["status"] == "ok" + + +def test_client_side_route_and_direct_refresh_both_get_the_spa_shell(mounted_client: TestClient) -> None: + root = mounted_client.get("/") + deep_link = mounted_client.get("/dashboard/some/nested/route") + + assert root.status_code == 200 + assert "spa shell" in root.text + assert deep_link.status_code == 200 + assert "spa shell" in deep_link.text + + +def test_built_asset_is_served_directly(mounted_client: TestClient) -> None: + response = mounted_client.get("/assets/app.js") + + assert response.status_code == 200 + assert "console.log" in response.text + + +@pytest.mark.parametrize( + "traversal_path", + [ + "/../secret.txt", + "/assets/../../secret.txt", + "/%2e%2e/secret.txt", + "/..%2fsecret.txt", + ], +) +def test_traversal_attempts_cannot_escape_the_dist_directory( + mounted_client: TestClient, tmp_path: Path, traversal_path: str +) -> None: + # A file that sits next to (not inside) the mounted dist directory must + # never be reachable through the catch-all handler, no matter how the + # ".." segments are spelled in the request path. + (tmp_path / "secret.txt").write_text("top secret", encoding="utf-8") + + response = mounted_client.get(traversal_path) + + assert response.status_code == 200 + assert "top secret" not in response.text + assert "spa shell" in response.text diff --git a/apps/backend/tests/test_import_dispositions.py b/apps/backend/tests/test_import_dispositions.py new file mode 100644 index 00000000..88ed4dc3 --- /dev/null +++ b/apps/backend/tests/test_import_dispositions.py @@ -0,0 +1,81 @@ +"""Unit coverage for the unresolved-import disposition rules (#412). + +End-to-end coverage (a real repository, a real sealed snapshot, a real +Engineering Review response) lives in test_engineering_review_v2.py. This +file is scoped to the pure decision functions themselves. +""" + +from app.review.import_dispositions import ( + NODE_BUILTIN_MODULES, + PYTHON_STDLIB_MODULES, + declared_dependency_key, + is_platform_import, + is_recognized_external_import, +) + + +def test_python_stdlib_modules_includes_the_common_offenders(): + # These are exactly the Django-boilerplate imports (asgi.py/wsgi.py/ + # settings.py/urls.py/manage.py) that motivated this fix. + for name in ("os", "sys", "pathlib", "json", "re", "typing", "collections"): + assert name in PYTHON_STDLIB_MODULES + + +def test_node_builtin_modules_covers_the_common_ones(): + for name in ("fs", "path", "http", "crypto", "os", "url"): + assert name in NODE_BUILTIN_MODULES + # A Python stdlib name is not automatically a Node builtin, or vice versa. + assert "pathlib" not in NODE_BUILTIN_MODULES + + +def test_is_platform_import_recognizes_python_stdlib_by_package_root(): + assert is_platform_import("os", "app.py") is True + assert is_platform_import("django.core.wsgi.get_wsgi_application", "app.py") is False + assert is_platform_import("pathlib.Path", "app.py") is True + + +def test_is_platform_import_recognizes_node_builtins_with_and_without_prefix(): + assert is_platform_import("fs", "src/app.ts") is True + assert is_platform_import("node:fs", "src/app.ts") is True + assert is_platform_import("node:fs/promises", "src/app.ts") is True + assert is_platform_import("react", "src/app.ts") is False + + +def test_is_platform_import_never_matches_a_relative_import(): + assert is_platform_import(".models.User", "app/views.py") is False + assert is_platform_import("./util", "src/app.ts") is False + + +def test_declared_dependency_key_is_none_for_a_relative_import(): + assert declared_dependency_key(".models.User", "app/views.py") is None + assert declared_dependency_key("./util", "src/app.ts") is None + + +def test_declared_dependency_key_normalizes_pypi_names_like_the_resolver_does(): + # "Django" and "django" (and "django_rest-framework" style separators) + # must land on the same key a manifest declaration would produce, or a + # real declared dependency would still fail to match by pure casing. + assert declared_dependency_key("django.core.wsgi", "app.py") == "dep:pypi:django" + assert declared_dependency_key("Django", "app.py") == "dep:pypi:django" + + +def test_declared_dependency_key_uses_npm_ecosystem_for_non_python_files(): + assert declared_dependency_key("react", "src/app.ts") == "dep:npm:react" + assert declared_dependency_key("@scope/name/sub", "src/app.ts") == "dep:npm:@scope/name" + + +def test_is_recognized_external_import_true_for_platform_regardless_of_declared_keys(): + assert is_recognized_external_import("os", "app.py", frozenset()) is True + + +def test_is_recognized_external_import_true_only_when_declared(): + declared = frozenset({"dep:pypi:django"}) + assert is_recognized_external_import("django.core.wsgi", "app.py", declared) is True + assert is_recognized_external_import("flask", "app.py", declared) is False + + +def test_is_recognized_external_import_false_for_a_relative_import_even_with_matching_declared_keys(): + # A pathological but real guard: an empty package root must never match + # anything, even if a dependency named "" somehow existed in the set. + declared = frozenset({"dep:pypi:"}) + assert is_recognized_external_import(".models.User", "app/views.py", declared) is False diff --git a/apps/backend/tests/test_ingestion_pipeline.py b/apps/backend/tests/test_ingestion_pipeline.py index 7a203c6b..5f6b7bf9 100644 --- a/apps/backend/tests/test_ingestion_pipeline.py +++ b/apps/backend/tests/test_ingestion_pipeline.py @@ -1,4 +1,5 @@ import io +import subprocess import tarfile import zipfile from pathlib import Path @@ -7,6 +8,10 @@ from app.github.client import GitHubClient from app.core.exceptions import TimeoutServiceError +from app.core.database import SessionLocal +from app.models.repository import RepositoryRecord +from tests.analysis_helpers import run_analysis_jobs +from tests.api_assertions import assert_error_response def _zip_bytes(files: dict[str, str]) -> bytes: @@ -28,16 +33,16 @@ def _tar_gz_bytes(files: dict[str, str]) -> bytes: return buffer.getvalue() -def _upload(client, filename: str, content: bytes): - return client.post( +def _upload(auth_client, filename: str, content: bytes): + return auth_client.post( "/repositories/upload", files={"file": (filename, content, "application/octet-stream")}, ) -def test_zip_upload_persists_repository_and_analysis_completes(client): +def test_zip_upload_persists_repository_and_analysis_completes(auth_client): response = _upload( - client, + auth_client, "sample.zip", _zip_bytes( { @@ -55,12 +60,36 @@ def test_zip_upload_persists_repository_and_analysis_completes(client): assert repository["analysisStage"] == "building-file-tree" assert repository["analysisProgress"] == 70 assert repository["meta"]["framework"] == "React" - - start_response = client.post(f"/analysis/{repository['id']}/start") + assert repository["revision"] == { + "kind": "upload", + "value": repository["commitSha"], + "ref": None, + } + assert repository["commitSha"].startswith("sha256:") + assert len(repository["commitSha"]) == 71 + assert "commitSha" not in repository["meta"] + + # Import performs bounded archive/clone parsing only. Intelligence is not + # built in the request path; the durable worker owns that work. + with SessionLocal() as session: + record = session.get(RepositoryRecord, repository["id"]) + assert "intelligence" not in (record.repo_metadata or {}) + + # /start now enqueues durably and returns immediately (never blocks on the + # worker); the test drives the worker synchronously to reach completion. + start_response = auth_client.post(f"/analysis/{repository['id']}/start") assert start_response.status_code == 200 - assert start_response.json() == {"repositoryId": repository["id"], "status": "completed"} + start_body = start_response.json() + assert start_body["repositoryId"] == repository["id"] + assert start_body["status"] == "queued" + assert start_body["jobId"] is not None + assert run_analysis_jobs() == 1 + + with SessionLocal() as session: + record = session.get(RepositoryRecord, repository["id"]) + assert "intelligence" not in (record.repo_metadata or {}) - status_response = client.get(f"/analysis/{repository['id']}/status") + status_response = auth_client.get(f"/analysis/{repository['id']}/status") assert status_response.status_code == 200 status = status_response.json() assert status["status"] == "completed" @@ -68,16 +97,211 @@ def test_zip_upload_persists_repository_and_analysis_completes(client): assert status["progress"] == 100 assert status["completedAt"] is not None - list_response = client.get("/repositories") + architecture_response = auth_client.get(f"/analysis/{repository['id']}/architecture") + assert architecture_response.status_code == 200 + architecture = architecture_response.json() + assert architecture["repositoryId"] == repository["id"] + assert architecture["summary"]["framework"] == "React" + + review_response = auth_client.get(f"/analysis/{repository['id']}/review") + assert review_response.status_code == 200 + review = review_response.json() + assert review["repositoryId"] == repository["id"] + assert review["schemaVersion"] == "engineering-review.v2" + assert review["snapshotSchemaVersion"] == "ri.v1" + assert review["revisionValue"] == repository["revision"]["value"] + assert review["summary"]["evidenceBackedFindingCount"] == len(review["findings"]) + + list_response = auth_client.get("/repositories") assert list_response.status_code == 200 repositories = list_response.json()["data"] assert repositories[0]["id"] == repository["id"] assert repositories[0]["status"] == "completed" -def test_tar_gz_upload_is_supported(client): +def test_analysis_read_endpoints_return_typed_defaults_before_worker_runs(auth_client): + response = _upload( + auth_client, + "pending-analysis.zip", + _zip_bytes( + { + "pending-analysis/package.json": '{"dependencies":{"react":"^18.0.0"}}', + "pending-analysis/src/main.tsx": "import React from 'react';", + } + ), + ) + assert response.status_code == 201 + repository_id = response.json()["id"] + + with SessionLocal() as session: + record = session.get(RepositoryRecord, repository_id) + assert "intelligence" not in (record.repo_metadata or {}) + + # Architecture is sealed-snapshot-bound (#217), same as Dependencies/Review/ + # Insights: no snapshot yet means 404, never a fallback graph built from + # unsealed repository metadata. + architecture_response = auth_client.get(f"/analysis/{repository_id}/architecture") + assert architecture_response.status_code == 404 + + # Dependency Graph is sealed-snapshot-bound (#158), same as Review/Insights: + # no snapshot yet means 404, not a typed-empty 200. + dependency_response = auth_client.get(f"/analysis/{repository_id}/dependencies") + assert dependency_response.status_code == 404 + + review_response = auth_client.get(f"/analysis/{repository_id}/review") + error = assert_error_response(review_response, 404, "not_found") + assert error.message == ("No sealed Repository Intelligence snapshot is available for this repository.") + assert error.details == {"repositoryId": repository_id} + + # Read endpoints must not rebuild the missing compatibility model from disk. + with SessionLocal() as session: + record = session.get(RepositoryRecord, repository_id) + assert "intelligence" not in (record.repo_metadata or {}) + + +def test_dependency_endpoint_reports_uncomputed_assessments_without_clean_claims(auth_client): + response = _upload( + auth_client, + "historical-dependency.zip", + _zip_bytes( + { + "historical-dependency/package.json": '{"dependencies":{"lodash":"4.17.15"}}', + "historical-dependency/src/main.js": "const lodash = require('lodash');", + } + ), + ) + assert response.status_code == 201 + repository_id = response.json()["id"] + assert auth_client.post(f"/analysis/{repository_id}/start").status_code == 200 + assert run_analysis_jobs() == 1 + + dependency_response = auth_client.get(f"/analysis/{repository_id}/dependencies") + + assert dependency_response.status_code == 200 + payload = dependency_response.json() + assert payload["vulnerabilityAssessment"] == {"status": "not_computed"} + assert payload["outdatedAssessment"] == {"status": "not_computed"} + assert any(node["name"] == "lodash" and node["version"] == "4.17.15" for node in payload["nodes"]) + assert payload["edges"] + + serialized_keys = {key for value in _walk_json(payload) if isinstance(value, dict) for key in value} + assert serialized_keys.isdisjoint( + { + "has_vulnerabilities", + "hasVulnerabilities", + "is_outdated", + "isOutdated", + "vulnerabilities", + "outdated", + } + ) + + +def test_empty_dependency_endpoint_still_reports_uncomputed_assessments(auth_client): + response = _upload( + auth_client, + "empty-dependencies.zip", + _zip_bytes({"empty-dependencies/package.json": "{}"}), + ) + assert response.status_code == 201 + repository_id = response.json()["id"] + assert auth_client.post(f"/analysis/{repository_id}/start").status_code == 200 + assert run_analysis_jobs() == 1 + + dependency_response = auth_client.get(f"/analysis/{repository_id}/dependencies") + + assert dependency_response.status_code == 200 + payload = dependency_response.json() + assert payload["nodes"] == [] + assert payload["edges"] == [] + assert payload["totalDependencies"] == 0 + assert payload["vulnerabilityAssessment"] == {"status": "not_computed"} + assert payload["outdatedAssessment"] == {"status": "not_computed"} + + +def test_dependency_endpoint_returns_nested_manifest_provenance_and_malformed_diagnostics(auth_client): + response = _upload( + auth_client, + "nested-dependencies.zip", + _zip_bytes( + { + "nested/apps/frontend/package.json": """{ + "dependencies": { + "react": "^18.3.0" + } +} +""", + "nested/apps/backend/pyproject.toml": '[project]\ndependencies = ["fastapi>=0.115"]\n', + "nested/services/worker/requirements.txt": "fastapi==0.116\ncelery==5.4\n", + "nested/apps/broken/package.json": "{", + "nested/node_modules/hidden/package.json": '{"dependencies":{"ignored":"1"}}', + } + ), + ) + assert response.status_code == 201 + repository_id = response.json()["id"] + assert auth_client.post(f"/analysis/{repository_id}/start").status_code == 200 + assert run_analysis_jobs() == 1 + + payload = auth_client.get(f"/analysis/{repository_id}/dependencies").json() + assert payload["schemaVersion"] == "dependency-graph.v2" + assert payload["provenance"]["source"] == "ri.v1" + assert payload["manifestCount"] == 4 + nodes = {node["id"]: node for node in payload["nodes"]} + assert set(nodes) == {"dep:npm:react", "dep:pypi:celery", "dep:pypi:fastapi"} + assert nodes["dep:pypi:fastapi"]["version"] is None + assert nodes["dep:pypi:fastapi"]["declarations"] == [ + { + "name": "fastapi", + "manifestPath": "apps/backend/pyproject.toml", + "workspacePath": "apps/backend", + "startLine": 2, + "endLine": 2, + "extractor": "dependency-manifest", + "extractorVersion": "1.2.0", + "ecosystem": "pypi", + "version": ">=0.115", + "type": "production", + }, + { + "name": "fastapi", + "manifestPath": "services/worker/requirements.txt", + "workspacePath": "services/worker", + "startLine": 1, + "endLine": 1, + "extractor": "dependency-manifest", + "extractorVersion": "1.2.0", + "ecosystem": "pypi", + "version": "==0.116", + "type": "production", + }, + ] + assert payload["diagnostics"] == [ + { + "code": "RI-SRC-MALFORMED", + "category": "malformed source", + "severity": "error", + "message": "dependency manifest could not be parsed or has an unsupported structure", + "path": "apps/broken/package.json", + "producer": "dependency-manifest@1.2.0", + "details": None, + } + ] + + +def _walk_json(value): + yield value + if isinstance(value, dict): + for item in value.values(): + yield from _walk_json(item) + elif isinstance(value, list): + for item in value: + yield from _walk_json(item) + + +def test_tar_gz_upload_is_supported(auth_client): response = _upload( - client, + auth_client, "python-service.tar.gz", _tar_gz_bytes( { @@ -94,29 +318,25 @@ def test_tar_gz_upload_is_supported(client): assert repository["meta"]["framework"] == "FastAPI" -def test_invalid_archive_returns_backend_validation_error(client): - response = _upload(client, "broken.zip", b"not an archive") +def test_invalid_archive_returns_backend_validation_error(auth_client): + response = _upload(auth_client, "broken.zip", b"not an archive") - assert response.status_code == 422 - body = response.json() - assert body["code"] == "validation_error" - assert body["message"] == "Unsupported archive format. Upload a ZIP or TAR archive." + error = assert_error_response(response, 422, "validation_error") + assert error.message == "Unsupported archive format. Upload a ZIP or TAR archive." -def test_empty_archive_is_rejected(client): - response = _upload(client, "empty.zip", _zip_bytes({})) +def test_empty_archive_is_rejected(auth_client): + response = _upload(auth_client, "empty.zip", _zip_bytes({})) - assert response.status_code == 422 - body = response.json() - assert body["code"] == "validation_error" - assert body["message"] == "Repository archive does not contain any readable files." + error = assert_error_response(response, 422, "validation_error") + assert error.message == "Repository archive does not contain any readable files." -def test_duplicate_upload_name_returns_conflict(client): +def test_duplicate_upload_name_returns_conflict(auth_client): content = _zip_bytes({"repo/package.json": "{}"}) - first = _upload(client, "repo.zip", content) - second = _upload(client, "repo.zip", content) + first = _upload(auth_client, "repo.zip", content) + second = _upload(auth_client, "repo.zip", content) assert first.status_code == 201 assert second.status_code == 409 @@ -126,32 +346,103 @@ def test_duplicate_upload_name_returns_conflict(client): assert body["details"]["name"] == "repo" -def test_github_import_uses_backend_validation_and_duplicate_detection(client, monkeypatch: pytest.MonkeyPatch): +def test_same_upload_filename_with_new_content_creates_a_new_revision(auth_client): + first = _upload(auth_client, "repo.zip", _zip_bytes({"repo/main.py": "print('first')\n"})) + second = _upload(auth_client, "repo.zip", _zip_bytes({"repo/main.py": "print('second')\n"})) + + assert first.status_code == 201 + assert second.status_code == 201 + assert first.json()["id"] != second.json()["id"] + assert first.json()["revision"]["value"] != second.json()["revision"]["value"] + + +def test_github_import_uses_backend_validation_and_duplicate_detection(auth_client, monkeypatch: pytest.MonkeyPatch): def fake_clone(_: GitHubClient, __: str, destination: Path, ___: str | None = None) -> None: destination.mkdir(parents=True, exist_ok=True) (destination / "package.json").write_text('{"dependencies":{"react":"^18.0.0"}}', encoding="utf-8") (destination / "src").mkdir() (destination / "src" / "main.tsx").write_text("import React from 'react';", encoding="utf-8") + commits = iter(["a" * 40, "a" * 40, "b" * 40, "a" * 40]) monkeypatch.setattr(GitHubClient, "clone_public_repository", fake_clone) - - first = client.post("/repositories/github", json={"url": "https://github.com/example/demo"}) - duplicate = client.post("/repositories/github", json={"url": "https://github.com/example/demo"}) - malformed_branch = client.post( + monkeypatch.setattr(GitHubClient, "read_head_commit", lambda *_: next(commits)) + monkeypatch.setattr(GitHubClient, "read_head_ref", lambda *_: "refs/heads/main") + + first = auth_client.post("/repositories/github", json={"url": "https://github.com/example/demo"}) + duplicate = auth_client.post("/repositories/github", json={"url": "https://github.com/example/demo"}) + new_revision = auth_client.post("/repositories/github", json={"url": "https://github.com/example/demo"}) + shared_commit_other_source = auth_client.post( + "/repositories/github", json={"url": "https://github.com/example/fork"} + ) + malformed_branch = auth_client.post( "/repositories/github", json={"url": "https://github.com/example/other", "branch": "../main"}, ) assert first.status_code == 201 assert first.json()["status"] == "analysing" + assert first.json()["revision"] == {"kind": "git", "value": "a" * 40, "ref": "refs/heads/main"} + assert first.json()["commitSha"] == "a" * 40 + assert "commitSha" not in first.json()["meta"] assert duplicate.status_code == 409 - assert malformed_branch.status_code == 422 - assert malformed_branch.json()["message"] == "Branch name contains unsupported characters." + assert new_revision.status_code == 201 + assert new_revision.json()["revision"]["value"] == "b" * 40 + assert new_revision.json()["id"] != first.json()["id"] + assert shared_commit_other_source.status_code == 201 + error = assert_error_response(malformed_branch, 422, "validation_error") + assert error.message == "Branch name contains unsupported characters." + + +def test_github_import_rejects_a_repository_containing_a_symlink( + auth_client, monkeypatch: pytest.MonkeyPatch, tmp_path: Path +): + """A malicious public repository can't use a symlink to read the host. + + git clone faithfully recreates real filesystem symlinks committed to a + source repository, including ones that point outside the checkout (e.g. + a repo containing ``ln -s /etc some_dir``) -- unlike archive uploads, + where TAR extraction already rejects symlink members outright and + zipfile.extractall() never creates a real symlink from a zip entry in + the first place. Without a guard, RepositoryParser's tree walk + (is_dir()/is_file()/stat(), all of which follow symlinks) would recurse + into and catalog whatever the symlink points at. This exercises the real + HTTP import path end to end, not just the parser unit, to prove the + fix actually reaches production: a clean 422 validation_error, not a + 500, and not a repository record left behind with leaked content in its + file tree. + """ + + outside = tmp_path / "outside-the-checkout" + outside.mkdir() + (outside / "secret.txt").write_text("host file content that must never be reachable\n", encoding="utf-8") + + def fake_clone(_: GitHubClient, __: str, destination: Path, ___: str | None = None) -> None: + destination.mkdir(parents=True, exist_ok=True) + (destination / "README.md").write_text("# demo\n", encoding="utf-8") + (destination / "evil_link").symlink_to(outside) + monkeypatch.setattr(GitHubClient, "clone_public_repository", fake_clone) + monkeypatch.setattr(GitHubClient, "read_head_commit", lambda *_: "a" * 40) + monkeypatch.setattr(GitHubClient, "read_head_ref", lambda *_: "refs/heads/main") -def test_github_clone_timeout_is_reported(monkeypatch: pytest.MonkeyPatch, tmp_path: Path): - import subprocess + response = auth_client.post("/repositories/github", json={"url": "https://github.com/example/malicious"}) + + error = assert_error_response(response, 422, "validation_error") + assert error.message == "Repository contains a symlink, which is not supported." + + from app.core.database import SessionLocal + db = SessionLocal() + try: + # The failed import must not leave a half-imported repository record + # behind (the outer except in import_github_repository cleans up on + # any exception, including this new one). + assert db.query(RepositoryRecord).count() == 0 + finally: + db.close() + + +def test_github_clone_timeout_is_reported(monkeypatch: pytest.MonkeyPatch, tmp_path: Path): def fake_run(*args, **kwargs): raise subprocess.TimeoutExpired(cmd=args[0], timeout=kwargs["timeout"]) @@ -159,6 +450,107 @@ def fake_run(*args, **kwargs): from app.core.config import Settings - client = GitHubClient(Settings(clone_timeout_seconds=1)) + auth_client = GitHubClient(Settings(clone_timeout_seconds=1)) with pytest.raises(TimeoutServiceError): - client.clone_public_repository("https://github.com/example/demo", tmp_path / "demo") + auth_client.clone_public_repository("https://github.com/example/demo", tmp_path / "demo") + + +def test_github_clone_failure_for_private_or_nonexistent_repository_is_reported( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +): + """`git clone` fails identically for a private repo, a nonexistent repo, + and a nonexistent branch/revision -- there is no separate signal to + distinguish them from the client's perspective, so the client's own + honest, non-leaking message covers all three (#319: "invalid revisions + and honest error responses", "non-public GitHub URLs").""" + + def fake_run(*args, **kwargs): + raise subprocess.CalledProcessError( + returncode=128, + cmd=args[0], + output="", + stderr="fatal: repository 'https://github.com/example/private-or-missing/' not found", + ) + + monkeypatch.setattr(subprocess, "run", fake_run) + + from app.core.config import Settings + from app.core.exceptions import ExternalServiceError + + destination = tmp_path / "demo" + github_client = GitHubClient(Settings()) + with pytest.raises(ExternalServiceError) as caught: + github_client.clone_public_repository("https://github.com/example/private-or-missing", destination) + + assert ( + caught.value.message + == "Failed to clone GitHub repository. Confirm the repository is public and the branch exists." + ) + # The failed clone attempt must not leave a partial checkout on disk. + assert not destination.exists() + + +def test_github_import_reports_a_private_or_nonexistent_repository_honestly( + auth_client, monkeypatch: pytest.MonkeyPatch +): + """The same failure through the real HTTP import path: a clean 502, not + a 500, and no repository record left behind.""" + + def fake_run(*args, **kwargs): + raise subprocess.CalledProcessError(returncode=128, cmd=args[0], output="", stderr="fatal: not found") + + monkeypatch.setattr(subprocess, "run", fake_run) + + response = auth_client.post("/repositories/github", json={"url": "https://github.com/example/private-or-missing"}) + + error = assert_error_response(response, 502, "external_service_error") + assert error.message == "Failed to clone GitHub repository. Confirm the repository is public and the branch exists." + + from app.core.database import SessionLocal + + db = SessionLocal() + try: + assert db.query(RepositoryRecord).count() == 0 + finally: + db.close() + + +def test_git_head_ref_resolves_branches_and_detached_tags(tmp_path: Path): + from app.core.config import Settings + + repository = tmp_path / "git-repository" + subprocess.run(["git", "init", "-b", "main", str(repository)], check=True, capture_output=True) + subprocess.run(["git", "-C", str(repository), "config", "user.name", "test"], check=True) + subprocess.run(["git", "-C", str(repository), "config", "user.email", "test@example.com"], check=True) + (repository / "README.md").write_text("revision\n", encoding="utf-8") + subprocess.run(["git", "-C", str(repository), "add", "README.md"], check=True) + subprocess.run(["git", "-C", str(repository), "commit", "-m", "initial"], check=True, capture_output=True) + + github = GitHubClient(Settings()) + assert github.read_head_ref(repository) == "refs/heads/main" + subprocess.run(["git", "-C", str(repository), "tag", "v1.0.0"], check=True) + subprocess.run(["git", "-C", str(repository), "checkout", "--detach", "v1.0.0"], check=True, capture_output=True) + assert github.read_head_ref(repository, "v1.0.0") == "refs/tags/v1.0.0" + + +def test_github_clone_over_size_limit_aborts_and_cleans_up(monkeypatch: pytest.MonkeyPatch, tmp_path: Path): + import subprocess + + from app.core.config import Settings + from app.core.exceptions import ValidationServiceError + + destination = tmp_path / "demo" + + def fake_run(*args, **kwargs): + destination.mkdir(parents=True, exist_ok=True) + (destination / "big.bin").write_bytes(b"x" * 4096) + return subprocess.CompletedProcess(args=args[0], returncode=0, stdout="", stderr="") + + monkeypatch.setattr(subprocess, "run", fake_run) + + auth_client = GitHubClient(Settings(max_clone_size_bytes=1024)) + with pytest.raises(ValidationServiceError): + auth_client.clone_public_repository("https://github.com/example/demo", destination) + + # Over-limit clone must be removed from disk. + assert not destination.exists() diff --git a/apps/backend/tests/test_ingestion_resource_budgets.py b/apps/backend/tests/test_ingestion_resource_budgets.py new file mode 100644 index 00000000..01ccfcad --- /dev/null +++ b/apps/backend/tests/test_ingestion_resource_budgets.py @@ -0,0 +1,319 @@ +"""Resource-budget enforcement during archive extraction and repository ingestion. + +`LocalStorage.extract_archive` bounds nothing about decompressed size or +entry count while iterating zip/tar members, and neither ingestion path +(`RepositoryService.import_github_repository` / +`RepositoryService.import_uploaded_repository`) bounds the total number of +files a repository resolves to. These tests exercise the new +`max_extracted_size_bytes` / `max_extracted_entries` / `max_file_count` +budgets end to end through the API, with the relevant setting lowered via +environment variables (never real gigabyte-scale payloads) so the checks +trip on small, fast fixtures. +""" + +import io +import os +import tarfile +import zipfile +from collections.abc import Generator +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + +from tests.api_assertions import assert_error_response +from tests.conftest import register_user + + +def _zip_bytes(files: dict[str, str]) -> bytes: + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as archive: + for path, content in files.items(): + archive.writestr(path, content) + return buffer.getvalue() + + +def _tar_gz_bytes(files: dict[str, str]) -> bytes: + buffer = io.BytesIO() + with tarfile.open(fileobj=buffer, mode="w:gz") as archive: + for path, content in files.items(): + data = content.encode("utf-8") + info = tarfile.TarInfo(path) + info.size = len(data) + archive.addfile(info, io.BytesIO(data)) + return buffer.getvalue() + + +def _upload(client: TestClient, filename: str, content: bytes): + return client.post( + "/repositories/upload", + files={"file": (filename, content, "application/octet-stream")}, + ) + + +def _repositories_dir_is_empty(storage_path: Path) -> bool: + repositories_dir = storage_path / "repositories" + if not repositories_dir.exists(): + return True + return not any(repositories_dir.iterdir()) + + +def _build_client( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, extra_env: dict[str, str] +) -> Generator[TestClient, None, None]: + """The standard ``client`` fixture body, with one ingestion budget lowered. + + Mirrors ``tests.conftest.client`` / ``tests.test_rate_limit.limited_client``: + the relevant setting is set via environment variable *before* + ``create_app()`` builds the dependency graph, since ``Settings`` is only + ever constructed from the environment (see ``app/core/config.py``). + """ + database_path = tmp_path / "partha-test.db" + storage_path = tmp_path / "storage" + monkeypatch.setenv("DATABASE_URL", f"sqlite:///{database_path}") + monkeypatch.setenv("STORAGE_PATH", str(storage_path)) + monkeypatch.setenv("AUTO_CREATE_TABLES", "true") + monkeypatch.setenv("CORS_ORIGINS", "http://testserver") + for key, value in extra_env.items(): + monkeypatch.setenv(key, value) + + from app.core import config + + config.get_settings.cache_clear() + + import app.core.database as database + + settings = config.get_settings() + database.settings = settings + database.engine.dispose() + database.connect_args = {"check_same_thread": False} + database.engine = database.create_engine( + settings.database_url, pool_pre_ping=True, connect_args=database.connect_args + ) + database.SessionLocal.configure(bind=database.engine) + + from app.core.schema_sync import stamp_head + from app.main import create_app + from app.models.base import Base + + Base.metadata.create_all(bind=database.engine) + # Mirrors what the app's own lifespan does for a genuinely fresh database + # (#166); see the identical comment in tests/conftest.py. + stamp_head(database.engine) + with TestClient(create_app()) as test_client: + auth = register_user(test_client, "budget@example.com") + test_client.headers.update(auth["headers"]) + test_client.storage_path = storage_path # type: ignore[attr-defined] + yield test_client + + for key in ("DATABASE_URL", "STORAGE_PATH", "AUTO_CREATE_TABLES", "CORS_ORIGINS", *extra_env): + os.environ.pop(key, None) + config.get_settings.cache_clear() + + +@pytest.fixture() +def size_limited_client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Generator[TestClient, None, None]: + """``max_extracted_size_bytes`` lowered to 1KiB; other budgets stay generous.""" + yield from _build_client(tmp_path, monkeypatch, {"MAX_EXTRACTED_SIZE_BYTES": "1024"}) + + +@pytest.fixture() +def entries_limited_client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Generator[TestClient, None, None]: + """``max_extracted_entries`` lowered to 5; other budgets stay generous.""" + yield from _build_client(tmp_path, monkeypatch, {"MAX_EXTRACTED_ENTRIES": "5"}) + + +@pytest.fixture() +def file_count_limited_client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Generator[TestClient, None, None]: + """``max_file_count`` lowered to 5; other budgets stay generous.""" + yield from _build_client(tmp_path, monkeypatch, {"MAX_FILE_COUNT": "5"}) + + +# --- extracted-size budget ------------------------------------------------- + + +def test_zip_archive_over_extracted_size_limit_is_rejected_and_cleaned_up(size_limited_client: TestClient): + response = _upload( + size_limited_client, + "bomb.zip", + # A single member whose real (decompressed) size exceeds the 1KiB cap. + # zipfile.ZipInfo.file_size always reflects the actual data length, so + # this is a genuine oversized member, not a spoofed one. + _zip_bytes({"bomb/payload.bin": "x" * 4096}), + ) + + error = assert_error_response(response, 422, "validation_error") + assert error.message == "Archive would decompress to more than the configured maximum size." + assert _repositories_dir_is_empty(size_limited_client.storage_path) # type: ignore[attr-defined] + + +def test_tar_archive_over_extracted_size_limit_is_rejected_and_cleaned_up(size_limited_client: TestClient): + response = _upload( + size_limited_client, + "bomb.tar.gz", + _tar_gz_bytes({"bomb/payload.bin": "x" * 4096}), + ) + + error = assert_error_response(response, 422, "validation_error") + assert error.message == "Archive would decompress to more than the configured maximum size." + assert _repositories_dir_is_empty(size_limited_client.storage_path) # type: ignore[attr-defined] + + +def test_many_small_members_summing_over_extracted_size_limit_is_rejected(size_limited_client: TestClient): + # No single member is large, but the cumulative decompressed total is. + files = {f"bomb/part{i}.bin": "x" * 200 for i in range(10)} # 2000 bytes > 1024 cap + response = _upload(size_limited_client, "many-parts.zip", _zip_bytes(files)) + + error = assert_error_response(response, 422, "validation_error") + assert error.message == "Archive would decompress to more than the configured maximum size." + assert _repositories_dir_is_empty(size_limited_client.storage_path) # type: ignore[attr-defined] + + +# --- extracted-entry-count budget ------------------------------------------ + + +def test_zip_archive_over_extracted_entries_limit_is_rejected_and_cleaned_up(entries_limited_client: TestClient): + files = {f"many/file{i}.txt": "x" for i in range(10)} # 10 entries > 5 cap + response = _upload(entries_limited_client, "many-entries.zip", _zip_bytes(files)) + + error = assert_error_response(response, 422, "validation_error") + assert error.message == "Archive contains more entries than the configured maximum." + assert _repositories_dir_is_empty(entries_limited_client.storage_path) # type: ignore[attr-defined] + + +def test_tar_archive_over_extracted_entries_limit_is_rejected_and_cleaned_up(entries_limited_client: TestClient): + files = {f"many/file{i}.txt": "x" for i in range(10)} + response = _upload(entries_limited_client, "many-entries.tar.gz", _tar_gz_bytes(files)) + + error = assert_error_response(response, 422, "validation_error") + assert error.message == "Archive contains more entries than the configured maximum." + assert _repositories_dir_is_empty(entries_limited_client.storage_path) # type: ignore[attr-defined] + + +# --- repository-wide file-count budget -------------------------------------- + + +def test_upload_with_file_tree_over_file_count_limit_is_rejected_and_cleaned_up( + file_count_limited_client: TestClient, +): + # 8 files > MAX_FILE_COUNT=5, but well inside the (default, generous) + # extracted-size/entry-count budgets, so this exercises the + # RepositoryService-level check specifically, not the archive-level ones. + files = {f"project/file{i}.py": "print(1)\n" for i in range(8)} + response = _upload(file_count_limited_client, "project.zip", _zip_bytes(files)) + + error = assert_error_response(response, 422, "validation_error") + assert error.message == "Repository exceeds the configured maximum file count." + assert error.details == {"maxFileCount": 5, "fileCount": 6} + assert _repositories_dir_is_empty(file_count_limited_client.storage_path) # type: ignore[attr-defined] + + +def test_github_import_with_file_tree_over_file_count_limit_is_rejected_and_cleaned_up( + file_count_limited_client: TestClient, monkeypatch: pytest.MonkeyPatch +): + from app.github.client import GitHubClient + + def fake_clone(_: GitHubClient, __: str, destination: Path, ___: str | None = None) -> None: + destination.mkdir(parents=True, exist_ok=True) + for i in range(8): + (destination / f"file{i}.py").write_text("print(1)\n", encoding="utf-8") + + monkeypatch.setattr(GitHubClient, "clone_public_repository", fake_clone) + monkeypatch.setattr(GitHubClient, "read_head_commit", lambda *_: "a" * 40) + monkeypatch.setattr(GitHubClient, "read_head_ref", lambda *_: "refs/heads/main") + + response = file_count_limited_client.post("/repositories/github", json={"url": "https://github.com/example/demo"}) + + error = assert_error_response(response, 422, "validation_error") + assert error.message == "Repository exceeds the configured maximum file count." + assert error.details == {"maxFileCount": 5, "fileCount": 6} + assert _repositories_dir_is_empty(file_count_limited_client.storage_path) # type: ignore[attr-defined] + + +# --- archive path safety ------------------------------------------------------ +# +# These cover the guards in ``LocalStorage._safe_extract_tar`` / +# ``_safe_extract_zip`` that reject traversal and link members. They were +# previously untested, so a regression would have been silent — and the tar +# path additionally relies on ``extractall(filter="data")`` as defence in depth. + + +def _malicious_tar_gz_bytes(members: list[tarfile.TarInfo], payload: bytes = b"pwned") -> bytes: + """A tar built from raw ``TarInfo`` objects, so unsafe members can be forged.""" + buffer = io.BytesIO() + with tarfile.open(fileobj=buffer, mode="w:gz") as archive: + for info in members: + if info.isreg(): + info.size = len(payload) + archive.addfile(info, io.BytesIO(payload)) + else: + archive.addfile(info) + return buffer.getvalue() + + +def test_tar_archive_with_parent_traversal_path_is_rejected(auth_client): + """A member escaping the destination via ``..`` must never be written.""" + escaping = tarfile.TarInfo("../escaped.txt") + escaping.type = tarfile.REGTYPE + + response = _upload(auth_client, "evil.tar.gz", _malicious_tar_gz_bytes([escaping])) + + error = assert_error_response(response, 422, "validation_error") + assert error.message == "Archive contains unsafe paths." + + +def test_tar_archive_with_absolute_path_is_rejected(auth_client): + """An absolute member path must not be able to write outside the sandbox.""" + absolute = tarfile.TarInfo("/tmp/partha-escaped.txt") + absolute.type = tarfile.REGTYPE + + response = _upload(auth_client, "evil.tar.gz", _malicious_tar_gz_bytes([absolute])) + + error = assert_error_response(response, 422, "validation_error") + assert error.message == "Archive contains unsafe paths." + + +def test_tar_archive_with_symlink_member_is_rejected(auth_client): + """Symlinks are refused outright: they are the classic extraction escape.""" + link = tarfile.TarInfo("sample/link") + link.type = tarfile.SYMTYPE + link.linkname = "/etc/passwd" + + response = _upload(auth_client, "evil.tar.gz", _malicious_tar_gz_bytes([link])) + + error = assert_error_response(response, 422, "validation_error") + assert error.message == "Archive contains unsupported link or device entries." + + +def test_zip_archive_with_parent_traversal_path_is_rejected(auth_client): + """The zip path enforces the same containment rule as the tar path.""" + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as archive: + archive.writestr("../escaped.txt", "pwned") + + response = _upload(auth_client, "evil.zip", buffer.getvalue()) + + error = assert_error_response(response, 422, "validation_error") + assert error.message == "Archive contains unsafe paths." + + +# --- regression: unaffected happy path -------------------------------------- + + +def test_small_normal_upload_still_succeeds_under_default_budgets(auth_client): + response = _upload( + auth_client, + "sample.zip", + _zip_bytes( + { + "sample/package.json": '{"dependencies":{"react":"^18.0.0"}}', + "sample/src/main.tsx": "import React from 'react';", + "sample/README.md": "# Sample", + } + ), + ) + + assert response.status_code == 201 + repository = response.json() + assert repository["name"] == "sample" + assert repository["status"] == "analysing" diff --git a/apps/backend/tests/test_insights_relationship_diagnostics.py b/apps/backend/tests/test_insights_relationship_diagnostics.py new file mode 100644 index 00000000..9b0b6671 --- /dev/null +++ b/apps/backend/tests/test_insights_relationship_diagnostics.py @@ -0,0 +1,110 @@ +"""Unit tests for the repository-insights unresolved-relationship split.""" + +from __future__ import annotations + +from app.insights.relationship_diagnostics import ( + UnresolvedRelationshipContext, + split_unresolved_relationships, +) + +_DJANGO_KEY = "dep:pypi:django" +_REACT_KEY = "dep:npm:react" + + +def _split(diagnostics, *, kinds=None, referents=None, bindings=None, deps=frozenset()): + ctx = UnresolvedRelationshipContext( + observed_kind_by_observation=kinds or {}, + referent_by_observation=referents or {}, + import_specifier_by_local_name=bindings or {}, + declared_dependency_keys=deps, + ) + return split_unresolved_relationships(diagnostics, ctx) + + +def test_stdlib_and_declared_dependency_imports_are_external(): + split = _split( + [("app/a.py", "o1"), ("app/a.py", "o2"), ("app/a.py", "o3")], + kinds={"o1": "import", "o2": "import", "o3": "import"}, + referents={"o1": "pathlib.Path", "o2": "django.db.models", "o3": "__future__.annotations"}, + deps=frozenset({_DJANGO_KEY}), + ) + assert split == split.__class__(in_repo_gap=0, external_reference=3) + + +def test_relative_and_undeclared_imports_stay_in_repo_gaps(): + split = _split( + [("app/a.py", "o1"), ("app/a.py", "o2")], + kinds={"o1": "import", "o2": "import"}, + # A relative import and a bare package the repo never declares both + # still look like they should have been an in-repo reference. + referents={"o1": ".sibling.helper", "o2": "somevendorlib.client"}, + ) + assert split.in_repo_gap == 2 + assert split.external_reference == 0 + + +def test_call_bound_to_an_external_module_is_an_external_reference(): + split = _split( + [("web/app.py", "c1"), ("web/app.py", "c2")], + kinds={"c1": "call", "c2": "call"}, + referents={"c1": "jsonify", "c2": "reconcile"}, + # jsonify is imported from flask (declared); reconcile has no binding. + bindings={"web/app.py": {"jsonify": "flask"}}, + deps=frozenset({"dep:pypi:flask"}), + ) + assert split.external_reference == 1 + assert split.in_repo_gap == 1 + + +def test_bare_python_builtin_call_with_no_binding_is_external(): + # An already-sealed pre-#392 snapshot still has these recorded. + split = _split( + [("m.py", "b1"), ("m.py", "b2"), ("m.py", "b3")], + kinds={"b1": "call", "b2": "call", "b3": "call"}, + referents={"b1": "len", "b2": "print", "b3": "helper_defined_nowhere"}, + ) + assert split.external_reference == 2 + assert split.in_repo_gap == 1 + + +def test_js_test_runner_globals_are_external_only_in_js_files(): + split = _split( + [("web/x.test.tsx", "j1"), ("web/x.test.tsx", "j2"), ("api/x.py", "j3")], + kinds={"j1": "call", "j2": "call", "j3": "call"}, + referents={"j1": "expect", "j2": "describe", "j3": "expect"}, + ) + # expect/describe are ambient in the .tsx test; the same name in a .py file + # is not a Python builtin, so it stays a gap. + assert split.external_reference == 2 + assert split.in_repo_gap == 1 + + +def test_unknown_observation_or_missing_path_counts_as_a_gap(): + split = _split( + [("app/a.py", "missing"), (None, "c1")], + kinds={"c1": "call"}, + referents={"c1": "len"}, + ) + assert split.in_repo_gap == 2 + assert split.external_reference == 0 + + +def test_non_reference_kinds_are_never_reclassified(): + # http_call / iac_resource / dependency unresolveds are genuine gaps. + split = _split( + [("infra/main.tf", "h1")], + kinds={"h1": "iac_resource"}, + referents={"h1": "aws_s3_bucket.logs"}, + ) + assert split.in_repo_gap == 1 + assert split.external_reference == 0 + + +def test_total_always_equals_the_diagnostic_count(): + diagnostics = [(f"f{i}.py", f"o{i}") for i in range(20)] + kinds = {f"o{i}": "call" for i in range(20)} + referents = {f"o{i}": ("len" if i % 2 else "local_thing") for i in range(20)} + split = _split(diagnostics, kinds=kinds, referents=referents) + assert split.total == 20 + assert split.external_reference == 10 + assert split.in_repo_gap == 10 diff --git a/apps/backend/tests/test_intelligence_query_api.py b/apps/backend/tests/test_intelligence_query_api.py new file mode 100644 index 00000000..340cf8fc --- /dev/null +++ b/apps/backend/tests/test_intelligence_query_api.py @@ -0,0 +1,578 @@ +"""API coverage for the owner-scoped, stored-snapshot query boundary (#92).""" + +from uuid import uuid4 + +import pytest + +from app.intelligence.query_service import IMPACT_MAX_DEPTH + + +def _seed_snapshot(owner_id: str, *, suffix: str = "one", schema_version: str = "ri.v1") -> tuple[str, str]: + """Persist a sealed graph while deliberately pointing at no real worktree.""" + + from app.core.database import SessionLocal + from app.intelligence.snapshot_store import Evidence, Revision, SnapshotStore, node_ref, observation_ref + from app.models.repository import RepositoryRecord + + db = SessionLocal() + try: + repository_id = str(uuid4()) + revision_value = "sha256:" + ("a" if suffix == "one" else "b") * 64 + db.add( + RepositoryRecord( + id=repository_id, + owner_id=owner_id, + name=f"query-{suffix}", + source="upload", + revision_kind="upload", + revision_value=revision_value, + local_path=f"/definitely/inaccessible/{repository_id}", + status="completed", + file_tree=[], + ) + ) + db.commit() + store = SnapshotStore(db) + snapshot = store.begin( + repository_id=repository_id, + revision=Revision("upload", revision_value), + producer_version_set=["inventory@1.0.0", "resolver@1.0.0"], + schema_version=schema_version, + ) + + def evidence(path: str, start: int, end: int, producer: str = "inventory") -> Evidence: + return Evidence(path, start, end, producer, "1.0.0", logical_line_count=40) + + store.add_node(snapshot, node_kind="repository", stable_key="repo:root", evidence=[evidence("README.md", 1, 2)]) + store.add_node( + snapshot, + node_kind="file", + stable_key="file:src/app.py", + name="app.py", + language="python", + evidence=[evidence("src/app.py", 1, 40)], + ) + store.add_node( + snapshot, + node_kind="symbol", + stable_key=f"src/app.py::a_{suffix}", + name=f"a_{suffix}", + language="python", + evidence=[evidence("src/app.py", 4, 6)], + ) + store.add_node( + snapshot, + node_kind="symbol", + stable_key=f"src/app.py::z_{suffix}", + name=f"z_{suffix}", + language="python", + evidence=[evidence("src/app.py", 10, 12)], + ) + observation = store.add_observation( + snapshot, + observed_kind="import", + subject_kind="symbol", + subject_key=f"src/app.py::a_{suffix}", + referent_text="src/app.py::z_missing", + evidence=evidence("src/app.py", 5, 5), + ) + store.add_observation( + snapshot, + observed_kind="import", + subject_kind="symbol", + subject_key=f"src/app.py::z_{suffix}", + referent_text="not.resolvable", + evidence=evidence("src/app.py", 11, 11), + ) + store.add_edge( + snapshot, + subject_kind="symbol", + subject_key=f"src/app.py::a_{suffix}", + predicate="imports", + object_kind="file", + object_key="file:src/app.py", + producer="resolver", + producer_version="1.0.0", + evidence=[evidence("src/app.py", 5, 5, "resolver")], + derived_from=[observation_ref(observation.observation_id)], + ) + store.add_assertion( + snapshot, + subject_kind="symbol", + subject_key=f"src/app.py::a_{suffix}", + predicate="classified_as", + value={"classification": "entrypoint"}, + producer="resolver", + producer_version="1.0.0", + derived_from=[node_ref(f"src/app.py::a_{suffix}")], + ) + return repository_id, store.seal(snapshot).snapshot_id + finally: + db.close() + + +def _seed_impact_snapshot(owner_id: str) -> tuple[str, str]: + """Persist an intentionally cyclic graph without a readable worktree.""" + + from app.core.database import SessionLocal + from app.intelligence.snapshot_store import Evidence, Revision, SnapshotStore, observation_ref + from app.models.repository import RepositoryRecord + + db = SessionLocal() + try: + repository_id = str(uuid4()) + revision_value = "sha256:" + "c" * 64 + db.add( + RepositoryRecord( + id=repository_id, + owner_id=owner_id, + name="impact-query", + source="upload", + revision_kind="upload", + revision_value=revision_value, + local_path=f"/definitely/inaccessible/{repository_id}", + status="completed", + file_tree=[], + ) + ) + db.commit() + store = SnapshotStore(db) + snapshot = store.begin( + repository_id=repository_id, + revision=Revision("upload", revision_value), + producer_version_set=["inventory@1.0.0", "resolver@1.0.0"], + ) + + def evidence(path: str, line: int, producer: str = "inventory") -> Evidence: + return Evidence(path, line, line, producer, "1.0.0", logical_line_count=40) + + store.add_node(snapshot, node_kind="repository", stable_key="repo:root", evidence=[evidence("README.md", 1)]) + for index, path in enumerate(("a.py", "b.py", "c.py", "d.py"), start=2): + store.add_node( + snapshot, + node_kind="file", + stable_key=f"file:src/{path}", + name=path, + language="python", + evidence=[evidence(f"src/{path}", index)], + ) + store.add_node( + snapshot, + node_kind="dependency", + stable_key="dep:pypi:requests", + name="requests", + evidence=[evidence("pyproject.toml", 6)], + ) + + def edge( + subject_kind: str, + subject_key: str, + predicate: str, + object_kind: str, + object_key: str, + line: int, + ) -> None: + path = "pyproject.toml" if predicate == "depends_on" else "src/relationships.py" + observation = store.add_observation( + snapshot, + observed_kind="dependency" if predicate == "depends_on" else "import", + subject_kind=subject_kind, + subject_key=subject_key, + referent_text=object_key, + evidence=evidence(path, line), + ) + store.add_edge( + snapshot, + subject_kind=subject_kind, + subject_key=subject_key, + predicate=predicate, + object_kind=object_kind, + object_key=object_key, + producer="resolver", + producer_version="1.0.0", + evidence=[evidence(path, line, "resolver")], + derived_from=[observation_ref(observation.observation_id)], + ) + + # A structural edge is deliberately present and must not enter the + # impact result. The import edges form a cycle A -> B -> C -> A, with + # a second direct relationship A -> D and a separate manifest edge. + edge("repository", "repo:root", "contains", "file", "file:src/a.py", 7) + edge("repository", "repo:root", "depends_on", "dependency", "dep:pypi:requests", 8) + edge("file", "file:src/a.py", "imports", "file", "file:src/b.py", 9) + edge("file", "file:src/b.py", "imports", "file", "file:src/c.py", 10) + edge("file", "file:src/c.py", "imports", "file", "file:src/a.py", 11) + edge("file", "file:src/a.py", "imports", "file", "file:src/d.py", 12) + edge("file", "file:src/d.py", "imports", "file", "file:src/a.py", 13) + return repository_id, store.seal(snapshot).snapshot_id + finally: + db.close() + + +def _seed_duplicate_heavy_impact_snapshot(owner_id: str) -> tuple[str, str]: + """Persist a graph where duplicate paths would hide the overflow node.""" + + from app.core.database import SessionLocal + from app.intelligence.snapshot_store import Evidence, Revision, SnapshotStore, observation_ref + from app.models.repository import RepositoryRecord + + db = SessionLocal() + try: + repository_id = str(uuid4()) + revision_value = "sha256:" + "d" * 64 + db.add( + RepositoryRecord( + id=repository_id, + owner_id=owner_id, + name="impact-duplicates", + source="upload", + revision_kind="upload", + revision_value=revision_value, + local_path=f"/definitely/inaccessible/{repository_id}", + status="completed", + file_tree=[], + ) + ) + db.commit() + store = SnapshotStore(db) + snapshot = store.begin( + repository_id=repository_id, + revision=Revision("upload", revision_value), + producer_version_set=["inventory@1.0.0", "resolver@1.0.0"], + ) + + def evidence(line: int, producer: str = "inventory") -> Evidence: + return Evidence("src/graph.py", line, line, producer, "1.0.0", logical_line_count=300) + + store.add_node(snapshot, node_kind="repository", stable_key="repo:root", evidence=[evidence(1)]) + for number in range(4): + key = f"file:frontier/{number:02d}.py" + store.add_node( + snapshot, node_kind="file", stable_key=key, name=key.rsplit("/", 1)[-1], evidence=[evidence(number + 2)] + ) + for number in range(96): + key = f"file:shared/{number:03d}.py" + store.add_node( + snapshot, node_kind="file", stable_key=key, name=key.rsplit("/", 1)[-1], evidence=[evidence(number + 6)] + ) + store.add_node( + snapshot, + node_kind="file", + stable_key="file:overflow.py", + name="overflow.py", + evidence=[evidence(102)], + ) + + def edge(subject_key: str, object_key: str, line: int) -> None: + observation = store.add_observation( + snapshot, + observed_kind="import", + subject_kind="file" if subject_key != "repo:root" else "repository", + subject_key=subject_key, + referent_text=object_key, + evidence=evidence(line), + ordinal=line, + ) + store.add_edge( + snapshot, + subject_kind="file" if subject_key != "repo:root" else "repository", + subject_key=subject_key, + predicate="imports", + object_kind="file", + object_key=object_key, + producer="resolver", + producer_version="1.0.0", + evidence=[evidence(line, "resolver")], + derived_from=[observation_ref(observation.observation_id)], + ) + + for number in range(4): + edge("repo:root", f"file:frontier/{number:02d}.py", 103 + number) + for number in range(96): + edge("file:frontier/00.py", f"file:shared/{number:03d}.py", 107 + number) + for number in range(13): + edge("file:frontier/01.py", f"file:shared/{number:03d}.py", 203 + number) + edge("file:frontier/03.py", "file:overflow.py", 216) + return repository_id, store.seal(snapshot).snapshot_id + finally: + db.close() + + +def test_snapshot_query_endpoints_are_authenticated_owner_scoped_and_filesystem_independent(client, make_auth_headers): + alice = make_auth_headers("alice-query@example.com") + bob = make_auth_headers("bob-query@example.com") + _, alice_snapshot = _seed_snapshot(alice["user"]["id"]) + _, bob_snapshot = _seed_snapshot(bob["user"]["id"], suffix="two") + + endpoints = [ + "", + "/symbols", + "/neighbours?nodeKey=repo:root", + "/impact?nodeKey=repo:root", + "/references", + "/assertions", + "/paths", + "/evidence", + ] + for endpoint in endpoints: + unauthenticated = client.get(f"/intelligence/v1/snapshots/{alice_snapshot}{endpoint}") + assert unauthenticated.status_code == 401 + + metadata = client.get(f"/intelligence/v1/snapshots/{alice_snapshot}", headers=alice["headers"]) + assert metadata.status_code == 200 + assert metadata.json()["schemaVersion"] == "ri.v1" + assert metadata.json()["state"] == "completed" + assert metadata.json()["repositoryId"] + assert metadata.json()["canonicalGraphHash"].startswith("sha256:") + + for endpoint in endpoints: + denied = client.get(f"/intelligence/v1/snapshots/{alice_snapshot}{endpoint}", headers=bob["headers"]) + missing = client.get(f"/intelligence/v1/snapshots/snap_missing{endpoint}", headers=alice["headers"]) + assert denied.status_code == missing.status_code == 404 + assert denied.json()["code"] == missing.json()["code"] == "not_found" + assert denied.json()["message"] == missing.json()["message"] == "Snapshot not found." + + # A second snapshot owned by Bob is not visible in Alice's collection queries. + assert client.get(f"/intelligence/v1/snapshots/{bob_snapshot}/symbols", headers=alice["headers"]).status_code == 404 + + +def test_snapshot_query_mappings_and_deterministic_pagination(client, make_auth_headers): + owner = make_auth_headers("owner-query@example.com") + _, snapshot_id = _seed_snapshot(owner["user"]["id"]) + base = f"/intelligence/v1/snapshots/{snapshot_id}" + + first = client.get(f"{base}/symbols?limit=1", headers=owner["headers"]) + second = client.get(f"{base}/symbols?limit=1&offset=1", headers=owner["headers"]) + assert first.status_code == second.status_code == 200 + assert first.json()["pagination"] == {"offset": 0, "limit": 1, "total": 2} + assert second.json()["pagination"] == {"offset": 1, "limit": 1, "total": 2} + assert first.json()["data"][0]["stableKey"] == "src/app.py::a_one" + assert second.json()["data"][0]["stableKey"] == "src/app.py::z_one" + assert first.json()["data"][0]["truthClass"] == "observed" + assert first.json()["data"][0]["evidence"] == [ + { + "schemaVersion": "ri.v1", + "factKind": "node", + "factId": "src/app.py::a_one", + "path": "src/app.py", + "startLine": 4, + "endLine": 6, + "granularity": "span", + "extractor": "inventory", + "extractorVersion": "1.0.0", + } + ] + + neighbours = client.get(f"{base}/neighbours?nodeKey=src/app.py::a_one", headers=owner["headers"]) + assert neighbours.status_code == 200 + edge = neighbours.json()["data"][0] + assert edge["predicate"] == "imports" + assert edge["truthClass"] == "resolved" + assert edge["derivedFrom"][0]["kind"] == "observation" + assert edge["evidence"][0]["extractorVersion"] == "1.0.0" + + references = client.get(f"{base}/references", headers=owner["headers"]) + assert references.status_code == 200 + assert [item["edgeId"] for item in references.json()["data"]] == [edge["edgeId"]] + # The unresolvable stored observation is not elevated to a relationship fact. + assert "not.resolvable" not in references.text + + assertions = client.get(f"{base}/assertions", headers=owner["headers"]) + assert assertions.status_code == 200 + assert assertions.json()["data"][0]["truthClass"] == "inferred" + assert assertions.json()["data"][0]["derivedFrom"] == [{"kind": "node", "identity": "src/app.py::a_one"}] + + paths = client.get(f"{base}/paths", headers=owner["headers"]) + assert paths.status_code == 200 + assert paths.json()["data"][0]["path"] == "src/app.py" + assert paths.json()["data"][0]["node"]["evidence"][0]["path"] == "src/app.py" + + evidence = client.get(f"{base}/evidence", headers=owner["headers"]) + assert evidence.status_code == 200 + assert any(item["factKind"] == "edge" and item["extractor"] == "resolver" for item in evidence.json()["data"]) + + +def test_query_rejects_owner_visible_unsupported_schema_without_cross_owner_disclosure(client, make_auth_headers): + owner = make_auth_headers("owner-v2-query@example.com") + other_owner = make_auth_headers("other-v2-query@example.com") + _, snapshot_id = _seed_snapshot(owner["user"]["id"], schema_version="ri.v2") + path = f"/intelligence/v1/snapshots/{snapshot_id}" + + for suffix in [ + "", + "/symbols", + "/neighbours?nodeKey=repo:root", + "/impact?nodeKey=repo:root", + "/references", + "/assertions", + "/paths", + "/evidence", + ]: + rejected = client.get(f"{path}{suffix}", headers=owner["headers"]) + assert rejected.status_code == 422 + assert rejected.json()["code"] == "unsupported_schema_version" + assert rejected.json()["message"] == "Unsupported snapshot schema version: ri.v2." + assert rejected.json()["details"] == {"received": "ri.v2", "supported": ["ri.v1"]} + + denied = client.get(path, headers=other_owner["headers"]) + missing = client.get("/intelligence/v1/snapshots/snap_missing", headers=other_owner["headers"]) + assert denied.status_code == missing.status_code == 404 + assert denied.json()["code"] == missing.json()["code"] == "not_found" + assert denied.json()["message"] == missing.json()["message"] == "Snapshot not found." + + +@pytest.mark.parametrize("query", ["?limit=0", "?limit=101", "?offset=-1"]) +def test_snapshot_query_rejects_invalid_pagination(client, make_auth_headers, query): + owner = make_auth_headers(f"pagination-{uuid4().hex}@example.com") + _, snapshot_id = _seed_snapshot(owner["user"]["id"]) + response = client.get(f"/intelligence/v1/snapshots/{snapshot_id}/symbols{query}", headers=owner["headers"]) + assert response.status_code == 422 + assert response.json()["code"] == "request_validation_error" + + +def test_impact_query_returns_bounded_direct_and_transitive_relationships_with_provenance(client, make_auth_headers): + owner = make_auth_headers("impact-owner@example.com") + _, snapshot_id = _seed_impact_snapshot(owner["user"]["id"]) + base = f"/intelligence/v1/snapshots/{snapshot_id}/impact?nodeKey=file:src/a.py" + + depth_one = client.get(f"{base}&depth=1", headers=owner["headers"]) + depth_two = client.get(f"{base}&depth=2", headers=owner["headers"]) + repeated = client.get(f"{base}&depth=2", headers=owner["headers"]) + + assert depth_one.status_code == depth_two.status_code == repeated.status_code == 200 + assert depth_two.json() == repeated.json() + assert depth_two.json()["schemaVersion"] == "ri.v1" + assert depth_one.json()["depth"] == 1 + assert [item["nodeKey"] for item in depth_one.json()["dependencies"]["data"]] == [ + "file:src/b.py", + "file:src/d.py", + ] + assert [item["nodeKey"] for item in depth_two.json()["dependencies"]["data"]] == [ + "file:src/b.py", + "file:src/d.py", + "file:src/c.py", + ] + assert [item["depth"] for item in depth_two.json()["dependencies"]["data"]] == [1, 1, 2] + assert [item["nodeKey"] for item in depth_two.json()["dependents"]["data"]] == [ + "file:src/c.py", + "file:src/d.py", + "file:src/b.py", + ] + assert [item["depth"] for item in depth_two.json()["dependents"]["data"]] == [1, 1, 2] + assert "file:src/a.py" not in { + item["nodeKey"] for direction in ("dependencies", "dependents") for item in depth_two.json()[direction]["data"] + } + first_hop = depth_two.json()["dependencies"]["data"][0] + assert first_hop["via"]["predicate"] == "imports" + assert first_hop["via"]["evidence"][0]["extractor"] == "resolver" + assert first_hop["via"]["derivedFrom"][0]["kind"] == "observation" + assert depth_two.json()["dependencies"]["limitReached"] is False + assert depth_two.json()["dependents"]["limitReached"] is False + + repository = client.get( + f"/intelligence/v1/snapshots/{snapshot_id}/impact?nodeKey=repo:root", + headers=owner["headers"], + ) + assert repository.status_code == 200 + assert [item["nodeKey"] for item in repository.json()["dependencies"]["data"]] == ["dep:pypi:requests"] + + +def test_impact_query_enforces_depth_and_result_bounds(client, make_auth_headers, monkeypatch): + owner = make_auth_headers("impact-bounds@example.com") + _, snapshot_id = _seed_impact_snapshot(owner["user"]["id"]) + path = f"/intelligence/v1/snapshots/{snapshot_id}/impact?nodeKey=file:src/a.py" + + at_cap = client.get(f"{path}&depth={IMPACT_MAX_DEPTH}", headers=owner["headers"]) + too_deep = client.get(f"{path}&depth={IMPACT_MAX_DEPTH + 1}", headers=owner["headers"]) + zero_depth = client.get(f"{path}&depth=0", headers=owner["headers"]) + + assert at_cap.status_code == 200 + assert [item["nodeKey"] for item in at_cap.json()["dependencies"]["data"]] == [ + "file:src/b.py", + "file:src/d.py", + "file:src/c.py", + ] + assert len(at_cap.json()["dependencies"]["data"]) == len( + {item["nodeKey"] for item in at_cap.json()["dependencies"]["data"]} + ) + assert too_deep.status_code == zero_depth.status_code == 422 + assert too_deep.json()["code"] == zero_depth.json()["code"] == "request_validation_error" + + monkeypatch.setattr("app.intelligence.query_service.IMPACT_MAX_RESULTS_PER_DIRECTION", 1) + capped = client.get(f"{path}&depth=1", headers=owner["headers"]) + assert capped.status_code == 200 + assert [item["nodeKey"] for item in capped.json()["dependencies"]["data"]] == ["file:src/b.py"] + assert capped.json()["dependencies"]["limitReached"] is True + + +def test_impact_query_detects_cap_after_duplicate_paths_and_uses_canonical_hop(client, make_auth_headers): + """The cap applies after SQL deduplication, not to raw duplicate edges.""" + + owner = make_auth_headers("impact-duplicate-paths@example.com") + _, snapshot_id = _seed_duplicate_heavy_impact_snapshot(owner["user"]["id"]) + path = f"/intelligence/v1/snapshots/{snapshot_id}/impact?nodeKey=repo:root&depth=2" + + response = client.get(path, headers=owner["headers"]) + repeated = client.get(path, headers=owner["headers"]) + + assert response.status_code == repeated.status_code == 200 + assert response.json() == repeated.json() + dependencies = response.json()["dependencies"] + assert dependencies["limitReached"] is True + assert len(dependencies["data"]) == 100 + assert [item["nodeKey"] for item in dependencies["data"]] == [ + *(f"file:frontier/{number:02d}.py" for number in range(4)), + *(f"file:shared/{number:03d}.py" for number in range(96)), + ] + assert "file:overflow.py" not in {item["nodeKey"] for item in dependencies["data"]} + assert dependencies["data"][4]["via"]["subjectKey"] == "file:frontier/00.py" + assert dependencies["data"][4]["via"]["predicate"] == "imports" + + +@pytest.mark.parametrize( + "suffix", + [ + "", + "?nodeKey=", + "?nodeKey=file:src/a.py&depth=0", + f"?nodeKey=file:src/a.py&depth={IMPACT_MAX_DEPTH + 1}", + ], +) +def test_impact_query_rejects_malformed_parameters(client, make_auth_headers, suffix): + owner = make_auth_headers(f"impact-invalid-{uuid4().hex}@example.com") + _, snapshot_id = _seed_impact_snapshot(owner["user"]["id"]) + response = client.get(f"/intelligence/v1/snapshots/{snapshot_id}/impact{suffix}", headers=owner["headers"]) + + assert response.status_code == 422 + assert response.json()["code"] == "request_validation_error" + + +def test_impact_query_hides_unknown_nodes_and_cross_owner_snapshots(client, make_auth_headers): + owner = make_auth_headers("impact-visible@example.com") + other_owner = make_auth_headers("impact-hidden@example.com") + _, snapshot_id = _seed_impact_snapshot(owner["user"]["id"]) + path = f"/intelligence/v1/snapshots/{snapshot_id}/impact?nodeKey=file:src/missing.py" + + unknown_node = client.get(path, headers=owner["headers"]) + denied = client.get(path, headers=other_owner["headers"]) + missing = client.get( + "/intelligence/v1/snapshots/snap_missing/impact?nodeKey=file:src/missing.py", + headers=other_owner["headers"], + ) + + assert unknown_node.status_code == 404 + assert unknown_node.json()["code"] == "not_found" + assert unknown_node.json()["message"] == "Node not found in snapshot." + assert denied.status_code == missing.status_code == 404 + assert denied.json()["code"] == missing.json()["code"] == "not_found" + assert denied.json()["message"] == missing.json()["message"] == "Snapshot not found." + + +def test_snapshot_query_openapi_documents_versioned_routes_and_schemas(client): + document = client.get("/openapi.json").json() + assert "/intelligence/v1/snapshots/{snapshot_id}/symbols" in document["paths"] + assert "/intelligence/v1/snapshots/{snapshot_id}/impact" in document["paths"] + assert "RiImpactResponse" in document["components"]["schemas"] + assert "RiSymbolsResponse" in document["components"]["schemas"] + assert "RiEvidenceResponse" in document["components"]["schemas"] + assert document["components"]["schemas"]["RiSymbolsResponse"]["properties"]["schemaVersion"]["const"] == "ri.v1" diff --git a/apps/backend/tests/test_legacy_read_model_guard.py b/apps/backend/tests/test_legacy_read_model_guard.py new file mode 100644 index 00000000..1184928e --- /dev/null +++ b/apps/backend/tests/test_legacy_read_model_guard.py @@ -0,0 +1,75 @@ +"""Regression guard for Issue #171's single sealed product read model.""" + +import ast +from pathlib import Path + + +PRODUCT_ROOT = Path(__file__).parents[1] / "app" +EXCLUDED = { + PRODUCT_ROOT / "intelligence" / "engine.py", + PRODUCT_ROOT / "intelligence" / "__init__.py", +} + + +def test_product_code_has_no_legacy_intelligence_read_path(): + violations: list[str] = [] + for path in sorted(PRODUCT_ROOT.rglob("*.py")): + if path in EXCLUDED: + continue + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + for node in ast.walk(tree): + if isinstance(node, ast.ImportFrom) and node.module == "app.intelligence.engine": + violations.append(f"{path.relative_to(PRODUCT_ROOT)}:{node.lineno}: imports legacy engine") + if isinstance(node, ast.Call) and isinstance(node.func, ast.Name): + if node.func.id == "RepositoryIntelligenceEngine": + violations.append(f"{path.relative_to(PRODUCT_ROOT)}:{node.lineno}: constructs legacy engine") + if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute): + if node.func.attr in {"load", "from_record"}: + violations.append(f"{path.relative_to(PRODUCT_ROOT)}:{node.lineno}: calls {node.func.attr}()") + if ( + isinstance(node, ast.Subscript) + and isinstance(node.value, ast.Attribute) + and node.value.attr == "repo_metadata" + and isinstance(node.slice, ast.Constant) + and node.slice.value == "intelligence" + ): + violations.append( + f"{path.relative_to(PRODUCT_ROOT)}:{node.lineno}: reads repo_metadata['intelligence']" + ) + if ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and node.func.attr == "get" + and isinstance(node.func.value, ast.Attribute) + and node.func.value.attr == "repo_metadata" + and node.args + and isinstance(node.args[0], ast.Constant) + and node.args[0].value == "intelligence" + ): + violations.append( + f"{path.relative_to(PRODUCT_ROOT)}:{node.lineno}: reads repo_metadata.get('intelligence')" + ) + assert violations == [] + + +#: Analytical consumers per AGENTS.md's canonical-boundary rule: architecture, +#: dependencies, review, insights, AI and exports. `app/services` and +#: `app/workers` are deliberately excluded -- they legitimately read/write +#: `record.file_tree` as part of ingestion (populating the field the sealed +#: snapshot is built from), not as a canonical-source bypass. +ANALYTICAL_CONSUMER_DIRS = ("analysis", "graph", "review", "insights", "ai", "reports") + + +def test_analytical_consumers_never_read_repository_file_tree(): + """Regression guard for Issue #217: no analytical consumer may derive its + output from ``RepositoryRecord.file_tree`` -- a sealed ``ri.v1`` snapshot, + or an explicit missing-snapshot state, is the only allowed source.""" + + violations: list[str] = [] + for consumer_dir in ANALYTICAL_CONSUMER_DIRS: + for path in sorted((PRODUCT_ROOT / consumer_dir).rglob("*.py")): + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + for node in ast.walk(tree): + if isinstance(node, ast.Attribute) and node.attr == "file_tree": + violations.append(f"{path.relative_to(PRODUCT_ROOT)}:{node.lineno}: reads .file_tree") + assert violations == [] diff --git a/apps/backend/tests/test_list_waitlist_script.py b/apps/backend/tests/test_list_waitlist_script.py new file mode 100644 index 00000000..c6418fbd --- /dev/null +++ b/apps/backend/tests/test_list_waitlist_script.py @@ -0,0 +1,21 @@ +"""CSV/formula-injection guard for scripts/list_waitlist.py's --csv export.""" + +import pytest + +from scripts.list_waitlist import _csv_safe + + +@pytest.mark.parametrize( + "raw", + ["=cmd|'/c calc'!A1", "+1+1", "-2+3", "@SUM(A1:A9)"], +) +def test_leading_formula_trigger_is_neutralized(raw: str) -> None: + safe = _csv_safe(raw) + + assert safe.startswith("'") + assert safe == f"'{raw}" + + +@pytest.mark.parametrize("raw", ["Jane Doe", "person@example.com", ""]) +def test_ordinary_values_pass_through_unchanged(raw: str) -> None: + assert _csv_safe(raw) == raw diff --git a/apps/backend/tests/test_migration_rehearsal.py b/apps/backend/tests/test_migration_rehearsal.py new file mode 100644 index 00000000..66c2d21d --- /dev/null +++ b/apps/backend/tests/test_migration_rehearsal.py @@ -0,0 +1,279 @@ +import importlib.util +import os +import subprocess +import sys +from contextlib import contextmanager +from pathlib import Path + +import pytest +from sqlalchemy import create_engine, text +from sqlalchemy.engine import make_url + + +BACKEND_ROOT = Path(__file__).resolve().parents[1] +SCRIPT = BACKEND_ROOT / "scripts" / "rehearse_migrations.py" +PG_URL = os.environ.get("PARTHA_TEST_PG_URL") + + +def _load_rehearsal_module(): + """Import rehearse_migrations.py directly so its internals are reachable. + + scripts/ is a standalone maintainer command, not a package, so this + loads it by path the same way the tests below invoke it by path. + """ + + spec = importlib.util.spec_from_file_location("rehearse_migrations", SCRIPT) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module + + +def test_migration_rehearsal_runs_clean_and_representative_paths(): + result = subprocess.run( + [sys.executable, str(SCRIPT)], + cwd=BACKEND_ROOT, + capture_output=True, + text=True, + check=False, + env={**os.environ, "PARTHA_MIGRATION_REHEARSAL_PG_URL": ""}, + ) + + assert result.returncode == 0, result.stdout + result.stderr + assert "PASS clean upgrade -> clean downgrade -> re-upgrade" in result.stdout + assert "PASS representative 0004 baseline -> head" in result.stdout + assert "PASS migration rehearsal completed; disposable targets were removed." in result.stdout + + +def test_postgres_rehearsal_requires_explicit_disposable_confirmation(): + environment = { + **os.environ, + "PARTHA_MIGRATION_REHEARSAL_CONFIRM": "", + "PARTHA_MIGRATION_REHEARSAL_PG_URL": "postgresql+psycopg://should-not-appear:should-not-appear@db.example.invalid/postgres", + } + result = subprocess.run( + [sys.executable, str(SCRIPT), "--postgres"], + cwd=BACKEND_ROOT, + capture_output=True, + text=True, + check=False, + env=environment, + ) + + assert result.returncode == 1 + assert "PARTHA_MIGRATION_REHEARSAL_CONFIRM=disposable" in result.stdout + assert "should-not-appear" not in result.stdout + result.stderr + + +def test_postgres_rehearsal_requires_pg_url_when_confirmed(): + environment = { + **os.environ, + "PARTHA_MIGRATION_REHEARSAL_CONFIRM": "disposable", + "PARTHA_MIGRATION_REHEARSAL_PG_URL": "", + } + result = subprocess.run( + [sys.executable, str(SCRIPT), "--postgres"], + cwd=BACKEND_ROOT, + capture_output=True, + text=True, + check=False, + env=environment, + ) + + assert result.returncode == 1 + assert "PARTHA_MIGRATION_REHEARSAL_PG_URL" in result.stdout + + +def test_postgres_rehearsal_rejects_non_postgresql_url(): + environment = { + **os.environ, + "PARTHA_MIGRATION_REHEARSAL_CONFIRM": "disposable", + "PARTHA_MIGRATION_REHEARSAL_PG_URL": "sqlite:///should-not-be-used.db", + } + result = subprocess.run( + [sys.executable, str(SCRIPT), "--postgres"], + cwd=BACKEND_ROOT, + capture_output=True, + text=True, + check=False, + env=environment, + ) + + assert result.returncode == 1 + assert "must be a PostgreSQL URL" in result.stdout + + +def test_postgres_rehearsal_redacts_credentials_on_a_real_connection_failure(): + """A genuine (not just confirmation-gated) failure must still redact the URL. + + Unlike the confirmation test above, this uses a syntactically valid + PostgreSQL URL pointed at an unresolvable host, so the script actually + reaches the connection/redaction code path in `_run_phase` rather than + short-circuiting before ever reading the URL. + """ + + environment = { + **os.environ, + "PARTHA_MIGRATION_REHEARSAL_CONFIRM": "disposable", + "PARTHA_MIGRATION_REHEARSAL_PG_URL": ( + "postgresql+psycopg://rehearsal-user:should-not-appear-in-output@db.example.invalid/postgres" + ), + } + result = subprocess.run( + [sys.executable, str(SCRIPT), "--postgres"], + cwd=BACKEND_ROOT, + capture_output=True, + text=True, + check=False, + env=environment, + timeout=60, + ) + + assert result.returncode == 1 + combined_output = result.stdout + result.stderr + assert "should-not-appear-in-output" not in combined_output + assert "db.example.invalid" not in combined_output + assert "failed with a database error" in result.stdout + + +def test_generic_rehearsal_failure_does_not_print_exception_message(): + rehearsal = _load_rehearsal_module() + secret = "postgresql+psycopg://user:should-not-appear@private.example.invalid/postgres" + + @contextmanager + def target(): + yield "sqlite:///:memory:" + + def operation(_database_url): + raise RuntimeError(secret) + + with pytest.raises(rehearsal.RehearsalError) as captured: + rehearsal._run_phase("forced generic failure", operation, target) + + rendered = str(captured.value) + assert "RuntimeError" in rendered + assert "should-not-appear" not in rendered + assert "private.example.invalid" not in rendered + + +def test_main_fails_when_successful_rehearsal_cannot_clean_up(monkeypatch, capsys, tmp_path): + rehearsal = _load_rehearsal_module() + + @contextmanager + def target(): + yield f"sqlite:///{(tmp_path / 'cleanup-failure.db').as_posix()}" + raise rehearsal.RehearsalError("forced cleanup failure") + + monkeypatch.setattr(rehearsal, "_postgres_target", target) + monkeypatch.setattr(sys, "argv", [str(SCRIPT), "--postgres"]) + + assert rehearsal.main() == 1 + output = capsys.readouterr() + assert "FAIL migration rehearsal: forced cleanup failure" in output.out + assert "PASS migration rehearsal completed; disposable targets were removed." not in output.out + + +@pytest.mark.skipif(not PG_URL, reason="set PARTHA_TEST_PG_URL to run the Postgres cleanup test") +def test_postgres_target_drops_disposable_database_when_the_operation_fails(monkeypatch): + """The disposable database must not be leaked when rehearsal work fails. + + This is the core safety property #322 asks for: a failed rehearsal must + never leave a database behind on the rehearsal server. It exercises + `_postgres_target` directly (skipping Alembic) so the failure is + deterministic rather than depending on a real migration bug. + """ + + rehearsal = _load_rehearsal_module() + monkeypatch.setenv("PARTHA_MIGRATION_REHEARSAL_CONFIRM", "disposable") + monkeypatch.setenv("PARTHA_MIGRATION_REHEARSAL_PG_URL", PG_URL) + + admin_engine = create_engine(make_url(PG_URL), isolation_level="AUTOCOMMIT") + + class _ForcedFailure(RuntimeError): + pass + + captured_database_url = {} + + try: + with pytest.raises(_ForcedFailure): + with rehearsal._postgres_target() as database_url: + captured_database_url["url"] = database_url + raise _ForcedFailure("forced failure to prove disposable-database cleanup") + + disposable_database_name = make_url(captured_database_url["url"]).database + with admin_engine.connect() as connection: + still_exists = connection.scalar( + text("SELECT 1 FROM pg_database WHERE datname = :name"), + {"name": disposable_database_name}, + ) + assert still_exists is None, ( + f"disposable rehearsal database {disposable_database_name!r} was not " + "cleaned up after the rehearsal operation failed" + ) + finally: + admin_engine.dispose() + + +@pytest.mark.skipif(not PG_URL, reason="set PARTHA_TEST_PG_URL to run the Postgres cleanup test") +def test_postgres_target_disposes_engine_even_when_cleanup_fails(monkeypatch): + """A DROP DATABASE failure during teardown must not prevent engine.dispose(). + + This forces the exact failure mode the cleanup fix guards against: the + script's own DROP DATABASE call raises, and `engine.dispose()` must + still run afterward instead of being skipped. + """ + + rehearsal = _load_rehearsal_module() + from sqlalchemy.engine import Connection + + original_exec_driver_sql = Connection.exec_driver_sql + call_state = {"failed_once": False} + + def _flaky_exec_driver_sql(self, statement, *args, **kwargs): + if ( + isinstance(statement, str) + and statement.strip().upper().startswith("DROP DATABASE") + and not call_state["failed_once"] + ): + call_state["failed_once"] = True + raise RuntimeError("forced DROP DATABASE failure for test") + return original_exec_driver_sql(self, statement, *args, **kwargs) + + monkeypatch.setattr(Connection, "exec_driver_sql", _flaky_exec_driver_sql) + + disposed = {"called": False} + real_create_engine = rehearsal.create_engine + + def _tracking_create_engine(*args, **kwargs): + engine = real_create_engine(*args, **kwargs) + original_dispose = engine.dispose + + def _tracking_dispose(*dispose_args, **dispose_kwargs): + disposed["called"] = True + return original_dispose(*dispose_args, **dispose_kwargs) + + engine.dispose = _tracking_dispose + return engine + + monkeypatch.setattr(rehearsal, "create_engine", _tracking_create_engine) + monkeypatch.setenv("PARTHA_MIGRATION_REHEARSAL_CONFIRM", "disposable") + monkeypatch.setenv("PARTHA_MIGRATION_REHEARSAL_PG_URL", PG_URL) + + disposable_database_name = None + try: + with pytest.raises(rehearsal.RehearsalError, match="Cleanup failed"): + with rehearsal._postgres_target() as database_url: + disposable_database_name = make_url(database_url).database + assert disposed["called"], "engine.dispose() was skipped when DROP DATABASE cleanup failed" + finally: + # The forced failure only trips on the first DROP DATABASE call + # (the script's own attempt), so this cleanup call succeeds and + # doesn't leave the disposable database behind. + if disposable_database_name: + admin_engine = create_engine(make_url(PG_URL), isolation_level="AUTOCOMMIT") + try: + quoted = admin_engine.dialect.identifier_preparer.quote(disposable_database_name) + with admin_engine.connect() as connection: + connection.exec_driver_sql(f"DROP DATABASE IF EXISTS {quoted} WITH (FORCE)") + finally: + admin_engine.dispose() diff --git a/apps/backend/tests/test_migrations.py b/apps/backend/tests/test_migrations.py new file mode 100644 index 00000000..5b52dfd5 --- /dev/null +++ b/apps/backend/tests/test_migrations.py @@ -0,0 +1,268 @@ +import os +import uuid +from collections.abc import Iterator +from contextlib import contextmanager +from datetime import UTC, datetime +from pathlib import Path + +from alembic import command +from alembic.config import Config +from sqlalchemy import MetaData, Table, create_engine, func, inspect, select +from sqlalchemy.engine import make_url + +BACKEND_ROOT = Path(__file__).resolve().parents[1] + + +@contextmanager +def _migration_database_url(tmp_path: Path) -> Iterator[str]: + """Yield a clean migration target, preferring isolated PostgreSQL in CI.""" + postgres_url = os.environ.get("PARTHA_TEST_PG_URL") + if not postgres_url: + yield f"sqlite:///{tmp_path / 'migration-roundtrip.db'}" + return + + admin_url = make_url(postgres_url) + database_name = f"partha_migration_{uuid.uuid4().hex}" + migration_url = admin_url.set(database=database_name).render_as_string(hide_password=False) + admin_engine = create_engine(admin_url, isolation_level="AUTOCOMMIT") + quoted_database_name = admin_engine.dialect.identifier_preparer.quote(database_name) + database_created = False + try: + with admin_engine.connect() as connection: + connection.exec_driver_sql(f"CREATE DATABASE {quoted_database_name}") + database_created = True + yield migration_url + finally: + try: + if database_created: + with admin_engine.connect() as connection: + connection.exec_driver_sql(f"DROP DATABASE IF EXISTS {quoted_database_name} WITH (FORCE)") + finally: + admin_engine.dispose() + + +def test_migrations_upgrade_and_downgrade_run_clean(tmp_path, monkeypatch): + """The full revision chain applies and reverses on a fresh database. + + CI uses an isolated PostgreSQL database. Local runs without PostgreSQL use + a throwaway SQLite file. Running up -> down -> up proves both directions + and that the downgrade does not leave state that blocks a re-apply. + """ + with _migration_database_url(tmp_path) as database_url: + monkeypatch.setenv("DATABASE_URL", database_url) + monkeypatch.setenv("CORS_ORIGINS", "http://testserver") + + from app.core import config + + config.get_settings.cache_clear() + probe_engine = create_engine(database_url) + try: + assert inspect(probe_engine).get_table_names() == [] + + cfg = Config(str(BACKEND_ROOT / "alembic.ini")) + cfg.set_main_option("script_location", str(BACKEND_ROOT / "alembic")) + + command.upgrade(cfg, "head") + assert "repositories" in inspect(probe_engine).get_table_names() + snapshot_index = next( + index + for index in inspect(probe_engine).get_indexes("analysis_jobs") + if index["name"] == "uq_analysis_jobs_snapshot_id" + ) + assert bool(snapshot_index["unique"]) + assert snapshot_index["column_names"] == ["snapshot_id"] + effective_index = next( + index + for index in inspect(probe_engine).get_indexes("analysis_jobs") + if index["name"] == "uq_analysis_jobs_effective_identity" + ) + assert bool(effective_index["unique"]) + assert effective_index["column_names"] == [ + "repository_id", + "revision_value", + "config_hash", + ] + edge_indexes = { + index["name"]: index["column_names"] for index in inspect(probe_engine).get_indexes("ri_edges") + } + assert edge_indexes["ix_ri_edges_snapshot_subject_predicate"] == [ + "snapshot_id", + "subject_key", + "predicate", + ] + assert edge_indexes["ix_ri_edges_snapshot_object_predicate"] == [ + "snapshot_id", + "object_key", + "predicate", + ] + + command.downgrade(cfg, "base") + assert "repositories" not in inspect(probe_engine).get_table_names() + + command.upgrade(cfg, "head") + assert "repositories" in inspect(probe_engine).get_table_names() + finally: + probe_engine.dispose() + config.get_settings.cache_clear() + + +def test_migrations_accept_percent_encoded_database_urls_online_and_offline(tmp_path, monkeypatch): + """Percent-encoded URLs work in both Alembic execution modes.""" + database_url = f"sqlite:///{tmp_path / 'migration-percent-%40.db'}" + monkeypatch.setenv("DATABASE_URL", database_url) + monkeypatch.setenv("CORS_ORIGINS", "http://testserver") + + from app.core import config + + config.get_settings.cache_clear() + probe_engine = create_engine(database_url) + try: + cfg = Config(str(BACKEND_ROOT / "alembic.ini")) + cfg.set_main_option("script_location", str(BACKEND_ROOT / "alembic")) + + command.upgrade(cfg, "head") + assert "repositories" in inspect(probe_engine).get_table_names() + + command.downgrade(cfg, "base") + assert "repositories" not in inspect(probe_engine).get_table_names() + + monkeypatch.setenv( + "DATABASE_URL", + "postgresql+psycopg://user:p%40ss@localhost:5432/somedb", + ) + config.get_settings.cache_clear() + offline_cfg = Config(str(BACKEND_ROOT / "alembic.ini")) + offline_cfg.set_main_option("script_location", str(BACKEND_ROOT / "alembic")) + + # 0005 includes a data backfill that requires a live connection, so + # offline SQL generation intentionally stops at the latest + # schema-only revision. + command.upgrade(offline_cfg, "0004_ai_provider_configs", sql=True) + command.downgrade(offline_cfg, "0004_ai_provider_configs:base", sql=True) + finally: + probe_engine.dispose() + config.get_settings.cache_clear() + + +def test_revision_backfill_classifies_exact_legacy_values_and_downgrade_preserves_metadata(tmp_path, monkeypatch): + database_path = tmp_path / "migration-backfill.db" + database_url = f"sqlite:///{database_path}" + monkeypatch.setenv("DATABASE_URL", database_url) + monkeypatch.setenv("CORS_ORIGINS", "http://testserver") + + from app.core import config + + config.get_settings.cache_clear() + cfg = Config(str(BACKEND_ROOT / "alembic.ini")) + cfg.set_main_option("script_location", str(BACKEND_ROOT / "alembic")) + engine = create_engine(database_url) + try: + command.upgrade(cfg, "0004_ai_provider_configs") + metadata = MetaData() + repositories = Table("repositories", metadata, autoload_with=engine) + now = datetime.now(UTC) + common = { + "owner_id": "00000000-0000-0000-0000-000000000000", + "description": None, + "source_url": None, + "local_path": "/legacy", + "size": 0, + "file_count": 0, + "status": "completed", + "data_source": "real", + "analysis_stage": None, + "analysis_progress": 100, + "uploaded_at": now, + "analysed_at": now, + "error_message": None, + "file_tree": [], + "created_at": now, + "updated_at": now, + } + rows = [ + { + **common, + "id": "10000000-0000-0000-0000-000000000001", + "name": "git", + "source": "github", + "branch": "main", + "repo_metadata": {"commitSha": "a" * 40, "intelligence": {"nodes": ["legacy"]}}, + }, + { + **common, + "id": "10000000-0000-0000-0000-000000000002", + "name": "upload", + "source": "upload", + "branch": None, + "repo_metadata": {"commitSha": "sha256:" + "b" * 64}, + }, + { + **common, + "id": "10000000-0000-0000-0000-000000000003", + "name": "invalid", + "source": "github", + "branch": "main", + "repo_metadata": {"commitSha": "NOT-A-VALID-REVISION"}, + }, + { + **common, + "id": "10000000-0000-0000-0000-000000000004", + "name": "missing", + "source": "github", + "branch": None, + "repo_metadata": {"intelligence": {"relationships": ["legacy"]}}, + }, + ] + with engine.begin() as connection: + connection.execute(repositories.insert(), rows) + + command.upgrade(cfg, "head") + # data_source was removed in 0007 (#96): always-"real", never a computed value. + assert "data_source" not in {column["name"] for column in inspect(engine).get_columns("repositories")} + upgraded = MetaData() + upgraded_repositories = Table("repositories", upgraded, autoload_with=engine) + with engine.connect() as connection: + values = { + row.id: row + for row in connection.execute( + select( + upgraded_repositories.c.id, + upgraded_repositories.c.revision_kind, + upgraded_repositories.c.revision_value, + upgraded_repositories.c.revision_ref, + upgraded_repositories.c.repo_metadata, + ) + ) + } + git = values[rows[0]["id"]] + assert (git.revision_kind, git.revision_value, git.revision_ref) == ( + "git", + "a" * 40, + "refs/heads/main", + ) + upload = values[rows[1]["id"]] + assert (upload.revision_kind, upload.revision_value, upload.revision_ref) == ( + "upload", + "sha256:" + "b" * 64, + None, + ) + assert values[rows[2]["id"]].revision_kind is None + assert values[rows[3]["id"]].revision_value is None + assert git.repo_metadata["intelligence"] == {"nodes": ["legacy"]} + assert "ri_snapshots" in inspect(engine).get_table_names() + with engine.connect() as connection: + snapshots = Table("ri_snapshots", MetaData(), autoload_with=engine) + assert connection.scalar(select(func.count()).select_from(snapshots)) == 0 + + command.downgrade(cfg, "0004_ai_provider_configs") + assert "ri_snapshots" not in inspect(engine).get_table_names() + assert "data_source" in {column["name"] for column in inspect(engine).get_columns("repositories")} + assert "revision_value" not in {column["name"] for column in inspect(engine).get_columns("repositories")} + restored = Table("repositories", MetaData(), autoload_with=engine) + with engine.connect() as connection: + metadata_after = connection.scalar(select(restored.c.repo_metadata).where(restored.c.id == rows[0]["id"])) + assert metadata_after["commitSha"] == "a" * 40 + assert metadata_after["intelligence"] == {"nodes": ["legacy"]} + finally: + engine.dispose() + config.get_settings.cache_clear() diff --git a/apps/backend/tests/test_oauth_providers.py b/apps/backend/tests/test_oauth_providers.py new file mode 100644 index 00000000..1c75a5b4 --- /dev/null +++ b/apps/backend/tests/test_oauth_providers.py @@ -0,0 +1,461 @@ +"""Unit tests for the Google/GitHub OAuth provider clients (#288). + +Every HTTP call here goes through httpx.MockTransport -- a clearly-fake, +in-process handler, never a real network call to Google or GitHub. The +Google id_token is a real RS256-signed JWT built from a throwaway keypair +generated for the test, so signature verification, issuer/audience/nonce +checks, and JWKS key lookup are exercised for real rather than stubbed out. +""" + +import asyncio +import json +import time + +import httpx +import jwt +import pytest +from cryptography.hazmat.primitives.asymmetric import rsa +from jwt.algorithms import RSAAlgorithm + +from app.auth.oauth_providers import ( + GITHUB_EMAILS_URL, + GITHUB_TOKEN_URL, + GITHUB_USER_URL, + GOOGLE_JWKS_URL, + GOOGLE_TOKEN_URL, + GitHubOAuthClient, + GoogleOAuthClient, + OAuthProviderError, + generate_nonce, + generate_pkce_pair, + generate_state, +) +from app.core.config import Settings + +FAKE_GOOGLE_CLIENT_ID = "fake-google-client-id.apps.googleusercontent.com" +FAKE_GOOGLE_CLIENT_SECRET = "fake-google-client-secret" # noqa: S105 -- test double, not a real secret +FAKE_GITHUB_CLIENT_ID = "fake-github-client-id" +FAKE_GITHUB_CLIENT_SECRET = "fake-github-client-secret" # noqa: S105 -- test double, not a real secret + + +def _settings(**overrides: str) -> Settings: + values = { + "google_oauth_client_id": FAKE_GOOGLE_CLIENT_ID, + "google_oauth_client_secret": FAKE_GOOGLE_CLIENT_SECRET, + "github_oauth_client_id": FAKE_GITHUB_CLIENT_ID, + "github_oauth_client_secret": FAKE_GITHUB_CLIENT_SECRET, + } + values.update(overrides) + return Settings(**values) + + +@pytest.fixture(scope="module") +def rsa_keypair(): + private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + return private_key, private_key.public_key() + + +def _jwk_for(public_key, kid: str) -> dict: + jwk = json.loads(RSAAlgorithm.to_jwk(public_key)) + jwk.update(kid=kid, use="sig", alg="RS256") + return jwk + + +def _make_id_token( + private_key, + *, + kid: str, + audience: str, + nonce: str, + subject: str = "108234567890123456789", + email: str | None = "developer@example.com", + email_verified: bool = True, + issuer: str = "https://accounts.google.com", + expired: bool = False, +) -> str: + now = int(time.time()) + claims = { + "iss": issuer, + "aud": audience, + "sub": subject, + "nonce": nonce, + "iat": now, + "exp": now + (-60 if expired else 3600), + "name": "Test Developer", + } + if email is not None: + claims["email"] = email + claims["email_verified"] = email_verified + return jwt.encode(claims, private_key, algorithm="RS256", headers={"kid": kid}) + + +def _google_transport( + *, id_token: str | None, jwks: list[dict], token_status: int = 200, jwks_status: int = 200 +) -> httpx.MockTransport: + def handler(request: httpx.Request) -> httpx.Response: + if str(request.url) == GOOGLE_TOKEN_URL: + body = {"id_token": id_token} if id_token else {"error": "invalid_grant"} + return httpx.Response(token_status, json=body) + if str(request.url) == GOOGLE_JWKS_URL: + return httpx.Response(jwks_status, json={"keys": jwks}) + raise AssertionError(f"Unexpected request to {request.url}") + + return httpx.MockTransport(handler) + + +def _run(coro): + return asyncio.run(coro) + + +class TestPkceAndStateHelpers: + def test_pkce_pair_matches_s256_challenge(self): + import base64 + import hashlib + + verifier, challenge = generate_pkce_pair() + expected = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode("ascii")).digest()).rstrip(b"=").decode() + assert challenge == expected + assert len(verifier) <= 128 + + def test_state_and_nonce_are_high_entropy_and_unique(self): + values = {generate_state() for _ in range(20)} | {generate_nonce() for _ in range(20)} + assert len(values) == 40 + assert all(len(v) >= 32 for v in values) + + +class TestGoogleOAuthClient: + def test_is_configured_requires_both_id_and_secret(self): + assert GoogleOAuthClient(_settings()).is_configured() is True + assert GoogleOAuthClient(_settings(google_oauth_client_secret="")).is_configured() is False + assert GoogleOAuthClient(_settings(google_oauth_client_id="")).is_configured() is False + + def test_authorize_url_carries_pkce_state_and_nonce(self): + client = GoogleOAuthClient(_settings()) + url = client.authorize_url( + redirect_uri="https://api.example.test/auth/oauth/google/callback", + state="the-state", + code_challenge="the-challenge", + nonce="the-nonce", + ) + assert "client_id=" + FAKE_GOOGLE_CLIENT_ID in url + assert "state=the-state" in url + assert "code_challenge=the-challenge" in url + assert "code_challenge_method=S256" in url + assert "nonce=the-nonce" in url + assert "response_type=code" in url + + def test_resolve_identity_verifies_signature_and_returns_identity(self, rsa_keypair): + private_key, public_key = rsa_keypair + kid = "test-key-1" + nonce = "expected-nonce-value" + id_token = _make_id_token(private_key, kid=kid, audience=FAKE_GOOGLE_CLIENT_ID, nonce=nonce) + transport = _google_transport(id_token=id_token, jwks=[_jwk_for(public_key, kid)]) + client = GoogleOAuthClient(_settings(), http_client=httpx.AsyncClient(transport=transport)) + + identity = _run( + client.resolve_identity( + code="fake-code", + redirect_uri="https://api.example.test/auth/oauth/google/callback", + code_verifier="verifier", + nonce=nonce, + ) + ) + assert identity.subject == "108234567890123456789" + assert identity.email == "developer@example.com" + assert identity.email_verified is True + assert identity.display_name == "Test Developer" + + def test_resolve_identity_rejects_nonce_mismatch(self, rsa_keypair): + private_key, public_key = rsa_keypair + kid = "test-key-2" + id_token = _make_id_token(private_key, kid=kid, audience=FAKE_GOOGLE_CLIENT_ID, nonce="actual-nonce") + transport = _google_transport(id_token=id_token, jwks=[_jwk_for(public_key, kid)]) + client = GoogleOAuthClient(_settings(), http_client=httpx.AsyncClient(transport=transport)) + + with pytest.raises(OAuthProviderError, match="nonce"): + _run( + client.resolve_identity( + code="fake-code", + redirect_uri="https://api.example.test/auth/oauth/google/callback", + code_verifier="verifier", + nonce="different-nonce", + ) + ) + + def test_resolve_identity_rejects_wrong_audience(self, rsa_keypair): + private_key, public_key = rsa_keypair + kid = "test-key-3" + nonce = "n" + id_token = _make_id_token(private_key, kid=kid, audience="someone-elses-client-id", nonce=nonce) + transport = _google_transport(id_token=id_token, jwks=[_jwk_for(public_key, kid)]) + client = GoogleOAuthClient(_settings(), http_client=httpx.AsyncClient(transport=transport)) + + with pytest.raises(OAuthProviderError): + _run( + client.resolve_identity( + code="fake-code", + redirect_uri="https://api.example.test/auth/oauth/google/callback", + code_verifier="verifier", + nonce=nonce, + ) + ) + + def test_resolve_identity_rejects_wrong_issuer(self, rsa_keypair): + private_key, public_key = rsa_keypair + kid = "test-key-4" + nonce = "n" + id_token = _make_id_token( + private_key, kid=kid, audience=FAKE_GOOGLE_CLIENT_ID, nonce=nonce, issuer="https://not-google.example" + ) + transport = _google_transport(id_token=id_token, jwks=[_jwk_for(public_key, kid)]) + client = GoogleOAuthClient(_settings(), http_client=httpx.AsyncClient(transport=transport)) + + with pytest.raises(OAuthProviderError): + _run( + client.resolve_identity( + code="fake-code", + redirect_uri="https://api.example.test/auth/oauth/google/callback", + code_verifier="verifier", + nonce=nonce, + ) + ) + + def test_resolve_identity_rejects_expired_token(self, rsa_keypair): + private_key, public_key = rsa_keypair + kid = "test-key-5" + nonce = "n" + id_token = _make_id_token(private_key, kid=kid, audience=FAKE_GOOGLE_CLIENT_ID, nonce=nonce, expired=True) + transport = _google_transport(id_token=id_token, jwks=[_jwk_for(public_key, kid)]) + client = GoogleOAuthClient(_settings(), http_client=httpx.AsyncClient(transport=transport)) + + with pytest.raises(OAuthProviderError): + _run( + client.resolve_identity( + code="fake-code", + redirect_uri="https://api.example.test/auth/oauth/google/callback", + code_verifier="verifier", + nonce=nonce, + ) + ) + + def test_resolve_identity_rejects_unknown_kid(self, rsa_keypair): + _private_key, public_key = rsa_keypair + other_private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + nonce = "n" + # Signed by a key whose kid never appears in the JWKS response. + id_token = _make_id_token(other_private_key, kid="unknown-kid", audience=FAKE_GOOGLE_CLIENT_ID, nonce=nonce) + transport = _google_transport(id_token=id_token, jwks=[_jwk_for(public_key, "test-key-6")]) + client = GoogleOAuthClient(_settings(), http_client=httpx.AsyncClient(transport=transport)) + + with pytest.raises(OAuthProviderError, match="JWKS"): + _run( + client.resolve_identity( + code="fake-code", + redirect_uri="https://api.example.test/auth/oauth/google/callback", + code_verifier="verifier", + nonce=nonce, + ) + ) + + def test_resolve_identity_rejects_forged_signature(self, rsa_keypair): + """A token signed by a DIFFERENT key than the one published under its + own kid must fail -- otherwise an attacker could publish any claims + under someone else's kid label.""" + _private_key, public_key = rsa_keypair + forger_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + kid = "shared-kid-label" + nonce = "n" + forged_token = _make_id_token(forger_key, kid=kid, audience=FAKE_GOOGLE_CLIENT_ID, nonce=nonce) + # JWKS publishes the REAL public key under the same kid the forger used. + transport = _google_transport(id_token=forged_token, jwks=[_jwk_for(public_key, kid)]) + client = GoogleOAuthClient(_settings(), http_client=httpx.AsyncClient(transport=transport)) + + with pytest.raises(OAuthProviderError): + _run( + client.resolve_identity( + code="fake-code", + redirect_uri="https://api.example.test/auth/oauth/google/callback", + code_verifier="verifier", + nonce=nonce, + ) + ) + + def test_resolve_identity_handles_token_exchange_denial(self): + transport = _google_transport(id_token=None, jwks=[], token_status=400) + client = GoogleOAuthClient(_settings(), http_client=httpx.AsyncClient(transport=transport)) + + with pytest.raises(OAuthProviderError): + _run( + client.resolve_identity( + code="bad-code", + redirect_uri="https://api.example.test/auth/oauth/google/callback", + code_verifier="verifier", + nonce="n", + ) + ) + + def test_resolve_identity_handles_jwks_fetch_failure(self, rsa_keypair): + private_key, _public_key = rsa_keypair + id_token = _make_id_token(private_key, kid="k", audience=FAKE_GOOGLE_CLIENT_ID, nonce="n") + transport = _google_transport(id_token=id_token, jwks=[], jwks_status=503) + client = GoogleOAuthClient(_settings(), http_client=httpx.AsyncClient(transport=transport)) + + with pytest.raises(OAuthProviderError): + _run( + client.resolve_identity( + code="fake-code", + redirect_uri="https://api.example.test/auth/oauth/google/callback", + code_verifier="verifier", + nonce="n", + ) + ) + + def test_resolve_identity_treats_unverified_email_as_such(self, rsa_keypair): + private_key, public_key = rsa_keypair + kid = "test-key-7" + nonce = "n" + id_token = _make_id_token( + private_key, kid=kid, audience=FAKE_GOOGLE_CLIENT_ID, nonce=nonce, email_verified=False + ) + transport = _google_transport(id_token=id_token, jwks=[_jwk_for(public_key, kid)]) + client = GoogleOAuthClient(_settings(), http_client=httpx.AsyncClient(transport=transport)) + + identity = _run( + client.resolve_identity( + code="fake-code", + redirect_uri="https://api.example.test/auth/oauth/google/callback", + code_verifier="verifier", + nonce=nonce, + ) + ) + assert identity.email_verified is False + + +def _github_transport( + *, + token_body: dict | None = None, + token_status: int = 200, + user_body: dict | None = None, + user_status: int = 200, + emails_body: list[dict] | None = None, + emails_status: int = 200, +) -> httpx.MockTransport: + def handler(request: httpx.Request) -> httpx.Response: + if str(request.url) == GITHUB_TOKEN_URL: + return httpx.Response( + token_status, json=token_body if token_body is not None else {"access_token": "fake-access-token"} + ) + if str(request.url) == GITHUB_USER_URL: + return httpx.Response( + user_status, json=user_body if user_body is not None else {"id": 4242, "login": "octocat"} + ) + if str(request.url) == GITHUB_EMAILS_URL: + return httpx.Response(emails_status, json=emails_body if emails_body is not None else []) + raise AssertionError(f"Unexpected request to {request.url}") + + return httpx.MockTransport(handler) + + +class TestGitHubOAuthClient: + def test_is_configured_requires_both_id_and_secret(self): + assert GitHubOAuthClient(_settings()).is_configured() is True + assert GitHubOAuthClient(_settings(github_oauth_client_secret="")).is_configured() is False + + def test_authorize_url_ignores_pkce_extras(self): + client = GitHubOAuthClient(_settings()) + url = client.authorize_url( + redirect_uri="https://api.example.test/auth/oauth/github/callback", + state="the-state", + code_challenge="unused", + nonce="unused", + ) + assert "client_id=" + FAKE_GITHUB_CLIENT_ID in url + assert "state=the-state" in url + assert "code_challenge" not in url + + def test_resolve_identity_uses_public_email_when_present(self): + transport = _github_transport( + user_body={"id": 555, "login": "octocat", "name": "The Octocat", "email": "octo@example.com"} + ) + client = GitHubOAuthClient(_settings(), http_client=httpx.AsyncClient(transport=transport)) + + identity = _run( + client.resolve_identity( + code="fake-code", + redirect_uri="https://api.example.test/auth/oauth/github/callback", + code_verifier=None, + nonce=None, + ) + ) + assert identity.subject == "555" + assert identity.email == "octo@example.com" + assert identity.email_verified is True + assert identity.display_name == "The Octocat" + + def test_resolve_identity_falls_back_to_verified_primary_email(self): + transport = _github_transport( + user_body={"id": 555, "login": "octocat", "email": None}, + emails_body=[ + {"email": "secondary@example.com", "primary": False, "verified": True}, + {"email": "primary@example.com", "primary": True, "verified": True}, + ], + ) + client = GitHubOAuthClient(_settings(), http_client=httpx.AsyncClient(transport=transport)) + + identity = _run( + client.resolve_identity( + code="fake-code", + redirect_uri="https://api.example.test/auth/oauth/github/callback", + code_verifier=None, + nonce=None, + ) + ) + assert identity.email == "primary@example.com" + assert identity.email_verified is True + assert identity.display_name == "octocat" + + def test_resolve_identity_leaves_email_unset_when_none_verified(self): + transport = _github_transport( + user_body={"id": 555, "login": "octocat", "email": None}, + emails_body=[{"email": "unverified@example.com", "primary": True, "verified": False}], + ) + client = GitHubOAuthClient(_settings(), http_client=httpx.AsyncClient(transport=transport)) + + identity = _run( + client.resolve_identity( + code="fake-code", + redirect_uri="https://api.example.test/auth/oauth/github/callback", + code_verifier=None, + nonce=None, + ) + ) + assert identity.email is None + assert identity.email_verified is False + + def test_resolve_identity_rejects_denied_token_exchange(self): + transport = _github_transport(token_body={"error": "bad_verification_code"}) + client = GitHubOAuthClient(_settings(), http_client=httpx.AsyncClient(transport=transport)) + + with pytest.raises(OAuthProviderError): + _run( + client.resolve_identity( + code="bad-code", + redirect_uri="https://api.example.test/auth/oauth/github/callback", + code_verifier=None, + nonce=None, + ) + ) + + def test_resolve_identity_rejects_failed_user_lookup(self): + transport = _github_transport(user_status=401, user_body={"message": "Bad credentials"}) + client = GitHubOAuthClient(_settings(), http_client=httpx.AsyncClient(transport=transport)) + + with pytest.raises(OAuthProviderError): + _run( + client.resolve_identity( + code="fake-code", + redirect_uri="https://api.example.test/auth/oauth/github/callback", + code_verifier=None, + nonce=None, + ) + ) diff --git a/apps/backend/tests/test_oauth_routes.py b/apps/backend/tests/test_oauth_routes.py new file mode 100644 index 00000000..d8ce0d5c --- /dev/null +++ b/apps/backend/tests/test_oauth_routes.py @@ -0,0 +1,374 @@ +"""HTTP-level integration tests for /auth/oauth/* (#288). + +Provider clients are overridden via FastAPI's dependency_overrides with +in-process fakes (business logic is covered by test_oauth_service.py, +provider network behavior by test_oauth_providers.py) -- these tests exist +to prove the routing, cookie, redirect, and auth-dependency wiring itself: +that a real request through the real app reaches OAuthService and comes +back with the right status code, redirect target, and cookie. +""" + +from urllib.parse import parse_qs, urlparse + +from app.api.deps import get_github_oauth_client, get_google_oauth_client +from app.api.routes.auth import REFRESH_COOKIE +from app.auth.oauth_providers import OAuthIdentityInfo, OAuthProviderError +from tests.conftest import DEFAULT_TEST_PASSWORD, approve_email, register_user + + +class FakeProviderClient: + def __init__(self, *, identity=None, error=None, configured=True): + self.identity = identity + self.error = error + self.configured = configured + + def is_configured(self): + return self.configured + + def authorize_url(self, *, redirect_uri, state, code_challenge, nonce): + return f"https://fake-provider.example/authorize?state={state}" + + async def resolve_identity(self, *, code, redirect_uri, code_verifier, nonce): + if self.error is not None: + raise self.error + return self.identity + + +def _override(client, *, google=None, github=None): + if google is not None: + client.app.dependency_overrides[get_google_oauth_client] = lambda: google + if github is not None: + client.app.dependency_overrides[get_github_oauth_client] = lambda: github + + +def _clear_overrides(client): + client.app.dependency_overrides.pop(get_google_oauth_client, None) + client.app.dependency_overrides.pop(get_github_oauth_client, None) + + +def _state_from(authorize_url: str) -> str: + return parse_qs(urlparse(authorize_url).query)["state"][0] + + +class TestProvidersEndpoint: + def test_reports_none_configured_by_default(self, client): + response = client.get("/auth/oauth/providers") + assert response.status_code == 200 + assert response.json() == {"providers": []} + + def test_reports_configured_providers(self, client): + _override(client, google=FakeProviderClient(configured=True), github=FakeProviderClient(configured=False)) + try: + response = client.get("/auth/oauth/providers") + assert response.json() == {"providers": ["google"]} + finally: + _clear_overrides(client) + + +class TestStartEndpoint: + def test_unconfigured_provider_start_is_rejected(self, client): + response = client.get("/auth/oauth/google/start") + assert response.status_code == 422 + + def test_unknown_provider_is_rejected(self, client): + response = client.get("/auth/oauth/nope/start") + assert response.status_code == 422 + + def test_configured_provider_returns_an_authorize_url(self, client): + _override(client, google=FakeProviderClient()) + try: + response = client.get("/auth/oauth/google/start", headers={"Origin": "http://testserver"}) + assert response.status_code == 200 + assert response.json()["authorizeUrl"].startswith("https://fake-provider.example/authorize?state=") + finally: + _clear_overrides(client) + + def test_link_start_requires_authentication(self, client): + _override(client, google=FakeProviderClient()) + try: + response = client.post("/auth/oauth/google/link") + assert response.status_code == 401 + finally: + _clear_overrides(client) + + def test_authenticated_link_start_succeeds(self, auth_client): + _override(auth_client, google=FakeProviderClient()) + try: + response = auth_client.post("/auth/oauth/google/link", headers={"Origin": "http://testserver"}) + assert response.status_code == 200 + assert "authorizeUrl" in response.json() + finally: + _clear_overrides(auth_client) + + +class TestCallbackEndpoint: + def test_missing_state_is_rejected(self, client): + response = client.get("/auth/oauth/google/callback", params={"code": "c"}, follow_redirects=False) + assert response.status_code == 422 + + def test_unknown_state_is_rejected(self, client): + response = client.get( + "/auth/oauth/google/callback", params={"code": "c", "state": "never-issued"}, follow_redirects=False + ) + assert response.status_code == 422 + + def test_provider_denial_redirects_with_error(self, client): + fake = FakeProviderClient() + _override(client, google=fake) + try: + start = client.get("/auth/oauth/google/start", headers={"Origin": "http://testserver"}) + state = _state_from(start.json()["authorizeUrl"]) + response = client.get( + "/auth/oauth/google/callback", params={"state": state, "error": "access_denied"}, follow_redirects=False + ) + assert response.status_code == 302 + location = response.headers["location"] + assert location.startswith("http://testserver/oauth/complete?status=error") + assert "reason=access_denied" in location + finally: + _clear_overrides(client) + + def test_successful_login_for_an_already_linked_identity_sets_refresh_cookie(self, auth_client): + """Login-over-OAuth succeeds for an identity that's already linked to + a real account -- this links first (as the authenticated user would + from Settings), then proves an unauthenticated /start + /callback + with that same identity is a real, cookie-issuing login.""" + from app.auth.oauth_providers import OAuthIdentityInfo as _Identity + + identity = _Identity( + subject="linked-sub", email="linked-login@example.com", email_verified=True, display_name=None + ) + _override(auth_client, google=FakeProviderClient(identity=identity)) + try: + start = auth_client.post("/auth/oauth/google/link", headers={"Origin": "http://testserver"}) + state = _state_from(start.json()["authorizeUrl"]) + auth_client.get( + "/auth/oauth/google/callback", params={"state": state, "code": "fake-code"}, follow_redirects=False + ) + finally: + _clear_overrides(auth_client) + + anonymous = type(auth_client)(auth_client.app) + _override(anonymous, google=FakeProviderClient(identity=identity)) + try: + start = anonymous.get("/auth/oauth/google/start", headers={"Origin": "http://testserver"}) + state = _state_from(start.json()["authorizeUrl"]) + response = anonymous.get( + "/auth/oauth/google/callback", params={"state": state, "code": "fake-code"}, follow_redirects=False + ) + assert response.status_code == 302 + assert response.headers["location"] == "http://testserver/oauth/complete?status=success" + assert REFRESH_COOKIE in response.cookies + + # The refresh cookie actually works: bootstrap()'s primitive. + refresh_response = anonymous.post("/auth/refresh") + assert refresh_response.status_code == 200 + assert refresh_response.json()["user"]["id"] == auth_client.default_user["id"] + finally: + _clear_overrides(anonymous) + + def test_brand_new_unapproved_identity_redirects_to_signup_required(self, client): + # A baseline user is registered first so #388's first-user bootstrap + # (AuthService._require_approval, applies to this OAuth path exactly + # the same as password registration) has already closed -- otherwise + # this callback would BE the first-ever registration on a fresh + # database and succeed via bootstrap instead of exercising the + # rejection this test is about. + register_user(client, "existing-owner@example.com") + + identity = OAuthIdentityInfo( + subject="never-seen-sub", email="brandnew@example.com", email_verified=True, display_name="Brand New" + ) + fake = FakeProviderClient(identity=identity) + _override(client, google=fake) + try: + start = client.get("/auth/oauth/google/start", headers={"Origin": "http://testserver"}) + state = _state_from(start.json()["authorizeUrl"]) + response = client.get( + "/auth/oauth/google/callback", params={"state": state, "code": "fake-code"}, follow_redirects=False + ) + assert response.status_code == 302 + location = response.headers["location"] + assert "status=error" in location + assert "reason=email_not_approved" in location + assert REFRESH_COOKIE not in response.cookies + finally: + _clear_overrides(client) + + def test_brand_new_approved_identity_signs_up_and_signs_in_via_oauth(self, client): + """The allowlist (#374) is checked by identity, not by door: an + email approved for password registration may also complete + first-time sign-in via OAuth with no separate code needed.""" + approve_email("oauth-newcomer@example.com") + identity = OAuthIdentityInfo( + subject="approved-newcomer-sub", + email="oauth-newcomer@example.com", + email_verified=True, + display_name="Newcomer", + ) + fake = FakeProviderClient(identity=identity) + _override(client, google=fake) + try: + start = client.get("/auth/oauth/google/start", headers={"Origin": "http://testserver"}) + state = _state_from(start.json()["authorizeUrl"]) + response = client.get( + "/auth/oauth/google/callback", params={"state": state, "code": "fake-code"}, follow_redirects=False + ) + assert response.status_code == 302 + assert response.headers["location"] == "http://testserver/oauth/complete?status=success" + assert REFRESH_COOKIE in response.cookies + + refresh_response = client.post("/auth/refresh") + assert refresh_response.status_code == 200 + assert refresh_response.json()["user"]["email"] == "oauth-newcomer@example.com" + finally: + _clear_overrides(client) + + def test_exchange_failure_redirects_with_generic_error(self, client): + fake = FakeProviderClient(error=OAuthProviderError("network exploded")) + _override(client, google=fake) + try: + start = client.get("/auth/oauth/google/start", headers={"Origin": "http://testserver"}) + state = _state_from(start.json()["authorizeUrl"]) + response = client.get( + "/auth/oauth/google/callback", params={"state": state, "code": "fake-code"}, follow_redirects=False + ) + assert response.status_code == 302 + assert "status=error" in response.headers["location"] + assert "reason=exchange_failed" in response.headers["location"] + finally: + _clear_overrides(client) + + def test_email_collision_redirects_to_pending_link(self, client): + register_user(client, "collides@example.com") + identity = OAuthIdentityInfo( + subject="collide-sub", email="collides@example.com", email_verified=True, display_name=None + ) + fake = FakeProviderClient(identity=identity) + _override(client, google=fake) + try: + start = client.get("/auth/oauth/google/start", headers={"Origin": "http://testserver"}) + state = _state_from(start.json()["authorizeUrl"]) + response = client.get( + "/auth/oauth/google/callback", params={"state": state, "code": "fake-code"}, follow_redirects=False + ) + assert response.status_code == 302 + location = response.headers["location"] + assert "status=pending-link" in location + assert "pendingLinkId=" in location + assert REFRESH_COOKIE not in response.cookies + finally: + _clear_overrides(client) + + def test_link_callback_attaches_identity_without_reauthenticating(self, auth_client): + identity = OAuthIdentityInfo( + subject="link-sub", email="whatever@example.com", email_verified=True, display_name=None + ) + fake = FakeProviderClient(identity=identity) + _override(auth_client, github=fake) + try: + start = auth_client.post("/auth/oauth/github/link", headers={"Origin": "http://testserver"}) + state = _state_from(start.json()["authorizeUrl"]) + response = auth_client.get( + "/auth/oauth/github/callback", params={"state": state, "code": "fake-code"}, follow_redirects=False + ) + assert response.status_code == 302 + assert "status=linked" in response.headers["location"] + assert REFRESH_COOKIE not in response.cookies + + linked = auth_client.get("/auth/oauth/linked") + assert linked.status_code == 200 + assert [entry["provider"] for entry in linked.json()["identities"]] == ["github"] + finally: + _clear_overrides(auth_client) + + +class TestLinkConfirmEndpoint: + def test_confirms_with_correct_password_and_signs_in(self, client): + register_user(client, "confirmable@example.com") + identity = OAuthIdentityInfo( + subject="confirm-sub", email="confirmable@example.com", email_verified=True, display_name=None + ) + fake = FakeProviderClient(identity=identity) + _override(client, google=fake) + try: + start = client.get("/auth/oauth/google/start", headers={"Origin": "http://testserver"}) + state = _state_from(start.json()["authorizeUrl"]) + callback = client.get( + "/auth/oauth/google/callback", params={"state": state, "code": "fake-code"}, follow_redirects=False + ) + pending_link_id = parse_qs(urlparse(callback.headers["location"]).query)["pendingLinkId"][0] + + response = client.post( + "/auth/oauth/link/confirm", json={"pendingLinkId": pending_link_id, "password": DEFAULT_TEST_PASSWORD} + ) + assert response.status_code == 200 + assert response.json()["user"]["email"] == "confirmable@example.com" + assert REFRESH_COOKIE in response.cookies + finally: + _clear_overrides(client) + + def test_wrong_password_is_rejected(self, client): + register_user(client, "confirmable2@example.com") + identity = OAuthIdentityInfo( + subject="confirm-sub-2", email="confirmable2@example.com", email_verified=True, display_name=None + ) + fake = FakeProviderClient(identity=identity) + _override(client, google=fake) + try: + start = client.get("/auth/oauth/google/start", headers={"Origin": "http://testserver"}) + state = _state_from(start.json()["authorizeUrl"]) + callback = client.get( + "/auth/oauth/google/callback", params={"state": state, "code": "fake-code"}, follow_redirects=False + ) + pending_link_id = parse_qs(urlparse(callback.headers["location"]).query)["pendingLinkId"][0] + + response = client.post( + "/auth/oauth/link/confirm", + json={"pendingLinkId": pending_link_id, "password": "wrong-password-entirely"}, + ) + assert response.status_code == 401 + finally: + _clear_overrides(client) + + +class TestUnlinkEndpoint: + def test_unlink_requires_authentication(self, client): + response = client.delete("/auth/oauth/google") + assert response.status_code == 401 + + def test_unlink_removes_a_linked_identity(self, auth_client): + identity = OAuthIdentityInfo( + subject="unlink-sub", email="whatever2@example.com", email_verified=True, display_name=None + ) + fake = FakeProviderClient(identity=identity) + _override(auth_client, github=fake) + try: + start = auth_client.post("/auth/oauth/github/link", headers={"Origin": "http://testserver"}) + state = _state_from(start.json()["authorizeUrl"]) + auth_client.get( + "/auth/oauth/github/callback", params={"state": state, "code": "fake-code"}, follow_redirects=False + ) + + response = auth_client.delete("/auth/oauth/github") + assert response.status_code == 204 + + linked = auth_client.get("/auth/oauth/linked") + assert linked.json()["identities"] == [] + finally: + _clear_overrides(auth_client) + + def test_unlinking_something_never_linked_is_not_found(self, auth_client): + response = auth_client.delete("/auth/oauth/google") + assert response.status_code == 404 + + +class TestLinkedEndpoint: + def test_requires_authentication(self, client): + response = client.get("/auth/oauth/linked") + assert response.status_code == 401 + + def test_starts_empty_for_a_fresh_account(self, auth_client): + response = auth_client.get("/auth/oauth/linked") + assert response.status_code == 200 + assert response.json() == {"identities": []} diff --git a/apps/backend/tests/test_oauth_service.py b/apps/backend/tests/test_oauth_service.py new file mode 100644 index 00000000..2fb302ee --- /dev/null +++ b/apps/backend/tests/test_oauth_service.py @@ -0,0 +1,721 @@ +"""Unit tests for OAuthService (#288): login/link resolution, the +never-auto-link-by-email rule, and unlink's last-credential guard. + +Provider network behavior (PKCE, id_token verification, GitHub token/user +lookups) is covered in test_oauth_providers.py; here the provider clients +are simple in-process fakes implementing OAuthProviderClient, so these tests +exercise OAuthService's own business logic in isolation. +""" + +from datetime import UTC, datetime, timedelta + +import pytest + +from app.auth.oauth_providers import OAuthIdentityInfo, OAuthProviderError +from app.auth.security import hash_password +from app.auth.service import AuthService +from app.core.config import get_settings +from app.core.database import SessionLocal +from app.core.exceptions import NotFoundError, UnauthorizedError, ValidationServiceError +from app.models.approved_email import ApprovedEmail +from app.models.oauth_flow_state import OAuthFlowState +from app.models.oauth_identity import OAuthIdentity +from app.models.oauth_pending_link import OAuthPendingLink +from app.models.user import SEED_USER_ID, SEED_USER_EMAIL, User +from app.services.oauth_service import OAuthService + + +class FakeProviderClient: + """A scripted OAuthProviderClient double -- returns a fixed identity or + raises a fixed error, never touches the network.""" + + def __init__( + self, *, identity: OAuthIdentityInfo | None = None, error: Exception | None = None, configured: bool = True + ): + self.identity = identity + self.error = error + self.configured = configured + + def is_configured(self) -> bool: + return self.configured + + def authorize_url(self, *, redirect_uri: str, state: str, code_challenge: str, nonce: str) -> str: + return f"https://fake-provider.example/authorize?state={state}&redirect_uri={redirect_uri}" + + async def resolve_identity(self, *, code, redirect_uri, code_verifier, nonce) -> OAuthIdentityInfo: + if self.error is not None: + raise self.error + assert self.identity is not None + return self.identity + + +def _make_service(db, providers: dict) -> OAuthService: + settings = get_settings() + auth_service = AuthService(db, settings) + return OAuthService(db, settings, auth_service, providers) + + +def _create_user(db, email: str, password: str | None = "correct-horse-battery-staple") -> User: + import uuid + + user = User(id=str(uuid.uuid4()), email=email, password_hash=hash_password(password) if password else None) + db.add(user) + db.commit() + db.refresh(user) + return user + + +def approve_email(db, email: str, note: str | None = None) -> ApprovedEmail: + import uuid + + approval = ApprovedEmail(id=str(uuid.uuid4()), email=email, note=note) + db.add(approval) + db.commit() + db.refresh(approval) + return approval + + +@pytest.fixture() +def db(client): + """Piggybacks on the `client` fixture purely for its DB/env setup + (a fresh migrated-or-created-all sqlite database per test) -- these + tests drive OAuthService directly, never over HTTP.""" + with SessionLocal() as session: + yield session + + +class TestConfiguredProviders: + def test_reports_only_configured_providers(self, db): + service = _make_service( + db, {"google": FakeProviderClient(configured=True), "github": FakeProviderClient(configured=False)} + ) + assert service.configured_providers() == ["google"] + + def test_redirect_uri_is_built_from_settings(self, db): + service = _make_service(db, {}) + settings = get_settings() + assert service.redirect_uri("google") == f"{settings.oauth_public_base_url}/auth/oauth/google/callback" + + +class TestStart: + def test_creates_a_flow_row_and_returns_the_provider_authorize_url(self, db): + service = _make_service(db, {"google": FakeProviderClient()}) + url = service.start("google", intent="login", frontend_redirect_base="http://localhost:5173") + assert url.startswith("https://fake-provider.example/authorize?state=") + + flows = db.query(OAuthFlowState).all() + assert len(flows) == 1 + assert flows[0].provider == "google" + assert flows[0].intent == "login" + assert flows[0].link_user_id is None + assert flows[0].frontend_redirect_base == "http://localhost:5173" + + def test_link_intent_stores_the_target_user(self, db): + user = _create_user(db, "owner@example.com") + service = _make_service(db, {"github": FakeProviderClient()}) + service.start("github", intent="link", frontend_redirect_base="http://localhost:5173", link_user_id=user.id) + flow = db.query(OAuthFlowState).one() + assert flow.intent == "link" + assert flow.link_user_id == user.id + + def test_unconfigured_provider_is_rejected(self, db): + service = _make_service(db, {"google": FakeProviderClient(configured=False)}) + with pytest.raises(ValidationServiceError): + service.start("google", intent="login", frontend_redirect_base="http://localhost:5173") + + +class TestCompleteCallbackLogin: + def test_unknown_state_raises(self, db): + import asyncio + + service = _make_service(db, {"google": FakeProviderClient()}) + with pytest.raises(ValidationServiceError): + asyncio.run(service.complete_callback("google", state="never-issued", code="c", provider_error=None)) + + def test_expired_flow_raises(self, db): + import asyncio + import uuid + + service = _make_service(db, {"google": FakeProviderClient()}) + now = datetime.now(UTC) + db.add( + OAuthFlowState( + id=str(uuid.uuid4()), + state_hash="deadbeef" * 8, + provider="google", + code_verifier="v", + nonce="n", + intent="login", + link_user_id=None, + frontend_redirect_base="http://localhost:5173", + created_at=now - timedelta(seconds=1000), + expires_at=now - timedelta(seconds=1), + ) + ) + db.commit() + with pytest.raises(ValidationServiceError): + asyncio.run( + service.complete_callback( + "google", state="irrelevant-since-hash-lookup-fails", code="c", provider_error=None + ) + ) + + def test_provider_denial_returns_error_result_and_consumes_the_flow(self, db): + import asyncio + + service = _make_service(db, {"google": FakeProviderClient()}) + url = service.start("google", intent="login", frontend_redirect_base="http://localhost:5173") + state = url.split("state=")[1].split("&")[0] + + base, result = asyncio.run( + service.complete_callback("google", state=state, code=None, provider_error="access_denied") + ) + assert base == "http://localhost:5173" + assert result.kind == "error" + assert result.error_code == "access_denied" + assert db.query(OAuthFlowState).count() == 0 + + # Single-use: replaying the same state now hits the unknown-state path. + with pytest.raises(ValidationServiceError): + asyncio.run(service.complete_callback("google", state=state, code="c", provider_error=None)) + + def test_missing_code_without_provider_error_is_a_generic_error(self, db): + import asyncio + + service = _make_service(db, {"google": FakeProviderClient()}) + url = service.start("google", intent="login", frontend_redirect_base="http://localhost:5173") + state = url.split("state=")[1].split("&")[0] + base, result = asyncio.run(service.complete_callback("google", state=state, code=None, provider_error=None)) + assert result.kind == "error" + assert result.error_code == "missing_code" + + def test_exchange_failure_is_reported_as_error(self, db): + import asyncio + + service = _make_service(db, {"google": FakeProviderClient(error=OAuthProviderError("boom"))}) + url = service.start("google", intent="login", frontend_redirect_base="http://localhost:5173") + state = url.split("state=")[1].split("&")[0] + base, result = asyncio.run(service.complete_callback("google", state=state, code="c", provider_error=None)) + assert result.kind == "error" + assert result.error_code == "exchange_failed" + + def test_brand_new_unapproved_identity_is_rejected_never_bypassing_the_allowlist(self, db): + """OAuth login never creates an account for an email that isn't on + the allowlist (#374, superseding #288's original invite-code + comment): password registration requires an approved email + (AuthService.register), and an OAuth-created account with no + equivalent check would be a silent bypass of that gate, not a + feature. An unapproved visitor is sent back to the allowlist-gated + registration form instead. + + A baseline user is created first so #388's first-user bootstrap + (which applies to this OAuth path exactly the same as it does to + password registration, since both go through + AuthService._require_approval) has already closed before this + identity is attempted -- otherwise this OAuth sign-in would BE the + first-ever registration on a fresh database and succeed via + bootstrap instead of exercising the rejection this test is about.""" + import asyncio + + _create_user(db, "existing-owner@example.com") + + identity = OAuthIdentityInfo( + subject="sub-1", email="newperson@example.com", email_verified=True, display_name="New Person" + ) + service = _make_service(db, {"google": FakeProviderClient(identity=identity)}) + url = service.start("google", intent="login", frontend_redirect_base="http://localhost:5173") + state = url.split("state=")[1].split("&")[0] + + base, result = asyncio.run(service.complete_callback("google", state=state, code="c", provider_error=None)) + assert result.kind == "error" + assert result.error_code == "email_not_approved" + assert db.query(User).filter(User.email == "newperson@example.com").count() == 0 + assert db.query(OAuthIdentity).count() == 0 + + def test_first_ever_oauth_identity_becomes_the_owner_via_bootstrap(self, db): + """#388's first-user bootstrap applies to the OAuth path exactly the + same way it applies to password registration, since both call + AuthService._require_approval -- a verified provider identity that + happens to be the very first account on a fresh, otherwise-empty + instance is auto-approved and signed in, not rejected.""" + import asyncio + + identity = OAuthIdentityInfo( + subject="sub-1", email="first-owner@example.com", email_verified=True, display_name="First Owner" + ) + service = _make_service(db, {"google": FakeProviderClient(identity=identity)}) + url = service.start("google", intent="login", frontend_redirect_base="http://localhost:5173") + state = url.split("state=")[1].split("&")[0] + + base, result = asyncio.run(service.complete_callback("google", state=state, code="c", provider_error=None)) + assert result.kind == "session" + assert result.user is not None + assert result.user.email == "first-owner@example.com" + + approval = db.query(ApprovedEmail).filter(ApprovedEmail.email == "first-owner@example.com").one() + assert approval.added_by == "first-user-bootstrap" + + def test_brand_new_approved_identity_creates_an_account_via_oauth(self, db): + """The one exception to 'OAuth never creates an account': a verified + provider email that's already on the SAME allowlist password + registration uses may complete first-time sign-in with no separate + code needed (#374).""" + import asyncio + + approve_email(db, "approved-newcomer@example.com") + identity = OAuthIdentityInfo( + subject="sub-approved-1", + email="approved-newcomer@example.com", + email_verified=True, + display_name="Newcomer", + ) + service = _make_service(db, {"google": FakeProviderClient(identity=identity)}) + url = service.start("google", intent="login", frontend_redirect_base="http://localhost:5173") + state = url.split("state=")[1].split("&")[0] + + base, result = asyncio.run(service.complete_callback("google", state=state, code="c", provider_error=None)) + assert result.kind == "session" + assert result.user.email == "approved-newcomer@example.com" + assert result.user.password_hash is None + + stored_identity = db.query(OAuthIdentity).one() + assert stored_identity.provider_subject == "sub-approved-1" + assert stored_identity.user_id == result.user.id + + approval = db.query(ApprovedEmail).filter(ApprovedEmail.email == "approved-newcomer@example.com").one() + assert approval.used_at is not None + assert approval.used_by_user_id == result.user.id + + def test_existing_identity_reuses_the_same_account_without_duplicating_it(self, db): + import asyncio + import uuid + + user = _create_user(db, "repeat@example.com") + db.add( + OAuthIdentity( + id=str(uuid.uuid4()), + user_id=user.id, + provider="google", + provider_subject="sub-2", + email=user.email, + created_at=datetime.now(UTC), + ) + ) + db.commit() + + identity = OAuthIdentityInfo( + subject="sub-2", email="repeat@example.com", email_verified=True, display_name=None + ) + service = _make_service(db, {"google": FakeProviderClient(identity=identity)}) + + for _ in range(2): + url = service.start("google", intent="login", frontend_redirect_base="http://localhost:5173") + state = url.split("state=")[1].split("&")[0] + base, result = asyncio.run(service.complete_callback("google", state=state, code="c", provider_error=None)) + assert result.kind == "session" + assert result.user.id == user.id + + assert db.query(User).filter(User.email == "repeat@example.com").count() == 1 + assert db.query(OAuthIdentity).count() == 1 + + def test_email_match_against_existing_account_creates_a_pending_link_never_auto_links(self, db): + import asyncio + + existing = _create_user(db, "matched@example.com") + identity = OAuthIdentityInfo( + subject="sub-3", email="matched@example.com", email_verified=True, display_name="Someone" + ) + service = _make_service(db, {"google": FakeProviderClient(identity=identity)}) + url = service.start("google", intent="login", frontend_redirect_base="http://localhost:5173") + state = url.split("state=")[1].split("&")[0] + + base, result = asyncio.run(service.complete_callback("google", state=state, code="c", provider_error=None)) + assert result.kind == "pending_link" + assert result.pending_link_id is not None + + # Never auto-linked or auto-authenticated. + assert db.query(OAuthIdentity).count() == 0 + assert db.query(User).filter(User.email == "matched@example.com").count() == 1 + pending = db.get(OAuthPendingLink, result.pending_link_id) + assert pending.email == existing.email + assert pending.provider_subject == "sub-3" + + def test_unverified_email_with_no_existing_identity_is_rejected(self, db): + """An unverified email can't even be offered a pending-link (no + matched_user lookup happens), so this also lands on the same + no-signup-without-invite outcome as any other brand-new identity.""" + import asyncio + + identity = OAuthIdentityInfo( + subject="sub-4", email="unverified@example.com", email_verified=False, display_name=None + ) + service = _make_service(db, {"github": FakeProviderClient(identity=identity)}) + url = service.start("github", intent="login", frontend_redirect_base="http://localhost:5173") + state = url.split("state=")[1].split("&")[0] + + base, result = asyncio.run(service.complete_callback("github", state=state, code="c", provider_error=None)) + assert result.kind == "error" + assert result.error_code == "email_not_approved" + assert db.query(User).filter(User.email == "unverified@example.com").count() == 0 + + def test_missing_email_entirely_is_rejected(self, db): + import asyncio + + identity = OAuthIdentityInfo(subject="sub-5", email=None, email_verified=False, display_name=None) + service = _make_service(db, {"github": FakeProviderClient(identity=identity)}) + url = service.start("github", intent="login", frontend_redirect_base="http://localhost:5173") + state = url.split("state=")[1].split("&")[0] + base, result = asyncio.run(service.complete_callback("github", state=state, code="c", provider_error=None)) + assert result.kind == "error" + assert result.error_code == "email_not_approved" + + def test_seed_user_cannot_authenticate_via_oauth(self, db): + import asyncio + import uuid + + # A fresh test database has no seed-user row until something + # actually needs one (it's normally backfilled by migration 0002 for + # pre-existing data) -- insert it explicitly so the FK below is + # satisfiable, matching what a real deployment always has. + db.add(User(id=SEED_USER_ID, email=SEED_USER_EMAIL, password_hash=None)) + db.flush() + # A pre-existing identity somehow bound to the seed user (should never + # be created by normal flows -- this proves the login path itself + # refuses to open a session for it even if one existed). + db.add( + OAuthIdentity( + id=str(uuid.uuid4()), + user_id=SEED_USER_ID, + provider="google", + provider_subject="seed-sub", + email=SEED_USER_EMAIL, + created_at=datetime.now(UTC), + ) + ) + db.commit() + identity = OAuthIdentityInfo(subject="seed-sub", email=SEED_USER_EMAIL, email_verified=True, display_name=None) + service = _make_service(db, {"google": FakeProviderClient(identity=identity)}) + url = service.start("google", intent="login", frontend_redirect_base="http://localhost:5173") + state = url.split("state=")[1].split("&")[0] + base, result = asyncio.run(service.complete_callback("google", state=state, code="c", provider_error=None)) + assert result.kind == "error" + assert result.error_code == "account_unavailable" + + def test_inactive_account_cannot_authenticate_via_oauth(self, db): + import asyncio + import uuid + + user = _create_user(db, "disabled@example.com") + user.is_active = False + db.add( + OAuthIdentity( + id=str(uuid.uuid4()), + user_id=user.id, + provider="google", + provider_subject="sub-disabled", + email=user.email, + created_at=datetime.now(UTC), + ) + ) + db.commit() + identity = OAuthIdentityInfo(subject="sub-disabled", email=user.email, email_verified=True, display_name=None) + service = _make_service(db, {"google": FakeProviderClient(identity=identity)}) + url = service.start("google", intent="login", frontend_redirect_base="http://localhost:5173") + state = url.split("state=")[1].split("&")[0] + base, result = asyncio.run(service.complete_callback("google", state=state, code="c", provider_error=None)) + assert result.kind == "error" + assert result.error_code == "account_unavailable" + + +class TestCompleteCallbackLink: + def test_link_success_attaches_identity_to_the_authenticated_user(self, db): + import asyncio + + user = _create_user(db, "linker@example.com") + identity = OAuthIdentityInfo( + subject="link-sub-1", email="linker-provider-email@example.com", email_verified=True, display_name=None + ) + service = _make_service(db, {"google": FakeProviderClient(identity=identity)}) + url = service.start( + "google", intent="link", frontend_redirect_base="http://localhost:5173", link_user_id=user.id + ) + state = url.split("state=")[1].split("&")[0] + + base, result = asyncio.run(service.complete_callback("google", state=state, code="c", provider_error=None)) + assert result.kind == "linked" + assert result.user.id == user.id + stored = db.query(OAuthIdentity).one() + assert stored.user_id == user.id + assert stored.provider_subject == "link-sub-1" + + def test_link_conflict_when_identity_already_linked_elsewhere(self, db): + import asyncio + import uuid + + first_user = _create_user(db, "first@example.com") + second_user = _create_user(db, "second@example.com") + db.add( + OAuthIdentity( + id=str(uuid.uuid4()), + user_id=first_user.id, + provider="google", + provider_subject="contested-sub", + email="first@example.com", + created_at=datetime.now(UTC), + ) + ) + db.commit() + + identity = OAuthIdentityInfo( + subject="contested-sub", email="second@example.com", email_verified=True, display_name=None + ) + service = _make_service(db, {"google": FakeProviderClient(identity=identity)}) + url = service.start( + "google", intent="link", frontend_redirect_base="http://localhost:5173", link_user_id=second_user.id + ) + state = url.split("state=")[1].split("&")[0] + + base, result = asyncio.run(service.complete_callback("google", state=state, code="c", provider_error=None)) + assert result.kind == "error" + assert result.error_code == "already_linked" + # The original link is untouched. + assert db.query(OAuthIdentity).count() == 1 + assert db.query(OAuthIdentity).one().user_id == first_user.id + + def test_link_conflict_when_user_already_has_a_provider_identity(self, db): + import asyncio + import uuid + + user = _create_user(db, "double-linker@example.com") + db.add( + OAuthIdentity( + id=str(uuid.uuid4()), + user_id=user.id, + provider="google", + provider_subject="already-mine", + email=user.email, + created_at=datetime.now(UTC), + ) + ) + db.commit() + + identity = OAuthIdentityInfo( + subject="a-different-sub", email="whatever@example.com", email_verified=True, display_name=None + ) + service = _make_service(db, {"google": FakeProviderClient(identity=identity)}) + url = service.start( + "google", intent="link", frontend_redirect_base="http://localhost:5173", link_user_id=user.id + ) + state = url.split("state=")[1].split("&")[0] + + base, result = asyncio.run(service.complete_callback("google", state=state, code="c", provider_error=None)) + assert result.kind == "error" + assert result.error_code == "already_linked" + assert db.query(OAuthIdentity).count() == 1 + + +class TestConfirmPendingLink: + def test_correct_password_links_and_opens_a_session(self, db): + user = _create_user(db, "confirm-me@example.com", password="correct-horse-battery-staple") + import uuid + + pending = OAuthPendingLink( + id=str(uuid.uuid4()), + provider="google", + provider_subject="pending-sub", + email=user.email, + display_name="Display Name", + created_at=datetime.now(UTC), + expires_at=datetime.now(UTC) + timedelta(minutes=10), + ) + db.add(pending) + db.commit() + + service = _make_service(db, {}) + confirmed_user, access_token, refresh_token = service.confirm_pending_link( + pending.id, "correct-horse-battery-staple" + ) + assert confirmed_user.id == user.id + assert access_token + assert refresh_token + assert db.get(OAuthPendingLink, pending.id) is None + identity = db.query(OAuthIdentity).one() + assert identity.user_id == user.id + assert identity.provider_subject == "pending-sub" + + def test_wrong_password_is_rejected_and_pending_link_survives(self, db): + user = _create_user(db, "wrong-pw@example.com", password="correct-horse-battery-staple") + import uuid + + pending = OAuthPendingLink( + id=str(uuid.uuid4()), + provider="google", + provider_subject="pending-sub-2", + email=user.email, + display_name=None, + created_at=datetime.now(UTC), + expires_at=datetime.now(UTC) + timedelta(minutes=10), + ) + db.add(pending) + db.commit() + + service = _make_service(db, {}) + with pytest.raises(UnauthorizedError): + service.confirm_pending_link(pending.id, "totally-wrong-password") + assert db.get(OAuthPendingLink, pending.id) is not None + assert db.query(OAuthIdentity).count() == 0 + + def test_unknown_pending_link_id_is_rejected(self, db): + service = _make_service(db, {}) + with pytest.raises(UnauthorizedError): + service.confirm_pending_link("00000000-0000-0000-0000-000000000000", "any-password") + + def test_expired_pending_link_is_rejected(self, db): + user = _create_user(db, "expired@example.com") + import uuid + + pending = OAuthPendingLink( + id=str(uuid.uuid4()), + provider="google", + provider_subject="pending-sub-3", + email=user.email, + display_name=None, + created_at=datetime.now(UTC) - timedelta(minutes=30), + expires_at=datetime.now(UTC) - timedelta(minutes=1), + ) + db.add(pending) + db.commit() + service = _make_service(db, {}) + with pytest.raises(UnauthorizedError): + service.confirm_pending_link(pending.id, "correct-horse-battery-staple") + + def test_pending_link_against_a_password_less_account_is_rejected(self, db): + """An OAuth-only account (no password_hash) can never be the target + of a password-confirmed link -- there is no password to check.""" + user = _create_user(db, "oauth-only@example.com", password=None) + import uuid + + pending = OAuthPendingLink( + id=str(uuid.uuid4()), + provider="github", + provider_subject="pending-sub-4", + email=user.email, + display_name=None, + created_at=datetime.now(UTC), + expires_at=datetime.now(UTC) + timedelta(minutes=10), + ) + db.add(pending) + db.commit() + service = _make_service(db, {}) + with pytest.raises(UnauthorizedError): + service.confirm_pending_link(pending.id, "any-password-at-all") + + +class TestUnlink: + def test_unlinks_when_a_password_remains_as_a_credential(self, db): + import uuid + + user = _create_user(db, "has-password@example.com") + db.add( + OAuthIdentity( + id=str(uuid.uuid4()), + user_id=user.id, + provider="google", + provider_subject="sub", + email=user.email, + created_at=datetime.now(UTC), + ) + ) + db.commit() + service = _make_service(db, {}) + service.unlink(user, "google") + assert db.query(OAuthIdentity).count() == 0 + + def test_unlinks_when_another_provider_identity_remains(self, db): + import uuid + + user = _create_user(db, "two-providers@example.com", password=None) + db.add_all( + [ + OAuthIdentity( + id=str(uuid.uuid4()), + user_id=user.id, + provider="google", + provider_subject="g-sub", + email=user.email, + created_at=datetime.now(UTC), + ), + OAuthIdentity( + id=str(uuid.uuid4()), + user_id=user.id, + provider="github", + provider_subject="h-sub", + email=user.email, + created_at=datetime.now(UTC), + ), + ] + ) + db.commit() + service = _make_service(db, {}) + service.unlink(user, "google") + remaining = db.query(OAuthIdentity).all() + assert len(remaining) == 1 + assert remaining[0].provider == "github" + + def test_refuses_to_remove_the_only_sign_in_method(self, db): + import uuid + + user = _create_user(db, "oauth-only-2@example.com", password=None) + db.add( + OAuthIdentity( + id=str(uuid.uuid4()), + user_id=user.id, + provider="google", + provider_subject="only-sub", + email=user.email, + created_at=datetime.now(UTC), + ) + ) + db.commit() + service = _make_service(db, {}) + with pytest.raises(ValidationServiceError): + service.unlink(user, "google") + assert db.query(OAuthIdentity).count() == 1 + + def test_unlinking_a_provider_never_linked_is_not_found(self, db): + user = _create_user(db, "nothing-linked@example.com") + service = _make_service(db, {}) + with pytest.raises(NotFoundError): + service.unlink(user, "google") + + +class TestLinkedIdentities: + def test_lists_only_the_requested_users_identities(self, db): + import uuid + + user_a = _create_user(db, "a@example.com") + user_b = _create_user(db, "b@example.com") + db.add_all( + [ + OAuthIdentity( + id=str(uuid.uuid4()), + user_id=user_a.id, + provider="google", + provider_subject="a-sub", + email=user_a.email, + created_at=datetime.now(UTC), + ), + OAuthIdentity( + id=str(uuid.uuid4()), + user_id=user_b.id, + provider="github", + provider_subject="b-sub", + email=user_b.email, + created_at=datetime.now(UTC), + ), + ] + ) + db.commit() + service = _make_service(db, {}) + assert [i.provider for i in service.linked_identities(user_a.id)] == ["google"] + assert [i.provider for i in service.linked_identities(user_b.id)] == ["github"] diff --git a/apps/backend/tests/test_openapi_contract.py b/apps/backend/tests/test_openapi_contract.py new file mode 100644 index 00000000..1cfda8b4 --- /dev/null +++ b/apps/backend/tests/test_openapi_contract.py @@ -0,0 +1,242 @@ +"""Regression coverage for the generated OpenAPI contract (#83). + +The route table is intentionally enumerated here. The equality check against +the generated document makes a new operation fail loudly until its public/auth +classification, response statuses, and examples are consciously documented. +""" + +import re +from collections.abc import Mapping + +from app.core.exceptions import ErrorResponse + + +PUBLIC_OPERATIONS = { + ("POST", "/auth/register"), + ("POST", "/auth/login"), + # Logout is deliberately idempotent when its refresh cookie is absent. + ("POST", "/auth/logout"), + ("GET", "/health"), + ("GET", "/ready"), + # The one public write route besides register/login: reachable from the + # landing page before a visitor has an account or an invite (#334). + ("POST", "/waitlist"), + # OAuth sign-in (#288): all four are reachable before any session exists + # -- providers/start begin an anonymous flow, callback is a provider + # redirect target with no Authorization header of its own, and + # link/confirm authenticates via a one-time pending-link id + password, + # the same posture as login. + ("GET", "/auth/oauth/providers"), + ("GET", "/auth/oauth/{provider}/start"), + ("GET", "/auth/oauth/{provider}/callback"), + ("POST", "/auth/oauth/link/confirm"), +} + +# Refresh is unauthenticated in the HTTPBearer sense, but requires a valid +# refresh-token cookie. Keep it out of the bearer-auth sweep below. +COOKIE_REQUIRED_OPERATIONS = {("POST", "/auth/refresh")} + +EXPECTED_RESPONSES = { + ("POST", "/auth/register"): {201, 409, 422, 429, 500}, + ("POST", "/waitlist"): {201, 422, 429, 500}, + ("POST", "/auth/login"): {200, 401, 422, 429, 500}, + ("POST", "/auth/refresh"): {200, 401, 422, 429, 500}, + ("POST", "/auth/logout"): {204, 429, 500}, + ("GET", "/auth/me"): {200, 401, 429, 500}, + ("DELETE", "/auth/me"): {204, 401, 422, 429, 500}, + ("GET", "/auth/oauth/providers"): {200, 429, 500}, + ("GET", "/auth/oauth/{provider}/start"): {200, 422, 429, 500}, + ("POST", "/auth/oauth/{provider}/link"): {200, 401, 422, 429, 500}, + ("GET", "/auth/oauth/{provider}/callback"): {200, 422, 429, 500}, + ("POST", "/auth/oauth/link/confirm"): {200, 401, 409, 422, 429, 500}, + ("GET", "/auth/oauth/linked"): {200, 401, 429, 500}, + ("DELETE", "/auth/oauth/{provider}"): {204, 401, 404, 422, 429, 500}, + ("POST", "/repositories/upload"): {201, 401, 409, 422, 429, 500}, + ("POST", "/repositories/github"): {201, 401, 409, 422, 429, 502, 504, 500}, + ("GET", "/repositories"): {200, 401, 429, 500}, + ("GET", "/repositories/{repository_id}"): {200, 401, 404, 429, 500}, + ("GET", "/repositories/{repository_id}/file"): {200, 401, 404, 422, 429, 500}, + ("GET", "/repositories/{repository_id}/lineage"): {200, 401, 404, 429, 500}, + ("DELETE", "/repositories/{repository_id}"): {204, 401, 404, 429, 500}, + ("GET", "/intelligence/v1/snapshots/{snapshot_id}"): {200, 401, 404, 422, 429, 500}, + ("GET", "/intelligence/v1/snapshots/{snapshot_id}/symbols"): {200, 401, 404, 422, 429, 500}, + ("GET", "/intelligence/v1/snapshots/{snapshot_id}/neighbours"): {200, 401, 404, 422, 429, 500}, + ("GET", "/intelligence/v1/snapshots/{snapshot_id}/impact"): {200, 401, 404, 422, 429, 500}, + ("GET", "/intelligence/v1/snapshots/{snapshot_id}/references"): {200, 401, 404, 422, 429, 500}, + ("GET", "/intelligence/v1/snapshots/{snapshot_id}/assertions"): {200, 401, 404, 422, 429, 500}, + ("GET", "/intelligence/v1/snapshots/{snapshot_id}/paths"): {200, 401, 404, 422, 429, 500}, + ("GET", "/intelligence/v1/snapshots/{snapshot_id}/evidence"): {200, 401, 404, 422, 429, 500}, + ("POST", "/analysis/{repository_id}/start"): {200, 401, 404, 429, 500}, + ("GET", "/analysis/{repository_id}/status"): {200, 401, 404, 429, 500}, + ("POST", "/analysis/{repository_id}/cancel"): {200, 401, 404, 409, 429, 500}, + ("GET", "/analysis/{repository_id}/architecture"): {200, 401, 404, 429, 500}, + ("GET", "/analysis/{repository_id}/architecture/authentication"): {200, 401, 404, 429, 500}, + ("GET", "/analysis/{repository_id}/evidence"): {200, 401, 404, 422, 429, 500}, + ("GET", "/analysis/{repository_id}/revision-manifest"): {200, 401, 404, 429, 500}, + ("POST", "/analysis/{repository_id}/revision-manifest/verify"): {200, 401, 404, 422, 429, 500}, + ("GET", "/analysis/{repository_id}/dependencies"): {200, 401, 404, 422, 429, 500}, + ("GET", "/analysis/{repository_id}/review"): {200, 401, 404, 422, 429, 500}, + ("GET", "/analysis/{repository_id}/insights"): {200, 401, 404, 422, 429, 500}, + ("GET", "/ai/providers"): {200, 401, 429, 500}, + ("GET", "/ai/config"): {200, 401, 429, 500}, + ("PUT", "/ai/config"): {200, 401, 422, 429, 500}, + ("POST", "/ai/test"): {200, 401, 422, 429, 502, 500}, + ("POST", "/ai/query"): {200, 401, 404, 422, 429, 502, 500}, + ("GET", "/ai/conversations"): {200, 401, 404, 422, 429, 500}, + ("POST", "/documentation/generate"): {200, 401, 404, 422, 429, 500}, + ("POST", "/export"): {200, 401, 404, 409, 422, 429, 500}, + ("GET", "/health"): {200, 500}, + ("GET", "/ready"): {200, 503, 500}, + ("GET", "/metrics"): {200, 401, 500}, +} + +BODY_MEDIA_TYPES = { + ("POST", "/auth/register"): "application/json", + ("POST", "/auth/login"): "application/json", + ("POST", "/auth/oauth/link/confirm"): "application/json", + ("POST", "/repositories/upload"): "multipart/form-data", + ("POST", "/repositories/github"): "application/json", + ("PUT", "/ai/config"): "application/json", + ("POST", "/ai/test"): "application/json", + ("POST", "/ai/query"): "application/json", + ("POST", "/documentation/generate"): "application/json", + ("POST", "/export"): "application/json", +} + +STANDARD_ERROR_STATUSES = {401, 404, 409, 422, 429, 500, 502, 504} + + +def _operations(document: Mapping[str, object]) -> dict[tuple[str, str], dict]: + operations: dict[tuple[str, str], dict] = {} + for path, path_item in document["paths"].items(): + for method, operation in path_item.items(): + if method.upper() in {"GET", "POST", "PUT", "PATCH", "DELETE"}: + operations[(method.upper(), path)] = operation + return operations + + +def _response_media(operation: dict, status_code: int, media_type: str = "application/json") -> dict: + return operation["responses"][str(status_code)]["content"][media_type] + + +def _expected_responses_for(key: tuple[str, str]) -> set[int]: + assert key in EXPECTED_RESPONSES, f"missing expected response inventory for {key}" + return EXPECTED_RESPONSES[key] + + +def _concrete_path(path: str) -> str: + return re.sub(r"\{[^}]+\}", "11111111-1111-1111-1111-111111111111", path) + + +def _assert_unauthorized(response, key: tuple[str, str]) -> None: + assert response.status_code == 401, f"{key} accepted a request without a bearer token" + error = ErrorResponse.model_validate(response.json()) + assert error.code == "unauthorized", f"{key} did not use the unauthorized error envelope" + + +def test_openapi_covers_every_live_api_operation(client): + document = client.get("/openapi.json").json() + + assert set(_operations(document)) == set(EXPECTED_RESPONSES) + + +def test_openapi_declares_http_bearer_only_for_bearer_protected_operations(client): + document = client.get("/openapi.json").json() + operations = _operations(document) + + assert "HTTPBearer" in document["components"]["securitySchemes"] + for key, operation in operations.items(): + if key in PUBLIC_OPERATIONS | COOKIE_REQUIRED_OPERATIONS: + assert not operation.get("security"), f"non-bearer operation unexpectedly requires HTTPBearer: {key}" + else: + assert operation.get("security") == [{"HTTPBearer": []}], f"missing auth requirement: {key}" + + +def test_bearer_protected_operations_reject_requests_without_a_bearer_token(client): + operations = _operations(client.get("/openapi.json").json()) + bearer_protected = set(operations) - PUBLIC_OPERATIONS - COOKIE_REQUIRED_OPERATIONS + + assert bearer_protected, "expected to discover bearer-protected operations" + for key in sorted(bearer_protected): + method, path = key + _assert_unauthorized(client.request(method, _concrete_path(path)), key) + + +def test_cookie_required_operations_reject_requests_without_a_refresh_cookie(client): + operations = _operations(client.get("/openapi.json").json()) + + assert COOKIE_REQUIRED_OPERATIONS <= set(operations), "cookie-auth inventory drifted from the live API" + for key in sorted(COOKIE_REQUIRED_OPERATIONS): + method, path = key + _assert_unauthorized(client.request(method, _concrete_path(path)), key) + + +def test_openapi_declared_response_statuses_match_expected_inventory(client): + document = client.get("/openapi.json").json() + + for key in EXPECTED_RESPONSES: + expected_statuses = _expected_responses_for(key) + actual_statuses = {int(status) for status in _operations(document)[key]["responses"]} + assert actual_statuses == expected_statuses, f"response status drift for {key}" + + +def test_standard_error_responses_use_the_error_envelope_and_an_example(client): + document = client.get("/openapi.json") + components = document.json()["components"]["schemas"] + assert "ErrorResponse" in components + + for key, operation in _operations(document.json()).items(): + for status_code in _expected_responses_for(key) & STANDARD_ERROR_STATUSES: + media = _response_media(operation, status_code) + assert media["schema"] == {"$ref": "#/components/schemas/ErrorResponse"}, ( + f"{key} {status_code} does not declare ErrorResponse" + ) + assert media.get("example"), f"{key} {status_code} lacks an error example" + + +def test_openapi_has_request_examples_for_every_body_operation(client): + operations = _operations(client.get("/openapi.json").json()) + + for key, media_type in BODY_MEDIA_TYPES.items(): + request_body = operations[key]["requestBody"]["content"][media_type] + assert request_body.get("schema"), f"{key} lacks a request-body schema" + assert request_body.get("examples"), f"{key} lacks a request-body example" + + +def test_openapi_has_success_examples_except_for_no_content_deletes(client): + operations = _operations(client.get("/openapi.json").json()) + + for key, operation in operations.items(): + expected_statuses = _expected_responses_for(key) + success_status = 201 if 201 in expected_statuses else 204 if 204 in expected_statuses else 200 + response = operation["responses"][str(success_status)] + if success_status == 204: + assert "content" not in response, f"{key} must not document a body for 204" + continue + + media_type = "text/plain" if key == ("GET", "/metrics") else "application/json" + media = _response_media(operation, success_status, media_type) + assert media.get("schema"), f"{key} lacks a success response schema" + assert media.get("example") is not None, f"{key} lacks a success response example" + + +def test_readiness_documents_its_actual_non_error_503_payload(client): + operation = _operations(client.get("/openapi.json").json())[("GET", "/ready")] + media = _response_media(operation, 503) + + assert media["schema"]["type"] == "object" + assert media["example"]["status"] == "not_ready" + + +def test_dependency_openapi_exposes_manifest_provenance_and_diagnostics(client): + document = client.get("/openapi.json").json() + schemas = document["components"]["schemas"] + + node = schemas["DependencyNode"]["properties"] + assert node["version"]["anyOf"] == [{"type": "string"}, {"type": "null"}] + assert node["declarations"]["items"] == {"$ref": "#/components/schemas/DependencyDeclaration"} + response = schemas["DependencyGraphResponse"]["properties"] + assert response["manifestCount"]["type"] == "integer" + assert response["diagnostics"]["items"] == {"$ref": "#/components/schemas/DependencyDiagnostic"} + example = _response_media(_operations(document)[("GET", "/analysis/{repository_id}/dependencies")], 200)["example"] + assert example["nodes"][0]["declarations"][0]["manifestPath"] == "apps/frontend/package.json" diff --git a/apps/backend/tests/test_prototype_journey.py b/apps/backend/tests/test_prototype_journey.py new file mode 100644 index 00000000..51d8ed51 --- /dev/null +++ b/apps/backend/tests/test_prototype_journey.py @@ -0,0 +1,210 @@ +"""The flagship prototype journey, end to end (#154). + +Register -> import -> identify the revision -> run durable analysis -> explore +the architecture -> ask the authentication question -> receive an +evidence-backed answer -> open its source -> verify the revision manifest -> +prove a second owner is locked out. + +Each surface has its own focused tests elsewhere. This one exists because the +prototype's claim is that these steps connect: a citation shown at the end must +belong to the snapshot sealed in the middle, for the revision identified at the +start. Testing the steps in isolation cannot catch that seam breaking. +""" + +from __future__ import annotations + +import io +import zipfile + +from tests.analysis_helpers import run_analysis_jobs + +# A genuinely connected guard chain: a route depends on a guard, which reaches a +# service, which reaches a model. Plus an unguarded /health route that must +# never be claimed as authentication-relevant. +_SOURCES = { + "README.md": b"# journey fixture\n", + "requirements.txt": b"fastapi==0.115.0\n", + "src/dependencies.py": ( + b"from src.services import UserService\n\n\n" + b"def get_current_user(token: str) -> dict:\n" + b" return UserService(token)\n" + ), + "src/services.py": ( + b"from src.models import UserModel\n\n\ndef UserService(token: str) -> dict:\n return UserModel(token)\n" + ), + "src/models.py": b"def UserModel(token: str) -> dict:\n return {'token': token}\n", + "src/routes.py": ( + b"from fastapi import Depends, FastAPI\n" + b"from src.dependencies import get_current_user\n\n" + b"app = FastAPI()\n\n\n" + b'@app.get("/me")\n' + b"def me(user=Depends(get_current_user)) -> dict:\n" + b" return user\n\n\n" + b'@app.get("/health")\n' + b"def health() -> dict:\n" + b" return {'status': 'ok'}\n" + ), +} + + +def _archive() -> bytes: + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as archive: + for path, content in _SOURCES.items(): + archive.writestr(path, content) + return buffer.getvalue() + + +def test_evidence_backed_prototype_journey(auth_client, make_auth_headers): + # 1. Anonymous access is refused before anything else happens. + assert auth_client.get("/repositories", headers={"Authorization": ""}).status_code == 401 + + # 2. Import a repository. + upload = auth_client.post( + "/repositories/upload", + files={"file": ("journey.zip", _archive(), "application/zip")}, + ) + assert upload.status_code == 201, upload.text + repository = upload.json() + repository_id = repository["id"] + + # 3. The exact revision is identified at import time, before any analysis. + revision = repository["revision"] + assert revision["kind"] == "upload" + assert revision["value"].startswith("sha256:") + + # 4. Analysis is durable: submitting enqueues, draining the worker completes. + assert auth_client.post(f"/analysis/{repository_id}/start").status_code == 200 + assert run_analysis_jobs() == 1 + status = auth_client.get(f"/analysis/{repository_id}/status").json() + assert status["status"] == "completed" + + # 5. The architecture comes from a sealed snapshot, not the working tree. + architecture = auth_client.get(f"/analysis/{repository_id}/architecture").json() + snapshot_id = architecture["relationshipSnapshotId"] + assert snapshot_id + assert architecture["nodes"] + + # 6. The authentication question returns an evidence-backed answer. + explanation = auth_client.get(f"/analysis/{repository_id}/architecture/authentication").json() + assert explanation["status"] == "ready" + assert explanation["snapshotId"] == snapshot_id + claims = explanation["claims"] + assert claims + + # The unguarded route must not be claimed as authentication-relevant. + assert not any(claim["name"] == "/health" for claim in claims) + + # 7. Every citation belongs to the snapshot the manifest will name. + citations = [item for claim in claims for item in claim["evidence"]] + assert citations + assert all(item["snapshotId"] == snapshot_id for item in citations) + + # 8. A citation opens real source, verified against the sealed content. + cited = citations[0] + source = auth_client.get( + f"/analysis/{repository_id}/evidence", + params={ + "snapshotId": cited["snapshotId"], + "factId": cited["factId"], + "path": cited["path"], + "startLine": cited["startLine"], + "endLine": cited["endLine"], + }, + ).json() + assert source["status"] == "ready" + assert source["content"] + assert source["revisionValue"] == revision["value"] + + # 9. A fabricated fact id against a real snapshot must not resolve. This is + # the "a real citation on an unsupported claim is still invalid" rule. + forged = auth_client.get( + f"/analysis/{repository_id}/evidence", + params={ + "snapshotId": snapshot_id, + "factId": "fabricated::fact", + "path": cited["path"], + "startLine": cited["startLine"], + "endLine": cited["endLine"], + }, + ).json() + assert forged["status"] == "unavailable" + assert forged["content"] is None + + # 10. The revision manifest names that same snapshot and verifies. + manifest_response = auth_client.get(f"/analysis/{repository_id}/revision-manifest").json() + manifest = manifest_response["manifest"] + assert manifest["snapshotId"] == snapshot_id + assert manifest["revisionValue"] == revision["value"] + assert manifest_response["verificationState"] == "verified" + + verified = auth_client.post( + f"/analysis/{repository_id}/revision-manifest/verify", + json={"manifest": manifest, "manifestDigest": manifest_response["manifestDigest"]}, + ).json() + assert verified["verificationState"] == "verified" + + # 11. Dependency Graph is snapshot-bound too (#158): it shares the exact + # same snapshot identity proven above, not a separate legacy inventory. + dependencies = auth_client.get(f"/analysis/{repository_id}/dependencies").json() + assert dependencies["schemaVersion"] == "dependency-graph.v2" + assert dependencies["snapshotId"] == snapshot_id + assert dependencies["provenance"]["source"] == "ri.v1" + fastapi = next(node for node in dependencies["nodes"] if node["id"] == "dep:pypi:fastapi") + assert fastapi["declarations"][0]["manifestPath"] == "requirements.txt" + + # 12. A second owner reaches none of it, and sees an empty list. + intruder = make_auth_headers("journey-intruder@example.com") + for path in ( + f"/repositories/{repository_id}", + f"/analysis/{repository_id}/architecture", + f"/analysis/{repository_id}/architecture/authentication", + f"/analysis/{repository_id}/revision-manifest", + ): + assert auth_client.get(path, headers=intruder["headers"]).status_code == 404 + + listed = auth_client.get("/repositories", headers=intruder["headers"]).json() + assert listed["data"] == [] + + +def test_journey_answer_is_not_simulated_without_a_provider(auth_client): + """The AI path must fail closed rather than invent an answer.""" + + upload = auth_client.post( + "/repositories/upload", + files={"file": ("journey.zip", _archive(), "application/zip")}, + ) + repository_id = upload.json()["id"] + assert auth_client.post(f"/analysis/{repository_id}/start").status_code == 200 + assert run_analysis_jobs() == 1 + + response = auth_client.post( + "/ai/query", + json={"repositoryId": repository_id, "query": "How does authentication work?"}, + ) + + assert response.status_code == 422 + assert "not configured" in response.text.lower() + + +def test_journey_reports_honest_state_before_a_snapshot_exists(auth_client): + """A repository with no sealed snapshot must not look analysed.""" + + upload = auth_client.post( + "/repositories/upload", + files={"file": ("journey.zip", _archive(), "application/zip")}, + ) + repository_id = upload.json()["id"] + # Enqueue but do not drain the worker: no snapshot is sealed. + assert auth_client.post(f"/analysis/{repository_id}/start").status_code == 200 + + # Architecture 404s rather than falling back to a graph built from unsealed + # repository metadata (#217) -- the same honest contract as the manifest below. + assert auth_client.get(f"/analysis/{repository_id}/architecture").status_code == 404 + + explanation = auth_client.get(f"/analysis/{repository_id}/architecture/authentication").json() + assert explanation["status"] == "missing_snapshot" + assert explanation["claims"] == [] + + # The manifest must refuse to name a revision it cannot evidence. + assert auth_client.get(f"/analysis/{repository_id}/revision-manifest").status_code == 404 diff --git a/apps/backend/tests/test_provider_key_encryption.py b/apps/backend/tests/test_provider_key_encryption.py new file mode 100644 index 00000000..229f53ef --- /dev/null +++ b/apps/backend/tests/test_provider_key_encryption.py @@ -0,0 +1,221 @@ +"""Encrypted, per-user AI provider keys (E1.5 / #65). + +Covers the two contracts the issue calls out: the encrypt/decrypt round-trip, +and the no-leak guarantee — a saved key is stored only as ciphertext, is never +serialised back to the client in full (last-4 only), and one user's key is +neither visible to nor usable by another. +""" + +import io +import json +import zipfile + +from sqlalchemy import select + +from tests.analysis_helpers import run_analysis_jobs +from tests.api_assertions import assert_error_response +from tests.conftest import register_user + + +def _zip_bytes(files: dict[str, str]) -> bytes: + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as archive: + for path, content in files.items(): + archive.writestr(path, content) + return buffer.getvalue() + + +def _upload_sample(client, headers: dict) -> str: + response = client.post( + "/repositories/upload", + files={ + "file": ( + "sample.zip", + _zip_bytes({"sample/package.json": '{"dependencies":{}}'}), + "application/octet-stream", + ) + }, + headers=headers, + ) + assert response.status_code == 201, response.text + return response.json()["id"] + + +def _test_cipher(): + from app.core.config import Settings + from app.core.crypto import build_provider_cipher + + return build_provider_cipher(Settings(app_env="test")) + + +# --- cipher -------------------------------------------------------------------- + + +def test_cipher_round_trip_and_ciphertext_differs_from_plaintext(): + cipher = _test_cipher() + secret = "sk-live-abcdef1234567890" + + token = cipher.encrypt(secret) + assert token != secret + assert secret not in token + assert cipher.decrypt(token) == secret + + +def test_ciphertext_from_a_different_key_does_not_decrypt(): + import pytest + + from app.core.crypto import InvalidToken, ProviderKeyCipher + + token = _test_cipher().encrypt("sk-secret") + # A different (valid) Fernet key must not be able to read it. + other = ProviderKeyCipher(_fresh_key()) + with pytest.raises(InvalidToken): + other.decrypt(token) + + +def _fresh_key() -> str: + from cryptography.fernet import Fernet + + return Fernet.generate_key().decode() + + +# --- no-leak API contract ------------------------------------------------------ + + +def test_ai_config_starts_empty_for_a_new_user(client): + auth = register_user(client, "empty-config@example.com") + + response = client.get("/ai/config", headers=auth["headers"]) + + assert response.status_code == 200 + assert response.json() == { + "provider": None, + "model": None, + "baseUrl": None, + "hasApiKey": False, + "apiKeyLast4": None, + } + + +def test_saved_key_is_encrypted_at_rest_and_never_returned_in_full(client): + auth = register_user(client, "keys@example.com") + save = client.put( + "/ai/config", + json={"provider": "openai", "apiKey": "sk-secret-ABCD1234", "model": "gpt-4o-mini"}, + headers=auth["headers"], + ) + assert save.status_code == 200 + body = save.json() + assert body["hasApiKey"] is True + assert body["apiKeyLast4"] == "1234" + assert "sk-secret-ABCD1234" not in json.dumps(body) + + get_response = client.get("/ai/config", headers=auth["headers"]) + assert get_response.status_code == 200 + got = get_response.json() + assert got["hasApiKey"] is True + assert got["apiKeyLast4"] == "1234" + assert "sk-secret-ABCD1234" not in json.dumps(got) + + # The database column holds ciphertext, never the plaintext key. + from app.core.database import SessionLocal + from app.models.ai_provider_config import AiProviderConfigRecord + + db = SessionLocal() + try: + record = db.scalars(select(AiProviderConfigRecord)).one() + assert record.encrypted_api_key + assert "sk-secret-ABCD1234" not in record.encrypted_api_key + assert record.api_key_last4 == "1234" + finally: + db.close() + + +def test_store_decrypts_the_owners_key_for_a_request(client): + auth = register_user(client, "store@example.com") + client.put( + "/ai/config", + json={"provider": "openai", "apiKey": "sk-decrypt-XYZ7"}, + headers=auth["headers"], + ) + + from app.ai.providers.config_store import EncryptedProviderConfigStore + from app.core.database import SessionLocal + + db = SessionLocal() + try: + store = EncryptedProviderConfigStore(db, _test_cipher(), auth["user"]["id"]) + config = store.read_config() + # Decrypted in-process, at request time, to talk to the provider. + assert config is not None + assert config.provider == "openai" + assert config.api_key == "sk-decrypt-XYZ7" + finally: + db.close() + + +# --- per-user isolation -------------------------------------------------------- + + +def test_provider_config_is_scoped_per_user(client, make_auth_headers): + alice = make_auth_headers("alice@example.com") + bob = make_auth_headers("bob@example.com") + + client.put("/ai/config", json={"provider": "openai", "apiKey": "sk-alice-0001"}, headers=alice["headers"]) + + # Bob sees nothing of Alice's. + bob_config = client.get("/ai/config", headers=bob["headers"]).json() + assert bob_config["hasApiKey"] is False + assert bob_config["provider"] is None + + client.put("/ai/config", json={"provider": "anthropic", "apiKey": "sk-bob-9999"}, headers=bob["headers"]) + + alice_config = client.get("/ai/config", headers=alice["headers"]).json() + assert alice_config["provider"] == "openai" + assert alice_config["apiKeyLast4"] == "0001" + + bob_config = client.get("/ai/config", headers=bob["headers"]).json() + assert bob_config["provider"] == "anthropic" + assert bob_config["apiKeyLast4"] == "9999" + + +# --- missing / carried-forward keys -------------------------------------------- + + +def test_missing_key_for_new_provider_is_a_clear_error(client): + auth = register_user(client, "missing@example.com") + response = client.put("/ai/config", json={"provider": "openai"}, headers=auth["headers"]) + error = assert_error_response(response, 422, "validation_error") + assert "API key is required" in error.message + + +def test_saving_without_key_carries_the_existing_key_forward(client): + auth = register_user(client, "carry@example.com") + client.put( + "/ai/config", + json={"provider": "openai", "apiKey": "sk-carry-8888", "model": "gpt-4o-mini"}, + headers=auth["headers"], + ) + + # Re-save with a new model and no key: the stored key is retained. + response = client.put("/ai/config", json={"provider": "openai", "model": "gpt-4o"}, headers=auth["headers"]) + assert response.status_code == 200 + body = response.json() + assert body["hasApiKey"] is True + assert body["apiKeyLast4"] == "8888" + assert body["model"] == "gpt-4o" + + +def test_ai_query_without_provider_config_returns_a_clear_error(client): + auth = register_user(client, "noai@example.com") + repository_id = _upload_sample(client, auth["headers"]) + assert client.post(f"/analysis/{repository_id}/start", headers=auth["headers"]).status_code == 200 + assert run_analysis_jobs() == 1 + + response = client.post( + "/ai/query", + json={"repositoryId": repository_id, "query": "Summarize this repo"}, + headers=auth["headers"], + ) + error = assert_error_response(response, 422, "validation_error") + assert "not configured" in error.message.lower() diff --git a/apps/backend/tests/test_rate_limit.py b/apps/backend/tests/test_rate_limit.py new file mode 100644 index 00000000..24bd0dd0 --- /dev/null +++ b/apps/backend/tests/test_rate_limit.py @@ -0,0 +1,489 @@ +import asyncio +import io +import os +import uuid +import zipfile +from collections.abc import Generator +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + +from app.core.rate_limit import MemoryRateLimitStore, StoreUnavailableError, classify +from tests.conftest import register_user + +REDIS_URL = os.environ.get("PARTHA_TEST_REDIS_URL") + + +class _FakeClient: + def __init__(self, host: str) -> None: + self.host = host + + +class _FakeRequest: + """Minimal stand-in for a Starlette Request for resolve_rate_key. + + resolve_rate_key only reads ``.headers.get('authorization')`` and + ``.client.host``; a dict of lowercased headers matches Starlette's + case-insensitive lookup for these tests. + """ + + def __init__(self, headers: dict[str, str], host: str = "203.0.113.9") -> None: + self.headers = headers + self.client = _FakeClient(host) + + +def _hit(store, key: str, window: int) -> tuple[int, int]: + """Drive the async store.hit from a synchronous test.""" + return asyncio.run(store.hit(key, window)) + + +def _zip_bytes(files: dict[str, str]) -> bytes: + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as archive: + for path, content in files.items(): + archive.writestr(path, content) + return buffer.getvalue() + + +def _import_sample(client) -> str: + response = client.post( + "/repositories/upload", + files={ + "file": ( + "sample.zip", + _zip_bytes({"sample/package.json": '{"dependencies":{}}'}), + "application/octet-stream", + ) + }, + ) + assert response.status_code == 201 + return response.json()["id"] + + +RATE_ENV = { + "RATE_LIMIT_DEFAULT_PER_MINUTE": "3", + "RATE_LIMIT_HEAVY_PER_MINUTE": "2", + "RATE_LIMIT_AI_PER_MINUTE": "2", +} + + +@pytest.fixture() +def limited_client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Generator[TestClient, None, None]: + """The standard client fixture with budgets small enough to exhaust.""" + database_path = tmp_path / "partha-test.db" + monkeypatch.setenv("DATABASE_URL", f"sqlite:///{database_path}") + monkeypatch.setenv("STORAGE_PATH", str(tmp_path / "storage")) + monkeypatch.setenv("AUTO_CREATE_TABLES", "true") + monkeypatch.setenv("CORS_ORIGINS", "http://testserver") + for key, value in RATE_ENV.items(): + monkeypatch.setenv(key, value) + + from app.core import config + + config.get_settings.cache_clear() + + import app.core.database as database + + settings = config.get_settings() + database.settings = settings + database.engine.dispose() + database.connect_args = {"check_same_thread": False} + database.engine = database.create_engine( + settings.database_url, pool_pre_ping=True, connect_args=database.connect_args + ) + database.SessionLocal.configure(bind=database.engine) + + from app.core.schema_sync import stamp_head + from app.main import create_app + from app.models.base import Base + + Base.metadata.create_all(bind=database.engine) + # Mirrors what the app's own lifespan does for a genuinely fresh database + # (#166); see the identical comment in tests/conftest.py. + stamp_head(database.engine) + with TestClient(create_app()) as test_client: + # Protected routes now require auth (E1.3 / #63); authenticate as a real + # user so the budget tests exercise the routes rather than bouncing off + # a 401. Requests are keyed on this user's id — the register call below + # is the "auth" class, which the small RATE_ENV budgets do not touch. + auth = register_user(test_client, "limited@example.com") + test_client.headers.update(auth["headers"]) + yield test_client + + for key in ("DATABASE_URL", "STORAGE_PATH", "AUTO_CREATE_TABLES", "CORS_ORIGINS", *RATE_ENV): + os.environ.pop(key, None) + config.get_settings.cache_clear() + + +# --- classifier ---------------------------------------------------------------- + + +def test_classify_maps_routes_to_budget_classes(): + assert classify("POST", "/auth/login") == "auth" + assert classify("POST", "/auth/register") == "auth" + assert classify("POST", "/waitlist") == "auth" + assert classify("POST", "/ai/query") == "ai" + assert classify("GET", "/ai/config") == "ai" + assert classify("POST", "/repositories/upload") == "heavy" + assert classify("POST", "/repositories/github") == "heavy" + assert classify("POST", "/analysis/some-id/start") == "heavy" + assert classify("GET", "/analysis/some-id/status") == "default" + assert classify("POST", "/documentation/generate") == "heavy" + assert classify("POST", "/export") == "heavy" + assert classify("GET", "/repositories") == "default" + # Exemptions: probes, docs, and CORS preflight. + assert classify("GET", "/health") is None + assert classify("GET", "/metrics") is None + assert classify("GET", "/docs") is None + assert classify("OPTIONS", "/repositories") is None + + +# --- identity resolution (per-user vs client IP) -------------------------------- + + +def _test_settings(): + from app.core.config import Settings + + return Settings(app_env="test") + + +def test_resolve_rate_key_uses_validated_user_id_for_authenticated_requests(): + from app.auth.security import create_access_token + from app.core.rate_limit import resolve_rate_key + + settings = _test_settings() + token = create_access_token("user-123", settings) + request = _FakeRequest({"authorization": f"Bearer {token}"}) + + assert resolve_rate_key(request, settings) == "user:user-123" + + +def test_resolve_rate_key_falls_back_to_client_ip_when_unauthenticated(): + from app.core.rate_limit import resolve_rate_key + + request = _FakeRequest({}, host="198.51.100.7") + assert resolve_rate_key(request, _test_settings()) == "ip:198.51.100.7" + + +def test_forged_token_cannot_select_a_user_specific_key(): + # A token signed with a secret that is not the server's must never yield a + # user key — the signature check fails and identity falls back to the IP. + from app.auth.security import create_access_token + from app.core.config import Settings + from app.core.rate_limit import resolve_rate_key + + attacker = create_access_token("victim-id", Settings(app_env="test", auth_secret_key="x" * 40)) + request = _FakeRequest({"authorization": f"Bearer {attacker}"}) + + assert resolve_rate_key(request, _test_settings()) == "ip:203.0.113.9" + + +def test_garbage_bearer_token_does_not_select_a_user_key(): + from app.core.rate_limit import resolve_rate_key + + request = _FakeRequest({"authorization": "Bearer not-a-real-token"}) + assert resolve_rate_key(request, _test_settings()) == "ip:203.0.113.9" + + +def test_forwarded_header_is_ignored_for_rate_key(): + # X-Forwarded-For is client-settable and must not influence the key; only + # the direct socket peer address is used for the IP fallback. + from app.core.rate_limit import resolve_rate_key + + request = _FakeRequest({"x-forwarded-for": "10.0.0.1"}, host="203.0.113.9") + assert resolve_rate_key(request, _test_settings()) == "ip:203.0.113.9" + + +def test_two_authenticated_users_behind_one_ip_get_independent_budgets(limited_client): + # limited_client is authenticated as the fixture user and shares one client + # IP with a second user we register here. + other = register_user(limited_client, "second-user@example.com") + + # The primary user exhausts the default budget (3/min)... + for _ in range(3): + assert limited_client.get("/repositories").status_code == 200 + assert limited_client.get("/repositories").status_code == 429 + + # ...and the second user, same IP, still has a full budget. + assert limited_client.get("/repositories", headers=other["headers"]).status_code == 200 + + +def test_repeated_requests_from_one_user_consume_that_users_budget(limited_client): + for _ in range(3): + assert limited_client.get("/repositories").status_code == 200 + # The 4th request from the same user exceeds the 3/min default budget. + assert limited_client.get("/repositories").status_code == 429 + + +# --- memory store -------------------------------------------------------------- + + +def test_memory_store_counts_and_resets_after_window(): + now = [1000.0] + store = MemoryRateLimitStore(clock=lambda: now[0]) + + counts = [_hit(store, "k", 60)[0] for _ in range(3)] + assert counts == [1, 2, 3] + + _, retry_after = _hit(store, "k", 60) + assert 1 <= retry_after <= 60 + + now[0] += 61 # past the window: the counter starts over + count, _ = _hit(store, "k", 60) + assert count == 1 + + +def test_memory_store_keys_are_independent(): + store = MemoryRateLimitStore(clock=lambda: 0.0) + assert _hit(store, "a", 60)[0] == 1 + assert _hit(store, "b", 60)[0] == 1 + + +# --- middleware behaviour ------------------------------------------------------- + + +def test_exceeding_default_budget_returns_429_with_retry_after(limited_client): + for _ in range(3): + assert limited_client.get("/repositories").status_code == 200 + + blocked = limited_client.get("/repositories") + assert blocked.status_code == 429 + body = blocked.json() + assert body["code"] == "rate_limited" + assert body["details"]["retryAfterSeconds"] >= 1 + assert int(blocked.headers["Retry-After"]) >= 1 + + +def test_budget_classes_are_charged_separately(limited_client): + # Exhaust the heavy budget (2/min) with invalid imports — the limiter runs + # before validation, so 422s are charged too. + assert limited_client.post("/repositories/github", json={}).status_code != 429 + assert limited_client.post("/repositories/github", json={}).status_code != 429 + assert limited_client.post("/repositories/github", json={}).status_code == 429 + + # The default class still has budget left. + assert limited_client.get("/repositories").status_code == 200 + + +def test_export_endpoint_is_charged_against_the_heavy_budget(limited_client): + """Regression: classify() must key on the route FastAPI actually registers + (POST /export), not a path that was never wired up. Both the import and the + export below share the 2/min heavy budget, so if /export silently fell back + to the (looser) default class this would never hit 429.""" + repository_id = _import_sample(limited_client) # 1st heavy hit + + # Every export target is sealed-snapshot-bound (Dependencies since #158, + # Architecture since #217), and this repository was only imported, never + # analysed — so the export 404s. That still proves the request reached the + # route handler (past the rate limiter), which is all this regression needs; + # only a 429 would mean the budget wasn't charged. + payload = {"repositoryId": repository_id, "target": "architecture", "format": "json"} + assert limited_client.post("/export", json=payload).status_code == 404 # 2nd heavy hit + + blocked = limited_client.post("/export", json=payload) # 3rd heavy hit + assert blocked.status_code == 429 + body = blocked.json() + assert body["code"] == "rate_limited" + assert body["details"]["retryAfterSeconds"] >= 1 + assert int(blocked.headers["Retry-After"]) >= 1 + + +def test_probes_are_never_limited(limited_client): + for _ in range(10): + assert limited_client.get("/health").status_code == 200 + + +def test_cors_preflight_is_never_limited(limited_client): + for _ in range(3): + limited_client.get("/repositories") # exhaust the default budget + + preflight = limited_client.options( + "/repositories", + headers={"Origin": "http://testserver", "Access-Control-Request-Method": "GET"}, + ) + assert preflight.status_code == 200 + + +def test_store_failure_fails_open_loudly(limited_client): + class FailingStore: + async def hit(self, key: str, window_seconds: int) -> tuple[int, int]: + raise StoreUnavailableError("redis is down") + + from app.core.observability import runtime_metrics + + degraded_before = runtime_metrics.rate_limit_degraded_total + limited_client.app.state.rate_limit_store = FailingStore() + + # Way past the budget, yet everything is served — and counted as degraded. + for _ in range(5): + assert limited_client.get("/repositories").status_code == 200 + assert runtime_metrics.rate_limit_degraded_total >= degraded_before + 5 + + +def test_rate_limit_store_closed_on_app_shutdown(monkeypatch, tmp_path: Path) -> None: + """The Redis store holds a connection pool; the app must close it on + shutdown rather than leaking it, e.g. across a reload/redeploy.""" + monkeypatch.setenv("DATABASE_URL", f"sqlite:///{tmp_path / 'partha-test.db'}") + monkeypatch.setenv("STORAGE_PATH", str(tmp_path / "storage")) + monkeypatch.setenv("AUTO_CREATE_TABLES", "true") + monkeypatch.setenv("CORS_ORIGINS", "http://testserver") + + from app.core import config + + config.get_settings.cache_clear() + + from app.main import create_app + + closed = {"value": False} + + class StubStore: + async def hit(self, key: str, window_seconds: int) -> tuple[int, int]: + return 1, 60 + + async def aclose(self) -> None: + closed["value"] = True + + monkeypatch.setattr("app.main.build_rate_limit_store", lambda settings: StubStore()) + + with TestClient(create_app()) as test_client: + assert closed["value"] is False + test_client.get("/health") + + assert closed["value"] is True + + for key in ("DATABASE_URL", "STORAGE_PATH", "AUTO_CREATE_TABLES", "CORS_ORIGINS"): + os.environ.pop(key, None) + config.get_settings.cache_clear() + + +def test_rate_limit_store_closed_when_lifespan_exits_via_exception(monkeypatch, tmp_path: Path) -> None: + """Cleanup after `yield` must run even when the lifespan's running state + exits through an exception, not only on a clean shutdown — otherwise a crash + while the app is running leaks the Redis connection pool instead of closing + it, defeating the fix `test_rate_limit_store_closed_on_app_shutdown` proves + for the clean-shutdown path.""" + monkeypatch.setenv("DATABASE_URL", f"sqlite:///{tmp_path / 'partha-test.db'}") + monkeypatch.setenv("STORAGE_PATH", str(tmp_path / "storage")) + monkeypatch.setenv("AUTO_CREATE_TABLES", "true") + monkeypatch.setenv("CORS_ORIGINS", "http://testserver") + + from app.core import config + + config.get_settings.cache_clear() + + from app.main import create_app, lifespan + + closed = {"value": False} + + class StubStore: + async def hit(self, key: str, window_seconds: int) -> tuple[int, int]: + return 1, 60 + + async def aclose(self) -> None: + closed["value"] = True + + monkeypatch.setattr("app.main.build_rate_limit_store", lambda settings: StubStore()) + + app = create_app() + + class Boom(Exception): + pass + + async def scenario() -> None: + with pytest.raises(Boom): + async with lifespan(app): + raise Boom("simulated failure while the app is running") + + asyncio.run(scenario()) + + assert closed["value"] is True + + for key in ("DATABASE_URL", "STORAGE_PATH", "AUTO_CREATE_TABLES", "CORS_ORIGINS"): + os.environ.pop(key, None) + config.get_settings.cache_clear() + + +def test_metrics_endpoint_exposes_rate_limit_counters(limited_client): + text = limited_client.get("/metrics").text + assert "partha_rate_limited_requests_total" in text + assert "partha_rate_limit_degraded_total" in text + + +def test_429_response_still_carries_cors_and_security_headers(limited_client): + # RateLimitMiddleware is registered before SecurityHeadersMiddleware and + # CORSMiddleware, so it sits innermost and its 429s must still pick up both + # as they bubble back out — otherwise a browser client sees an opaque CORS + # failure instead of a readable 429. + origin = "http://testserver" + for _ in range(3): + assert limited_client.get("/repositories", headers={"Origin": origin}).status_code == 200 + + blocked = limited_client.get("/repositories", headers={"Origin": origin}) + assert blocked.status_code == 429 + assert blocked.headers["access-control-allow-origin"] == origin + assert blocked.headers["x-content-type-options"] == "nosniff" + assert "retry-after" in blocked.headers + assert "x-request-id" in blocked.headers + + +# --- real Redis backend (gated) ----------------------------------------------- + + +@pytest.mark.skipif(not REDIS_URL, reason="set PARTHA_TEST_REDIS_URL to run the Redis backend test") +def test_redis_store_is_atomic_and_expires(): + """Exercise the actual RedisRateLimitStore against a real server: the atomic + script counts up within a window, arms a bounded TTL, and isolates keys.""" + import redis.asyncio as redis_asyncio + + from app.core.rate_limit import RedisRateLimitStore + + async def scenario() -> None: + client = redis_asyncio.from_url(REDIS_URL, decode_responses=False) + store = RedisRateLimitStore(client) + key_a = f"pytest:{uuid.uuid4()}" + key_b = f"pytest:{uuid.uuid4()}" + try: + counts = [(await store.hit(key_a, 60))[0] for _ in range(3)] + assert counts == [1, 2, 3] + + _, ttl = await store.hit(key_a, 60) + assert 1 <= ttl <= 60 # armed once, bounded by the window + + # A different key is a separate budget. + assert (await store.hit(key_b, 60))[0] == 1 + finally: + await client.delete(f"partha:ratelimit:{key_a}", f"partha:ratelimit:{key_b}") + await client.aclose() + + asyncio.run(scenario()) + + +@pytest.mark.skipif(not REDIS_URL, reason="set PARTHA_TEST_REDIS_URL to run the Redis backend test") +def test_redis_store_atomic_under_concurrent_hits(): + """The separate INCR/EXPIRE calls this replaced had a race window; fire real + concurrent hits at one key and prove the count is exact (no lost updates) — + a sequential test can't distinguish an atomic script from a racy one.""" + import redis.asyncio as redis_asyncio + + from app.core.rate_limit import RedisRateLimitStore + + concurrency = 50 + + async def scenario() -> None: + client = redis_asyncio.from_url(REDIS_URL, decode_responses=False) + store = RedisRateLimitStore(client) + key = f"pytest:{uuid.uuid4()}" + try: + results = await asyncio.gather(*(store.hit(key, 60) for _ in range(concurrency))) + counts = sorted(count for count, _ in results) + # Every hit claimed a distinct, contiguous slot — no two racers + # observed/wrote the same counter value, and none were lost. + assert counts == list(range(1, concurrency + 1)) + ttls = {ttl for _, ttl in results} + assert all(1 <= ttl <= 60 for ttl in ttls) + finally: + await client.delete(f"partha:ratelimit:{key}") + await client.aclose() + + asyncio.run(scenario()) diff --git a/apps/backend/tests/test_report_builders.py b/apps/backend/tests/test_report_builders.py index d2d843ab..a85e16b4 100644 --- a/apps/backend/tests/test_report_builders.py +++ b/apps/backend/tests/test_report_builders.py @@ -8,7 +8,12 @@ ArchNode, RequestFlowStep, ) -from app.schemas.dependencies import DependencyGraphResponse, DependencyNode +from app.schemas.dependencies import ( + DependencyAssessment, + DependencyGraphResponse, + DependencyNode, + DependencyProvenance, +) def _architecture() -> ArchitectureResponse: @@ -35,9 +40,24 @@ def _architecture() -> ArchitectureResponse: ], edges=[], modules=[ - ArchModule(id="module:services", name="Services", layer="business-logic", node_ids=["module:services"], description="Services module.", file_count=2) + ArchModule( + id="module:services", + name="Services", + layer="business-logic", + node_ids=["module:services"], + description="Services module.", + file_count=2, + ) + ], + request_flow=[ + RequestFlowStep( + id="service", + name="Service Layer", + type="service", + description="Business logic executes.", + details=["Transform data"], + ) ], - request_flow=[RequestFlowStep(id="service", name="Service Layer", type="service", description="Business logic executes.", details=["Transform data"])], summary=ArchitectureSummary( language="Python", framework="FastAPI", @@ -49,17 +69,48 @@ def _architecture() -> ArchitectureResponse: ) +def _dependency_provenance() -> DependencyProvenance: + return DependencyProvenance( + snapshot_id="snap_example", + snapshot_schema_version="ri.v1", + canonical_graph_hash="sha256:" + "1" * 64, + ) + + def _dependencies() -> DependencyGraphResponse: return DependencyGraphResponse( repository_id="repo-1", + repository_name="sample", + revision_kind="upload", + revision_value="sha256:" + "0" * 64, + snapshot_id="snap_example", + snapshot_schema_version="ri.v1", + canonical_graph_hash="sha256:" + "1" * 64, + manifest_digest="sha256:" + "2" * 64, + provenance=_dependency_provenance(), + generated_at="2026-07-17T00:00:00Z", nodes=[ - DependencyNode(id="dependency:npm:react", name="react", version="^18.0.0", type="production", has_vulnerabilities=False, is_outdated=False), - DependencyNode(id="dependency:npm:vite", name="vite", version="^5.0.0", type="development", has_vulnerabilities=False, is_outdated=False), + DependencyNode( + id="dep:npm:react", + name="react", + version="^18.0.0", + type="production", + ecosystem="npm", + declarations=[], + ), + DependencyNode( + id="dep:npm:vite", + name="vite", + version="^5.0.0", + type="development", + ecosystem="npm", + declarations=[], + ), ], edges=[], total_dependencies=2, - vulnerabilities=0, - outdated=0, + vulnerability_assessment=DependencyAssessment(status="not_computed"), + outdated_assessment=DependencyAssessment(status="not_computed"), ) @@ -83,13 +134,33 @@ def test_build_dependencies_document_lists_inventory(): markdown = render_markdown(document) assert "# Dependencies: sample" in markdown assert "| react | ^18.0.0 | production |" in markdown + assert "| Vulnerability assessment | Not computed |" in markdown + assert "| Outdated-version assessment | Not computed |" in markdown assert "outside the current analysis scope" in markdown # no fabricated vuln counts def test_build_dependencies_document_handles_empty_inventory(): - empty = DependencyGraphResponse(repository_id="repo-1", nodes=[], edges=[], total_dependencies=0, vulnerabilities=0, outdated=0) + empty = DependencyGraphResponse( + repository_id="repo-1", + repository_name="sample", + revision_kind="upload", + revision_value="sha256:" + "0" * 64, + snapshot_id="snap_example", + snapshot_schema_version="ri.v1", + canonical_graph_hash="sha256:" + "1" * 64, + manifest_digest="sha256:" + "2" * 64, + provenance=_dependency_provenance(), + generated_at="2026-07-17T00:00:00Z", + nodes=[], + edges=[], + total_dependencies=0, + vulnerability_assessment=DependencyAssessment(status="not_computed"), + outdated_assessment=DependencyAssessment(status="not_computed"), + ) document = build_dependencies_document(empty, "sample") markdown = render_markdown(document) assert "No dependencies were detected." in markdown + assert "| Vulnerability assessment | Not computed |" in markdown + assert "| Outdated-version assessment | Not computed |" in markdown diff --git a/apps/backend/tests/test_repositories_api.py b/apps/backend/tests/test_repositories_api.py index e95c6670..9d7b45cd 100644 --- a/apps/backend/tests/test_repositories_api.py +++ b/apps/backend/tests/test_repositories_api.py @@ -1,12 +1,14 @@ -def test_list_repositories_starts_empty(client): - response = client.get("/repositories") +from tests.api_assertions import assert_error_response + + +def test_list_repositories_starts_empty(auth_client): + response = auth_client.get("/repositories") assert response.status_code == 200 assert response.json() == {"data": [], "total": 0} -def test_github_import_rejects_non_github_url(client): - response = client.post("/repositories/github", json={"url": "https://example.com/project"}) +def test_github_import_rejects_non_github_url(auth_client): + response = auth_client.post("/repositories/github", json={"url": "https://example.com/project"}) - assert response.status_code == 422 - assert response.json()["code"] == "validation_error" + assert_error_response(response, 422, "validation_error") diff --git a/apps/backend/tests/test_repository_file_api.py b/apps/backend/tests/test_repository_file_api.py index dbfbec0f..79f1da4f 100644 --- a/apps/backend/tests/test_repository_file_api.py +++ b/apps/backend/tests/test_repository_file_api.py @@ -6,6 +6,7 @@ from app.core.config import get_settings from app.services.repository_service import MAX_FILE_PREVIEW_BYTES +from tests.api_assertions import assert_error_response def _zip_bytes(files: dict[str, bytes]) -> bytes: @@ -19,8 +20,8 @@ def _zip_bytes(files: dict[str, bytes]) -> bytes: PNG_BYTES = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR" -def _import_archive(client, files: dict[str, bytes]) -> str: - response = client.post( +def _import_archive(auth_client, files: dict[str, bytes]) -> str: + response = auth_client.post( "/repositories/upload", files={ "file": ( @@ -34,9 +35,9 @@ def _import_archive(client, files: dict[str, bytes]) -> str: return response.json()["id"] -def _import_sample(client): +def _import_sample(auth_client): return _import_archive( - client, + auth_client, { "sample/package.json": b'{"name":"sample"}', "sample/src/app.ts": b"export const answer = 42;\n", @@ -46,18 +47,18 @@ def _import_sample(client): ) -def _get_file(client, repository_id: str, path: str): - return client.get(f"/repositories/{repository_id}/file", params={"path": path}) +def _get_file(auth_client, repository_id: str, path: str): + return auth_client.get(f"/repositories/{repository_id}/file", params={"path": path}) def _repository_root(repository_id: str): return get_settings().storage_path / "repositories" / repository_id / "sample" -def test_read_text_file_returns_real_content(client): - repository_id = _import_sample(client) +def test_read_text_file_returns_real_content(auth_client): + repository_id = _import_sample(auth_client) - response = _get_file(client, repository_id, "/src/app.ts") + response = _get_file(auth_client, repository_id, "/src/app.ts") assert response.status_code == 200 body = response.json() @@ -68,10 +69,10 @@ def test_read_text_file_returns_real_content(client): assert body["size"] == len("export const answer = 42;\n") -def test_read_image_file_is_base64_encoded(client): - repository_id = _import_sample(client) +def test_read_image_file_is_base64_encoded(auth_client): + repository_id = _import_sample(auth_client) - response = _get_file(client, repository_id, "/assets/logo.png") + response = _get_file(auth_client, repository_id, "/assets/logo.png") assert response.status_code == 200 body = response.json() @@ -81,10 +82,10 @@ def test_read_image_file_is_base64_encoded(client): assert base64.b64decode(body["content"]) == PNG_BYTES -def test_read_binary_file_is_flagged_without_content(client): - repository_id = _import_sample(client) +def test_read_binary_file_is_flagged_without_content(auth_client): + repository_id = _import_sample(auth_client) - response = _get_file(client, repository_id, "/data/blob.bin") + response = _get_file(auth_client, repository_id, "/data/blob.bin") assert response.status_code == 200 body = response.json() @@ -92,11 +93,11 @@ def test_read_binary_file_is_flagged_without_content(client): assert body["content"] == "" -def test_large_text_file_preview_is_truncated(client): +def test_large_text_file_preview_is_truncated(auth_client): content = b"a" * (MAX_FILE_PREVIEW_BYTES + 17) - repository_id = _import_archive(client, {"sample/large.txt": content}) + repository_id = _import_archive(auth_client, {"sample/large.txt": content}) - response = _get_file(client, repository_id, "/large.txt") + response = _get_file(auth_client, repository_id, "/large.txt") assert response.status_code == 200 body = response.json() @@ -107,17 +108,16 @@ def test_large_text_file_preview_is_truncated(client): assert body["isImage"] is False -def test_path_traversal_is_rejected(client): - repository_id = _import_sample(client) +def test_path_traversal_is_rejected(auth_client): + repository_id = _import_sample(auth_client) - response = _get_file(client, repository_id, "/../../secret.txt") + response = _get_file(auth_client, repository_id, "/../../secret.txt") - assert response.status_code == 422 - assert response.json()["code"] == "validation_error" + assert_error_response(response, 422, "validation_error") -def test_symlink_escape_is_rejected(client): - repository_id = _import_sample(client) +def test_symlink_escape_is_rejected(auth_client): + repository_id = _import_sample(auth_client) root = _repository_root(repository_id) secret = get_settings().storage_path / "outside-secret.txt" secret.write_text("do not expose me", encoding="utf-8") @@ -128,23 +128,22 @@ def test_symlink_escape_is_rejected(client): except (NotImplementedError, OSError) as exc: pytest.skip(f"symlinks are not supported in this environment: {exc}") - response = _get_file(client, repository_id, "/src/outside-secret.txt") + response = _get_file(auth_client, repository_id, "/src/outside-secret.txt") assert response.status_code == 422 assert response.json()["code"] == "validation_error" -def test_missing_file_returns_not_found(client): - repository_id = _import_sample(client) +def test_missing_file_returns_not_found(auth_client): + repository_id = _import_sample(auth_client) - response = _get_file(client, repository_id, "/does-not-exist.ts") + response = _get_file(auth_client, repository_id, "/does-not-exist.ts") - assert response.status_code == 404 - assert response.json()["code"] == "not_found" + assert_error_response(response, 404, "not_found") -def test_file_endpoint_requires_existing_repository(client): - response = _get_file(client, "00000000-0000-0000-0000-000000000000", "/package.json") +def test_file_endpoint_requires_existing_repository(auth_client): + response = _get_file(auth_client, "00000000-0000-0000-0000-000000000000", "/package.json") assert response.status_code == 404 assert response.json()["code"] == "not_found" diff --git a/apps/backend/tests/test_repository_insights.py b/apps/backend/tests/test_repository_insights.py new file mode 100644 index 00000000..14c70709 --- /dev/null +++ b/apps/backend/tests/test_repository_insights.py @@ -0,0 +1,175 @@ +"""Authentic repository-insights.v1 endpoint tests (#154).""" + +from __future__ import annotations + +import io +import zipfile + +from app.models.repository import RepositoryRecord + +from tests.analysis_helpers import run_analysis_jobs + +_SOURCES = { + "README.md": b"# insights fixture\n", + "package.json": b'{"dependencies":{"react":"18.3.0"}}\n', + "src/index.ts": ( + b"import { helper } from './helper';\n" + b"import { absent } from './absent';\n" + b"export const value = helper() + absent();\n" + ), + "src/helper.ts": b"export function helper() { return 1; }\n", +} + + +def _archive(files: dict[str, bytes]) -> bytes: + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as archive: + for path, content in files.items(): + archive.writestr(path, content) + return buffer.getvalue() + + +def _upload(auth_client, files: dict[str, bytes] = _SOURCES, *, analyse: bool = True) -> dict: + response = auth_client.post( + "/repositories/upload", + files={"file": ("insights.zip", _archive(files), "application/zip")}, + ) + assert response.status_code == 201, response.text + repository = response.json() + if analyse: + assert auth_client.post(f"/analysis/{repository['id']}/start").status_code == 200 + assert run_analysis_jobs() == 1 + return repository + + +def test_insights_requires_authentication(client): + response = client.get("/analysis/11111111-1111-1111-1111-111111111111/insights") + assert response.status_code == 401 + + +def test_insights_is_owner_scoped(auth_client, make_auth_headers): + repository = _upload(auth_client) + intruder = make_auth_headers("insights-intruder@example.com") + + response = auth_client.get( + f"/analysis/{repository['id']}/insights", + headers=intruder["headers"], + ) + + assert response.status_code == 404 + + +def test_insights_without_a_sealed_snapshot_returns_404(auth_client): + repository = _upload(auth_client, analyse=False) + assert auth_client.get(f"/analysis/{repository['id']}/insights").status_code == 404 + + +def test_insights_identity_definitions_ratios_and_order_are_deterministic(auth_client): + repository = _upload(auth_client) + + first = auth_client.get(f"/analysis/{repository['id']}/insights") + second = auth_client.get(f"/analysis/{repository['id']}/insights") + + assert first.status_code == 200, first.text + assert first.json() == second.json() + body = first.json() + assert body["schemaVersion"] == "repository-insights.v1" + assert body["repositoryId"] == repository["id"] + assert body["revisionValue"] == repository["revision"]["value"] + assert body["snapshotSchemaVersion"] == "ri.v1" + assert body["provenance"]["source"] == "ri.v1" + assert body["canonicalGraphHash"].startswith("sha256:") + assert body["manifestDigest"].startswith("sha256:") + assert body["changeOverTime"] == { + "assessmentState": "not_assessed", + "message": "Change-over-time insights are not available yet.", + } + ids = [metric["id"] for metric in body["metrics"]] + assert len(ids) == len(set(ids)) + assert all(metric["definition"] for metric in body["metrics"]) + assert all(metric["snapshotId"] == body["snapshotId"] for metric in body["metrics"]) + coverage = next(metric for metric in body["metrics"] if metric["id"] == "extraction.coverage") + assert coverage["numerator"] is not None + assert coverage["denominator"] is not None + assert coverage["denominator"] > 0 + assert coverage["value"] == coverage["numerator"] / coverage["denominator"] + + +def test_seeded_insights_totals_are_exact_and_not_scores(auth_client): + repository = _upload(auth_client) + body = auth_client.get(f"/analysis/{repository['id']}/insights").json() + metrics = {metric["id"]: metric for metric in body["metrics"]} + + # These totals are fixed by the four-file fixture and the TypeScript plus + # manifest extractors; drift requires an intentional contract update. + assert metrics["nodes.files.total"]["value"] == 4 + assert metrics["nodes.dependencies.total"]["value"] == 1 + # ./absent (relative import) and absent() (bound to it) are genuine in-repo + # gaps; nothing in this fixture references external code. + assert metrics["diagnostics.relationships.unresolved"]["value"] >= 1 + assert metrics["diagnostics.relationships.external-references"]["value"] == 0 + assert metrics["evidence.records.total"]["value"] > 0 + assert metrics["assessment.vulnerability-scanning"]["value"] is None + assert metrics["assessment.vulnerability-scanning"]["assessmentState"] == "not_assessed" + prohibited = { + "health", + "quality", + "maintainability", + "productivity", + "velocity", + "security rating", + "technical debt", + "predicted effort", + "top risk", + } + labels = " ".join(metric["label"].lower() for metric in body["metrics"]) + assert all(term not in labels for term in prohibited) + + +def test_unresolved_relationships_split_external_refs_from_in_repo_gaps(auth_client): + files = { + "README.md": b"# split fixture\n", + "package.json": b'{"dependencies":{"react":"18.3.0"}}\n', + "src/app.ts": ( + b"import { useState } from 'react';\n" + b"import { helper } from './helper';\n" + b"export const value = useState() + helper() + missingLocal();\n" + ), + "src/helper.ts": b"export function helper() { return 1; }\n", + } + repository = _upload(auth_client, files) + body = auth_client.get(f"/analysis/{repository['id']}/insights").json() + metrics = {metric["id"]: metric for metric in body["metrics"]} + by_code = {item["key"]: item["value"] for item in body["diagnosticsByCode"]} + + external = metrics["diagnostics.relationships.external-references"]["value"] + in_repo = metrics["diagnostics.relationships.unresolved"]["value"] + + # useState() is bound to the declared 'react' dependency -> external. + assert external >= 1 + # missingLocal() has no binding and is not a platform name -> genuine gap. + assert in_repo >= 1 + # Every raw RI-RES-UNRESOLVED diagnostic lands in exactly one bucket. + assert external + in_repo == by_code["RI-RES-UNRESOLVED"] + + +def test_insights_never_falls_back_to_mutable_legacy_metadata(auth_client): + repository = _upload(auth_client) + endpoint = f"/analysis/{repository['id']}/insights" + before = auth_client.get(endpoint).json() + + from app.core.database import SessionLocal + + with SessionLocal() as session: + record = session.get(RepositoryRecord, repository["id"]) + assert record is not None + metadata = dict(record.repo_metadata or {}) + metadata["intelligence"] = { + "files": [{"path": "fabricated.py"}], + "healthScore": 100, + "diagnostics": [{"code": "FABRICATED"}], + } + record.repo_metadata = metadata + session.commit() + + assert auth_client.get(endpoint).json() == before diff --git a/apps/backend/tests/test_repository_intelligence.py b/apps/backend/tests/test_repository_intelligence.py deleted file mode 100644 index e98e6556..00000000 --- a/apps/backend/tests/test_repository_intelligence.py +++ /dev/null @@ -1,109 +0,0 @@ -from datetime import UTC, datetime -from pathlib import Path - -from app.analysis.architecture import ArchitectureAnalyzer -from app.graph.dependency_graph import DependencyGraphBuilder -from app.intelligence.engine import RepositoryIntelligenceEngine -from app.models.repository import RepositoryRecord -from app.parsers.repository_parser import RepositoryParser -from app.review.review_service import EngineeringReviewBuilder - - -def _sample_repository(root: Path) -> None: - (root / "src" / "services").mkdir(parents=True) - (root / "src" / "api").mkdir(parents=True) - (root / "src" / "models").mkdir(parents=True) - (root / "tests").mkdir() - (root / ".github" / "workflows").mkdir(parents=True) - (root / "package.json").write_text( - '{"dependencies":{"react":"^18.0.0","@tanstack/react-query":"^5.0.0"},"devDependencies":{"vite":"^5.0.0"}}', - encoding="utf-8", - ) - (root / "src" / "main.tsx").write_text("import React from 'react';\nexport function App() { return null; }", encoding="utf-8") - (root / "src" / "api" / "routes.ts").write_text( - "import { UserService } from '../services/user-service';\nrouter.get('/users', handler);\nexport const routes = [];", - encoding="utf-8", - ) - (root / "src" / "services" / "user-service.ts").write_text( - "import { User } from '../models/user';\nexport class UserService { list() { return []; } }", - encoding="utf-8", - ) - (root / "src" / "models" / "user.ts").write_text("export interface User { id: string }", encoding="utf-8") - (root / "tests" / "user-service.test.ts").write_text("export const ok = true;", encoding="utf-8") - (root / ".github" / "workflows" / "ci.yml").write_text("name: CI", encoding="utf-8") - (root / "README.md").write_text("# Sample", encoding="utf-8") - (root / "LICENSE").write_text("Apache License\nVersion 2.0", encoding="utf-8") - - -def _build_intelligence(root: Path): - tree, meta, total_size = RepositoryParser().parse(root) - return tree, meta, total_size, RepositoryIntelligenceEngine().build("repo-1", "sample", root, tree, meta, total_size) - - -def _record(root: Path, intelligence) -> RepositoryRecord: - metadata = intelligence.metadata.model_dump(mode="json", by_alias=True) - metadata["intelligence"] = intelligence.model_dump(mode="json", by_alias=True) - return RepositoryRecord( - id="repo-1", - name="sample", - source="upload", - local_path=str(root), - size=intelligence.discovery.statistics.total_size, - file_count=intelligence.discovery.statistics.total_files, - status="completed", - data_source="real", - analysis_stage="completed", - analysis_progress=100, - uploaded_at=datetime.now(UTC), - analysed_at=datetime.now(UTC), - repo_metadata=metadata, - file_tree=[], - ) - - -def test_repository_intelligence_detects_discovery_and_source_code(tmp_path: Path): - _sample_repository(tmp_path) - _, _, _, intelligence = _build_intelligence(tmp_path) - - assert intelligence.discovery.primary_language == "TypeScript" - assert "React" in intelligence.discovery.frameworks - assert "npm" in intelligence.discovery.package_managers - assert "Vite" in intelligence.discovery.build_systems - assert intelligence.discovery.ci_files == [".github/workflows/ci.yml"] - assert intelligence.discovery.statistics.test_files == 1 - assert any(module.id == "module:services" for module in intelligence.modules) - assert any(file.role == "route" and "/users" in file.api_routes for file in intelligence.files) - assert any(symbol.name == "UserService" and symbol.kind == "class" for symbol in intelligence.symbols) - assert any(dependency.name == "react" for dependency in intelligence.dependencies) - assert any(relationship.type == "contains" for relationship in intelligence.graph.relationships) - assert any(relationship.type == "imports" for relationship in intelligence.graph.relationships) - assert any(relationship.type == "depends_on" for relationship in intelligence.graph.relationships) - - -def test_repository_intelligence_is_serializable_and_persisted(tmp_path: Path): - _sample_repository(tmp_path) - _, _, _, intelligence = _build_intelligence(tmp_path) - record = _record(tmp_path, intelligence) - engine = RepositoryIntelligenceEngine() - - loaded = engine.load(record) - - assert loaded is not None - assert loaded.repository_id == "repo-1" - assert loaded.graph.nodes - assert loaded.model_dump(mode="json", by_alias=True)["graph"]["nodes"] - - -def test_feature_consumers_read_repository_intelligence(tmp_path: Path): - _sample_repository(tmp_path) - _, _, _, intelligence = _build_intelligence(tmp_path) - record = _record(tmp_path, intelligence) - - architecture = ArchitectureAnalyzer().build_architecture(record) - dependencies = DependencyGraphBuilder().build(record) - review = EngineeringReviewBuilder().build(record) - - assert architecture.summary.language == "TypeScript" - assert any(node.id == "module:services" for node in architecture.nodes) - assert any(node.name == "react" for node in dependencies.nodes) - assert review.summary.total_findings >= 1 diff --git a/apps/backend/tests/test_repository_lineage_api.py b/apps/backend/tests/test_repository_lineage_api.py new file mode 100644 index 00000000..5c5b2a78 --- /dev/null +++ b/apps/backend/tests/test_repository_lineage_api.py @@ -0,0 +1,131 @@ +"""HTTP-level coverage for `GET /repositories/{id}/lineage` (#299, RFC-0002; #400). + +Lineage allocation itself (sequence numbers, canonical-key grouping, the +duplicate-commit and concurrency cases) is already covered by +test_repository_lineage_service.py and test_repository_lineage_concurrency.py. +This file is scoped to the read surface: what the endpoint returns for a +standalone import, for each member of a real multi-import lineage, and that +it stays owner-scoped like every other repository route. +""" + +import uuid +from pathlib import Path + +import pytest + +from app.github.client import GitHubClient + + +def _fake_clone(_: GitHubClient, __: str, destination: Path, ___: str | None = None) -> None: + destination.mkdir(parents=True, exist_ok=True) + (destination / "README.md").write_text("# demo\n", encoding="utf-8") + + +def _mock_github(monkeypatch: pytest.MonkeyPatch, commits: list[str], ref: str = "refs/heads/main") -> None: + commit_iter = iter(commits) + monkeypatch.setattr(GitHubClient, "clone_public_repository", _fake_clone) + monkeypatch.setattr(GitHubClient, "read_head_commit", lambda *_: next(commit_iter)) + monkeypatch.setattr(GitHubClient, "read_head_ref", lambda *_: ref) + + +def _seed_upload(owner_id: str, name: str = "standalone-repo") -> str: + from app.core.database import SessionLocal + from app.models.repository import RepositoryRecord + + db = SessionLocal() + try: + repository_id = str(uuid.uuid4()) + db.add( + RepositoryRecord( + id=repository_id, + owner_id=owner_id, + name=name, + source="upload", + local_path=f"/tmp/{repository_id}", + status="completed", + ) + ) + db.commit() + return repository_id + finally: + db.close() + + +def test_standalone_upload_has_no_lineage_and_lists_only_itself(auth_client): + repository_id = _seed_upload(auth_client.default_user["id"]) + + response = auth_client.get(f"/repositories/{repository_id}/lineage") + + assert response.status_code == 200, response.text + body = response.json() + assert body["isLineaged"] is False + assert body["lineageId"] is None + assert body["canonicalSourceKey"] is None + assert body["canonicalBranch"] is None + assert [entry["repositoryId"] for entry in body["entries"]] == [repository_id] + assert body["entries"][0]["isCurrent"] is True + assert body["entries"][0]["sequence"] is None + + +def test_lineaged_repository_lists_every_member_most_recent_first(auth_client, monkeypatch: pytest.MonkeyPatch): + _mock_github(monkeypatch, ["a" * 40, "b" * 40, "c" * 40]) + + first = auth_client.post("/repositories/github", json={"url": "https://github.com/acme/widgets"}) + second = auth_client.post("/repositories/github", json={"url": "https://github.com/acme/widgets"}) + third = auth_client.post("/repositories/github", json={"url": "https://github.com/acme/widgets"}) + assert first.status_code == second.status_code == third.status_code == 201 + + response = auth_client.get(f"/repositories/{third.json()['id']}/lineage") + + assert response.status_code == 200, response.text + body = response.json() + assert body["isLineaged"] is True + assert body["canonicalSourceKey"] == "github.com/acme/widgets" + assert body["canonicalBranch"] == "refs/heads/main" + + entries = body["entries"] + assert [entry["repositoryId"] for entry in entries] == [ + third.json()["id"], + second.json()["id"], + first.json()["id"], + ] + assert [entry["sequence"] for entry in entries] == [3, 2, 1] + assert [entry["isCurrent"] for entry in entries] == [True, False, False] + assert entries[0]["revision"]["value"] == "c" * 40 + assert entries[2]["revision"]["value"] == "a" * 40 + + +def test_lineaged_repository_read_from_an_older_member_flips_which_entry_is_current( + auth_client, monkeypatch: pytest.MonkeyPatch +): + _mock_github(monkeypatch, ["a" * 40, "b" * 40]) + + first = auth_client.post("/repositories/github", json={"url": "https://github.com/acme/widgets"}) + second = auth_client.post("/repositories/github", json={"url": "https://github.com/acme/widgets"}) + assert first.status_code == second.status_code == 201 + + response = auth_client.get(f"/repositories/{first.json()['id']}/lineage") + + assert response.status_code == 200, response.text + body = response.json() + entries = {entry["repositoryId"]: entry for entry in body["entries"]} + assert len(entries) == 2 + assert entries[first.json()["id"]]["isCurrent"] is True + assert entries[second.json()["id"]]["isCurrent"] is False + + +def test_lineage_endpoint_returns_404_for_another_owners_repository(client, make_auth_headers): + alice = make_auth_headers("alice@example.com") + bob = make_auth_headers("bob@example.com") + repository_id = _seed_upload(alice["user"]["id"]) + + denied = client.get(f"/repositories/{repository_id}/lineage", headers=bob["headers"]) + assert denied.status_code == 404 + + allowed = client.get(f"/repositories/{repository_id}/lineage", headers=alice["headers"]) + assert allowed.status_code == 200 + + +def test_lineage_endpoint_returns_404_for_a_nonexistent_repository(auth_client): + response = auth_client.get(f"/repositories/{uuid.uuid4()}/lineage") + assert response.status_code == 404 diff --git a/apps/backend/tests/test_repository_lineage_concurrency.py b/apps/backend/tests/test_repository_lineage_concurrency.py new file mode 100644 index 00000000..6a2f0d50 --- /dev/null +++ b/apps/backend/tests/test_repository_lineage_concurrency.py @@ -0,0 +1,456 @@ +"""Real-PostgreSQL concurrency and integrity coverage for #299 (RFC-0002). + +Per the migration plan's own §13/§14: "SQLite tests prove migration +portability, constraint reflection, and serialized-writer behavior. They +cannot prove PostgreSQL row-lock behavior, transaction isolation, +partial-index semantics, or concurrent create reconciliation." Every test in +this file runs against a real, separate-connection PostgreSQL database using +threading.Barrier synchronization -- not thread timing or sleeps -- matching +the existing `test_concurrent_refresh_on_postgres_mints_one_successor` +pattern in test_auth_concurrency.py. This whole file skips without +PARTHA_TEST_PG_URL. +""" + +import os +import threading +import uuid +from datetime import UTC, datetime + +import pytest +from sqlalchemy import create_engine, select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import sessionmaker + +PG_URL = os.environ.get("PARTHA_TEST_PG_URL") + +pytestmark = pytest.mark.skipif( + not PG_URL, reason="set PARTHA_TEST_PG_URL to run the Postgres lineage concurrency tests" +) + + +def _make_session_factory(): + from app.models.base import Base + + engine = create_engine(PG_URL) + Base.metadata.create_all(engine) + return engine, sessionmaker(bind=engine, expire_on_commit=False) + + +def _make_user(session, email: str | None = None) -> str: + from app.models.user import User + + user = User(id=str(uuid.uuid4()), email=email or f"lineage-{uuid.uuid4().hex}@example.com") + session.add(user) + session.commit() + return user.id + + +def _repository_record(owner_id: str, revision_value: str, **overrides) -> "RepositoryRecord": # noqa: F821 + from app.models.repository import RepositoryRecord + + base = dict( + id=str(uuid.uuid4()), + owner_id=owner_id, + name="widgets", + source="github", + source_url="https://github.com/acme/widgets", + branch="main", + revision_kind="git", + revision_value=revision_value, + revision_ref="refs/heads/main", + local_path="/tmp/x", + status="analysing", + ) + base.update(overrides) + return RepositoryRecord(**base) + + +def test_concurrent_imports_into_an_existing_lineage_get_unique_consecutive_sequences(): + """Two different commits, same canonical pair, an already-existing + lineage: both must serialize on the atomic counter update and receive + distinct, consecutive sequences -- never the same one.""" + from app.repositories.repository_repository import RepositoryRepository + + engine, Session = _make_session_factory() + setup = Session() + try: + owner_id = _make_user(setup) + finally: + setup.close() + + results: list[int] = [] + errors: list[BaseException] = [] + results_lock = threading.Lock() + start = threading.Barrier(2) + + def worker(revision_value: str) -> None: + session = Session() + try: + start.wait(timeout=10) + persisted = RepositoryRepository(session).add_with_lineage( + _repository_record(owner_id, revision_value), + owner_id=owner_id, + canonical_source_key="github.com/acme/widgets", + canonical_branch="refs/heads/main", + display_name="widgets", + ) + with results_lock: + results.append(persisted.sequence) + except BaseException as exc: # noqa: BLE001 -- captured for the assertion below, not swallowed + with results_lock: + errors.append(exc) + finally: + session.close() + + threads = [threading.Thread(target=worker, args=(sha,)) for sha in ("a" * 40, "b" * 40)] + for thread in threads: + thread.start() + for thread in threads: + thread.join(timeout=15) + + try: + assert not [t for t in threads if t.is_alive()], "a worker thread did not finish within the timeout" + assert not errors, errors + assert sorted(results) == [1, 2], results + + verify = Session() + try: + from app.models.repository_lineage import RepositoryLineage + + lineage = verify.scalars(select(RepositoryLineage).where(RepositoryLineage.owner_id == owner_id)).one() + assert lineage.next_sequence == 3 + finally: + verify.close() + finally: + _cleanup_owner(Session, owner_id) + engine.dispose() + + +def test_two_first_imports_racing_create_exactly_one_lineage(): + """No lineage exists yet for this canonical pair; two callers race to + create it. The canonical partial unique index must let exactly one win; + the loser reloads the winner's lineage and retries, ending with one + lineage and two distinct sequences -- never two lineages.""" + from app.repositories.repository_repository import RepositoryRepository + + engine, Session = _make_session_factory() + setup = Session() + try: + owner_id = _make_user(setup) + finally: + setup.close() + + results: list[int] = [] + errors: list[BaseException] = [] + results_lock = threading.Lock() + start = threading.Barrier(2) + + def worker(revision_value: str) -> None: + session = Session() + try: + start.wait(timeout=10) + persisted = RepositoryRepository(session).add_with_lineage( + _repository_record(owner_id, revision_value), + owner_id=owner_id, + canonical_source_key="github.com/acme/first-race", + canonical_branch="refs/heads/main", + display_name="first-race", + ) + with results_lock: + results.append(persisted.sequence) + except BaseException as exc: # noqa: BLE001 + with results_lock: + errors.append(exc) + finally: + session.close() + + threads = [threading.Thread(target=worker, args=(sha,)) for sha in ("c" * 40, "d" * 40)] + for thread in threads: + thread.start() + for thread in threads: + thread.join(timeout=15) + + try: + assert not [t for t in threads if t.is_alive()], "a worker thread did not finish within the timeout" + assert not errors, errors + assert sorted(results) == [1, 2], results + + verify = Session() + try: + from app.models.repository_lineage import RepositoryLineage + + lineages = verify.scalars( + select(RepositoryLineage).where( + RepositoryLineage.owner_id == owner_id, + RepositoryLineage.canonical_source_key == "github.com/acme/first-race", + ) + ).all() + assert len(lineages) == 1, f"expected exactly one lineage, found {len(lineages)}" + finally: + verify.close() + finally: + _cleanup_owner(Session, owner_id) + engine.dispose() + + +def test_two_identical_commits_racing_produce_one_repository_and_one_conflict(): + """The exact same commit imported twice, concurrently: the loser must + observe the duplicate after serialization and be rejected -- never a + second repository row, and never a wasted/skipped sequence number.""" + from app.repositories.repository_repository import LineageDuplicateRevision, RepositoryRepository + + engine, Session = _make_session_factory() + setup = Session() + try: + owner_id = _make_user(setup) + finally: + setup.close() + + outcomes: list[str] = [] + outcomes_lock = threading.Lock() + start = threading.Barrier(2) + shared_commit = "e" * 40 + + def worker() -> None: + session = Session() + outcome = "error" + try: + start.wait(timeout=10) + RepositoryRepository(session).add_with_lineage( + _repository_record(owner_id, shared_commit, id=str(uuid.uuid4())), + owner_id=owner_id, + canonical_source_key="github.com/acme/identical-race", + canonical_branch="refs/heads/main", + display_name="identical-race", + ) + outcome = "ok" + except LineageDuplicateRevision: + outcome = "rejected" + finally: + session.close() + with outcomes_lock: + outcomes.append(outcome) + + threads = [threading.Thread(target=worker) for _ in range(2)] + for thread in threads: + thread.start() + for thread in threads: + thread.join(timeout=15) + + try: + assert not [t for t in threads if t.is_alive()], "a worker thread did not finish within the timeout" + assert sorted(outcomes) == ["ok", "rejected"], outcomes + + verify = Session() + try: + from app.models.repository import RepositoryRecord + from app.models.repository_lineage import RepositoryLineage + + repos = verify.scalars( + select(RepositoryRecord).where( + RepositoryRecord.owner_id == owner_id, RepositoryRecord.revision_value == shared_commit + ) + ).all() + assert len(repos) == 1, "exactly one repository row must exist for the winning commit" + lineage = verify.scalars( + select(RepositoryLineage).where( + RepositoryLineage.owner_id == owner_id, + RepositoryLineage.canonical_source_key == "github.com/acme/identical-race", + ) + ).one() + assert lineage.next_sequence == 2, "the rejected duplicate must not have burned a sequence number" + finally: + verify.close() + finally: + _cleanup_owner(Session, owner_id) + engine.dispose() + + +def test_forced_duplicate_lineage_sequence_pair_is_rejected(): + """Direct proof of the `uq_repositories_lineage_sequence` constraint on + real Postgres, bypassing the allocator entirely.""" + from app.models.repository_lineage import RepositoryLineage + + engine, Session = _make_session_factory() + session = Session() + owner_id = None + try: + owner_id = _make_user(session) + lineage = RepositoryLineage( + id=str(uuid.uuid4()), + owner_id=owner_id, + canonical_source_key="github.com/acme/forced-dup", + canonical_branch="refs/heads/main", + display_name="forced-dup", + latest_repository_id=None, + next_sequence=2, + created_at=datetime.now(UTC), + ) + session.add(lineage) + session.add(_repository_record(owner_id, "f" * 40, lineage_id=lineage.id, sequence=1)) + session.commit() + + session.add(_repository_record(owner_id, "1" * 40, lineage_id=lineage.id, sequence=1)) + with pytest.raises(IntegrityError): + session.commit() + session.rollback() + finally: + session.close() + if owner_id: + _cleanup_owner(Session, owner_id) + engine.dispose() + + +def test_cross_owner_composite_membership_is_rejected_on_real_postgres(): + """Direct proof of `fk_repositories_lineage_owner` on real Postgres: a + repository can never attach to a lineage owned by a different user, even + with a forced direct write that bypasses the service layer.""" + from app.models.repository_lineage import RepositoryLineage + + engine, Session = _make_session_factory() + session = Session() + owner_id = None + other_owner_id = None + try: + owner_id = _make_user(session) + other_owner_id = _make_user(session) + lineage = RepositoryLineage( + id=str(uuid.uuid4()), + owner_id=other_owner_id, + canonical_source_key="github.com/acme/cross-owner", + canonical_branch="refs/heads/main", + display_name="cross-owner", + latest_repository_id=None, + next_sequence=1, + created_at=datetime.now(UTC), + ) + session.add(lineage) + session.commit() + + session.add(_repository_record(owner_id, "2" * 40, lineage_id=lineage.id, sequence=1)) + with pytest.raises(IntegrityError): + session.commit() + session.rollback() + finally: + session.close() + for cleanup_owner in (owner_id, other_owner_id): + if cleanup_owner: + _cleanup_owner(Session, cleanup_owner) + engine.dispose() + + +def test_cross_lineage_latest_pointer_is_rejected_on_real_postgres(): + """Direct proof of `fk_repository_lineages_latest_member` on real + Postgres: a lineage's latest pointer can never name a repository that + actually belongs to a different lineage.""" + from app.models.repository_lineage import RepositoryLineage + + engine, Session = _make_session_factory() + session = Session() + owner_id = None + try: + owner_id = _make_user(session) + lineage_a = RepositoryLineage( + id=str(uuid.uuid4()), + owner_id=owner_id, + canonical_source_key="github.com/acme/lineage-a", + canonical_branch="refs/heads/main", + display_name="lineage-a", + latest_repository_id=None, + next_sequence=2, + created_at=datetime.now(UTC), + ) + lineage_b = RepositoryLineage( + id=str(uuid.uuid4()), + owner_id=owner_id, + canonical_source_key="github.com/acme/lineage-b", + canonical_branch="refs/heads/main", + display_name="lineage-b", + latest_repository_id=None, + next_sequence=2, + created_at=datetime.now(UTC), + ) + session.add_all([lineage_a, lineage_b]) + record_in_b = _repository_record(owner_id, "3" * 40, lineage_id=lineage_b.id, sequence=1) + session.add(record_in_b) + session.commit() + + # Point lineage_a's latest at a repository that actually belongs to + # lineage_b -- must be rejected. + lineage_a.latest_repository_id = record_in_b.id + with pytest.raises(IntegrityError): + session.commit() + session.rollback() + finally: + session.close() + if owner_id: + _cleanup_owner(Session, owner_id) + engine.dispose() + + +def test_account_deletion_cascades_lineages_for_that_owner_only(): + from app.models.repository_lineage import RepositoryLineage + from app.models.user import User + + engine, Session = _make_session_factory() + session = Session() + owner_id = None + other_owner_id = None + try: + owner_id = _make_user(session) + other_owner_id = _make_user(session) + session.add( + RepositoryLineage( + id=str(uuid.uuid4()), + owner_id=owner_id, + canonical_source_key="github.com/acme/deleted-owner", + canonical_branch="refs/heads/main", + display_name="deleted-owner", + latest_repository_id=None, + next_sequence=1, + created_at=datetime.now(UTC), + ) + ) + other_lineage_id = str(uuid.uuid4()) + session.add( + RepositoryLineage( + id=other_lineage_id, + owner_id=other_owner_id, + canonical_source_key="github.com/acme/surviving-owner", + canonical_branch="refs/heads/main", + display_name="surviving-owner", + latest_repository_id=None, + next_sequence=1, + created_at=datetime.now(UTC), + ) + ) + session.commit() + + session.delete(session.get(User, owner_id)) + session.commit() + + remaining = session.scalars(select(RepositoryLineage.id)).all() + assert other_lineage_id in remaining + assert not any( + True for _ in session.scalars(select(RepositoryLineage).where(RepositoryLineage.owner_id == owner_id)) + ) + finally: + session.close() + if other_owner_id: + _cleanup_owner(Session, other_owner_id) + engine.dispose() + + +def _cleanup_owner(Session, owner_id: str) -> None: + from app.models.repository import RepositoryRecord + from app.models.repository_lineage import RepositoryLineage + from app.models.user import User + + cleanup = Session() + try: + cleanup.query(RepositoryRecord).filter(RepositoryRecord.owner_id == owner_id).delete() + cleanup.query(RepositoryLineage).filter(RepositoryLineage.owner_id == owner_id).delete() + cleanup.query(User).filter(User.id == owner_id).delete() + cleanup.commit() + finally: + cleanup.close() diff --git a/apps/backend/tests/test_repository_lineage_migration.py b/apps/backend/tests/test_repository_lineage_migration.py new file mode 100644 index 00000000..fdd4b261 --- /dev/null +++ b/apps/backend/tests/test_repository_lineage_migration.py @@ -0,0 +1,521 @@ +"""Migration-level coverage for #299 (RFC-0002), revisions 0013/0014. + +Covers the plan's §13 "Migration tests" matrix: fresh empty DB shape, a +populated DB with the exact grouping/exclusion cases the backfill must get +right, deterministic tie-breaks, idempotent rerun, and full downgrade/ +upgrade round trips on both SQLite and (when ``PARTHA_TEST_PG_URL`` is set) +real PostgreSQL. +""" + +import importlib.util +import os +import uuid +from datetime import UTC, datetime, timedelta +from types import ModuleType + +import pytest +from alembic import command +from alembic.config import Config +from alembic.migration import MigrationContext +from alembic.operations import Operations +from sqlalchemy import MetaData, Table, create_engine, inspect, select +from sqlalchemy.engine import make_url + +from pathlib import Path + +BACKEND_ROOT = Path(__file__).resolve().parents[1] +PG_URL = os.environ.get("PARTHA_TEST_PG_URL") + + +def _alembic_config() -> Config: + cfg = Config(str(BACKEND_ROOT / "alembic.ini")) + cfg.set_main_option("script_location", str(BACKEND_ROOT / "alembic")) + return cfg + + +def _load_migration_module(revision_filename: str) -> ModuleType: + """Load a migration script's own module so its private helper functions + (e.g. ``_backfill_lineages``) can be called directly in a test, the same + way Alembic itself loads and executes it -- not via a package import, + since revision modules are not importable by their filename (it starts + with a digit) and are never meant to be imported by application code.""" + path = BACKEND_ROOT / "alembic" / "versions" / f"{revision_filename}.py" + spec = importlib.util.spec_from_file_location(revision_filename, path) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def _database_url(tmp_path) -> str: + if not PG_URL: + return f"sqlite:///{tmp_path / 'lineage-migration.db'}" + admin_url = make_url(PG_URL) + database_name = f"partha_lineage_migration_{uuid.uuid4().hex}" + admin_engine = create_engine(admin_url, isolation_level="AUTOCOMMIT") + try: + with admin_engine.connect() as connection: + connection.exec_driver_sql(f'CREATE DATABASE "{database_name}"') + finally: + admin_engine.dispose() + return admin_url.set(database=database_name).render_as_string(hide_password=False) + + +def _drop_pg_database(database_url: str) -> None: + if not PG_URL: + return + admin_url = make_url(PG_URL) + target = make_url(database_url) + admin_engine = create_engine(admin_url, isolation_level="AUTOCOMMIT") + try: + with admin_engine.connect() as connection: + connection.exec_driver_sql(f'DROP DATABASE IF EXISTS "{target.database}" WITH (FORCE)') + finally: + admin_engine.dispose() + + +@pytest.fixture() +def lineage_migration_db(tmp_path, monkeypatch): + # Import for its side effect: registers the `PRAGMA foreign_keys=ON` + # connect-event listener on the SQLAlchemy Engine class globally (see + # app/core/database.py). Without this import having already happened + # somewhere in the process, SQLite silently never enforces any foreign + # key at all -- these migration tests would then "pass" while proving + # nothing about the cyclic FK's actual behavior, exactly as happened + # once during development (caught only by incidental test-file + # ordering in a full-suite run, not by this file in isolation). Forcing + # it here makes that guarantee deterministic instead of accidental. + import app.core.database # noqa: F401 + + database_url = _database_url(tmp_path) + monkeypatch.setenv("DATABASE_URL", database_url) + monkeypatch.setenv("CORS_ORIGINS", "http://testserver") + from app.core import config + + config.get_settings.cache_clear() + engine = create_engine(database_url) + try: + yield database_url, engine + finally: + engine.dispose() + config.get_settings.cache_clear() + _drop_pg_database(database_url) + + +def test_fresh_database_reaches_head_with_the_expected_lineage_shape(lineage_migration_db): + database_url, engine = lineage_migration_db + cfg = _alembic_config() + + command.upgrade(cfg, "head") + + insp = inspect(engine) + assert "repository_lineages" in insp.get_table_names() + + lineage_columns = {column["name"] for column in insp.get_columns("repository_lineages")} + assert lineage_columns == { + "id", + "owner_id", + "canonical_source_key", + "canonical_branch", + "display_name", + "latest_repository_id", + "next_sequence", + "created_at", + } + + repo_columns = {column["name"] for column in insp.get_columns("repositories")} + assert {"lineage_id", "sequence"} <= repo_columns + + repo_fk_names = {fk["name"] for fk in insp.get_foreign_keys("repositories")} + assert "fk_repositories_lineage_owner" in repo_fk_names + lineage_fk_names = {fk["name"] for fk in insp.get_foreign_keys("repository_lineages")} + assert {"fk_repository_lineages_owner_id_users", "fk_repository_lineages_latest_member"} <= lineage_fk_names + + latest_member_fk = next( + fk + for fk in insp.get_foreign_keys("repository_lineages") + if fk["name"] == "fk_repository_lineages_latest_member" + ) + assert latest_member_fk["constrained_columns"] == ["latest_repository_id", "id"] + assert latest_member_fk["referred_columns"] == ["id", "lineage_id"] + assert latest_member_fk["options"].get("deferrable") is True + + repo_uk_names = {uk["name"] for uk in insp.get_unique_constraints("repositories")} + assert {"uq_repositories_lineage_sequence", "uq_repositories_id_lineage"} <= repo_uk_names + + index_names = {index["name"] for index in insp.get_indexes("repository_lineages")} + assert "uq_repository_lineages_owner_source_branch" in index_names + assert "ix_repository_lineages_owner_id" in index_names + + command.downgrade(cfg, "base") + assert "repository_lineages" not in inspect(engine).get_table_names() + + command.upgrade(cfg, "head") + assert "repository_lineages" in inspect(engine).get_table_names() + + +def _seed_users_and_repositories(engine, rows: list[dict]) -> tuple[str, str]: + """Insert two users and the given legacy-shaped repository rows at 0012, + before 0013/0014 exist. Returns (owner_a, owner_b).""" + meta = MetaData() + users = Table("users", meta, autoload_with=engine) + repositories = Table("repositories", meta, autoload_with=engine) + + owner_a = str(uuid.uuid4()) + owner_b = str(uuid.uuid4()) + now = datetime.now(UTC) + with engine.begin() as connection: + connection.execute( + users.insert(), + [ + { + "id": owner_a, + "email": f"a-{uuid.uuid4().hex}@example.com", + "is_active": True, + "created_at": now, + "updated_at": now, + "password_hash": None, + }, + { + "id": owner_b, + "email": f"b-{uuid.uuid4().hex}@example.com", + "is_active": True, + "created_at": now, + "updated_at": now, + "password_hash": None, + }, + ], + ) + if rows: + connection.execute(repositories.insert(), rows) + return owner_a, owner_b + + +def _repo_row(owner_id: str, **overrides) -> dict: + now = datetime.now(UTC) + base = dict( + id=str(uuid.uuid4()), + owner_id=owner_id, + name="demo", + description=None, + source="github", + source_url="https://github.com/acme/widgets", + branch="main", + local_path="/x", + size=0, + file_count=1, + status="completed", + analysis_stage=None, + analysis_progress=100, + uploaded_at=now, + analysed_at=now, + error_message=None, + repo_metadata=None, + file_tree=[], + created_at=now, + updated_at=now, + revision_kind="git", + revision_value="a" * 40, + revision_ref="refs/heads/main", + ) + base.update(overrides) + return base + + +def test_backfill_groups_correctly_and_leaves_ineligible_rows_standalone(lineage_migration_db): + """One populated-DB test exercising every §6 grouping/exclusion case at + once: same source/ref groups; different ref, different repo, and + different owner each get a separate lineage; a URL variant (mixed-case + host, .git suffix, trailing slash) still canonicalizes into the same + lineage; a malformed URL, an unresolved ref, and an upload all stay + standalone; and a deterministic timestamp tie breaks on repository id. + """ + database_url, engine = lineage_migration_db + cfg = _alembic_config() + command.upgrade(cfg, "0012_waitlist_entries") + + now = datetime.now(UTC) + owner_a, owner_b = _seed_users_and_repositories(engine, []) + + meta = MetaData() + repositories = Table("repositories", meta, autoload_with=engine) + + group_first = str(uuid.uuid4()) + group_second = str(uuid.uuid4()) + variant_row_id = str(uuid.uuid4()) + dev_row_id = str(uuid.uuid4()) + other_owner_row_id = str(uuid.uuid4()) + malformed_row_id = str(uuid.uuid4()) + unresolved_row_id = str(uuid.uuid4()) + upload_row_id = str(uuid.uuid4()) + tie_a = "20000000-0000-0000-0000-000000000001" + tie_b = "20000000-0000-0000-0000-000000000002" + + rows = [ + _repo_row( + owner_a, + id=group_first, + revision_value="a" * 40, + created_at=now - timedelta(days=2), + ), + _repo_row( + owner_a, + id=group_second, + revision_value="b" * 40, + created_at=now - timedelta(days=1), + ), + _repo_row( + owner_a, + id=variant_row_id, + source_url="https://GitHub.com/Acme/Widgets.git/", + revision_value="c" * 40, + created_at=now, + ), + _repo_row(owner_a, id=dev_row_id, revision_ref="refs/heads/dev", revision_value="d" * 40, created_at=now), + _repo_row(owner_b, id=other_owner_row_id, revision_value="e" * 40, created_at=now), + _repo_row(owner_a, id=malformed_row_id, source_url="not a url at all", revision_value="f" * 40, created_at=now), + _repo_row( + owner_a, + id=unresolved_row_id, + source_url="https://github.com/acme/other", + revision_ref=None, + revision_value="1" * 40, + created_at=now, + ), + dict( + id=upload_row_id, + owner_id=owner_a, + name="upload1", + description=None, + source="upload", + source_url=None, + branch=None, + local_path="/y", + size=0, + file_count=1, + status="completed", + analysis_stage=None, + analysis_progress=100, + uploaded_at=now, + analysed_at=now, + error_message=None, + repo_metadata=None, + file_tree=[], + created_at=now, + updated_at=now, + revision_kind="upload", + revision_value="sha256:" + "0" * 64, + revision_ref=None, + ), + _repo_row( + owner_a, + id=tie_a, + source_url="https://github.com/acme/tied", + revision_value="2" * 40, + created_at=now, + ), + _repo_row( + owner_a, + id=tie_b, + source_url="https://github.com/acme/tied", + revision_value="3" * 40, + created_at=now, + ), + ] + with engine.begin() as connection: + connection.execute(repositories.insert(), rows) + + command.upgrade(cfg, "head") + + meta2 = MetaData() + repos2 = Table("repositories", meta2, autoload_with=engine) + lineages2 = Table("repository_lineages", meta2, autoload_with=engine) + + with engine.connect() as connection: + attached = { + row.id: (row.lineage_id, row.sequence) + for row in connection.execute(select(repos2.c.id, repos2.c.lineage_id, repos2.c.sequence)) + } + + # The primary group: two original commits plus the URL-variant row, + # in creation order. + assert attached[group_first][1] == 1 + assert attached[group_second][1] == 2 + assert attached[variant_row_id][1] == 3 + primary_lineage = attached[group_first][0] + assert attached[group_second][0] == primary_lineage + assert attached[variant_row_id][0] == primary_lineage + + # A different ref is a different lineage. + assert attached[dev_row_id][0] != primary_lineage + assert attached[dev_row_id][1] == 1 + + # A different owner is a different lineage, even for the same + # canonical source/ref. + assert attached[other_owner_row_id][0] != primary_lineage + assert attached[other_owner_row_id][1] == 1 + + # Ineligible rows stay standalone. + assert attached[malformed_row_id] == (None, None) + assert attached[unresolved_row_id] == (None, None) + assert attached[upload_row_id] == (None, None) + + # Deterministic timestamp tie-break: identical created_at, so the + # lexicographically smaller id (tie_a) gets sequence 1. + assert attached[tie_a][1] == 1 + assert attached[tie_b][1] == 2 + assert attached[tie_a][0] == attached[tie_b][0] + + primary_row = ( + connection.execute( + select( + lineages2.c.owner_id, + lineages2.c.canonical_source_key, + lineages2.c.canonical_branch, + lineages2.c.display_name, + lineages2.c.latest_repository_id, + lineages2.c.next_sequence, + ).where(lineages2.c.id == primary_lineage) + ) + .mappings() + .one() + ) + assert primary_row["owner_id"] == owner_a + assert primary_row["canonical_source_key"] == "github.com/acme/widgets" + assert primary_row["canonical_branch"] == "refs/heads/main" + assert primary_row["display_name"] == "demo" + assert primary_row["latest_repository_id"] == variant_row_id + assert primary_row["next_sequence"] == 4 + + # No stray lineage attachment anywhere else: exactly the 7 eligible + # rows (group_first, group_second, variant, dev, other_owner, tie_a, + # tie_b) carry a lineage, and every (lineage_id, sequence) pair is + # unique. + attachments = [ + (row.lineage_id, row.sequence) + for row in connection.execute(select(repos2.c.lineage_id, repos2.c.sequence)) + if row.lineage_id is not None + ] + assert len(attachments) == 7 + assert len(set(attachments)) == 7 + + command.downgrade(cfg, "base") + command.upgrade(cfg, "head") + + +def test_backfill_rerun_is_idempotent(lineage_migration_db): + """Calling the backfill helper twice against the same already-backfilled + data reconciles to the identical final state rather than erroring or + double-counting (plan §6.2/§7 "interruption and rerun").""" + database_url, engine = lineage_migration_db + cfg = _alembic_config() + command.upgrade(cfg, "0012_waitlist_entries") + + owner_a, _owner_b = _seed_users_and_repositories(engine, []) + meta = MetaData() + repositories = Table("repositories", meta, autoload_with=engine) + now = datetime.now(UTC) + first_id, second_id = str(uuid.uuid4()), str(uuid.uuid4()) + with engine.begin() as connection: + connection.execute( + repositories.insert(), + [ + _repo_row(owner_a, id=first_id, revision_value="a" * 40, created_at=now - timedelta(days=1)), + _repo_row(owner_a, id=second_id, revision_value="b" * 40, created_at=now), + ], + ) + + command.upgrade(cfg, "0013_lineage_expand") + + migration = _load_migration_module("0013_lineage_expand") + + with engine.connect() as connection: + migration_context = MigrationContext.configure(connection) + with connection.begin(), Operations.context(migration_context): + groups_first = migration._backfill_lineages() + migration._verify_backfill(groups_first) + groups_second = migration._backfill_lineages() + migration._verify_backfill(groups_second) + + meta2 = MetaData() + repos2 = Table("repositories", meta2, autoload_with=engine) + lineages2 = Table("repository_lineages", meta2, autoload_with=engine) + with engine.connect() as connection: + lineage_rows = connection.execute(select(lineages2.c.id, lineages2.c.next_sequence)).all() + assert len(lineage_rows) == 1 + assert lineage_rows[0].next_sequence == 3 + + sequences = sorted( + row.sequence + for row in connection.execute(select(repos2.c.sequence).where(repos2.c.id.in_([first_id, second_id]))) + ) + assert sequences == [1, 2] + + command.upgrade(cfg, "head") + command.downgrade(cfg, "base") + + +def test_cross_owner_lineage_attachment_is_rejected_by_the_database_even_if_forced(lineage_migration_db): + """The composite deferred ownership FK (`fk_repositories_lineage_owner`) + rejects a forced cross-owner attachment even if application code is + wrong (#299 §9) -- proven against a database built the same way a real + deployment's is, via the actual Alembic migrations, not + `Base.metadata.create_all()`. + + This deliberately does not use the `client`/`auth_client` fixtures + (which bootstrap their schema via `create_all()`): resolving this + table pair's genuine foreign-key cycle there requires emitting one + table with an inline FK referencing the other before it exists, and at + least one SQLite build encountered in CI does not enforce a deferred FK + declared that way -- confirmed directly: the identical scenario built + on `create_all()` failed `PRAGMA foreign_key_check` outright on that + build, while raising `IntegrityError` reliably every time on other + platforms. The migration never creates that inline forward reference -- + 0014 adds this exact constraint in a second revision, after both tables + already exist -- which is what makes this version of the assertion + reliable everywhere instead of platform-dependent. + """ + database_url, engine = lineage_migration_db + cfg = _alembic_config() + command.upgrade(cfg, "head") + + owner_a, owner_b = _seed_users_and_repositories(engine, []) + + meta = MetaData() + lineages = Table("repository_lineages", meta, autoload_with=engine) + repositories = Table("repositories", meta, autoload_with=engine) + + now = datetime.now(UTC) + lineage_id = str(uuid.uuid4()) + with engine.begin() as connection: + connection.execute( + lineages.insert().values( + id=lineage_id, + owner_id=owner_b, + canonical_source_key="github.com/other/repo2", + canonical_branch="refs/heads/main", + display_name="repo2", + latest_repository_id=None, + next_sequence=1, + created_at=now, + ) + ) + + from sqlalchemy.exc import IntegrityError + + with engine.connect() as connection: + transaction = connection.begin() + connection.execute( + repositories.insert().values( + **_repo_row( + owner_a, + id=str(uuid.uuid4()), + name="cross-owner-attempt", + lineage_id=lineage_id, + sequence=1, + ) + ) + ) + with pytest.raises(IntegrityError): + transaction.commit() + transaction.rollback() diff --git a/apps/backend/tests/test_repository_lineage_service.py b/apps/backend/tests/test_repository_lineage_service.py new file mode 100644 index 00000000..a0d3d0c4 --- /dev/null +++ b/apps/backend/tests/test_repository_lineage_service.py @@ -0,0 +1,370 @@ +"""Service-level coverage for #299 (RFC-0002): live import/delete lineage +allocation, ownership, and the cyclic FK's real enforcement. + +HTTP-level tests exercise the actual `/repositories/github` and +`/repositories/upload` routes with a faked git clone (same idiom as +test_ingestion_pipeline.py), so lineage assignment is proven end to end, not +just at the repository-layer unit. Repository-layer tests exercise +`RepositoryRepository.add_with_lineage`/`delete_with_lineage_update` +directly for cases an HTTP request can't force (a same-lineage race, a +deliberately-forced FK violation). +""" + +import uuid +from datetime import UTC, datetime +from pathlib import Path + +import pytest + +from app.core.database import SessionLocal +from app.github.client import GitHubClient +from app.models.repository import RepositoryRecord +from app.models.repository_lineage import RepositoryLineage +from app.repositories.repository_repository import RepositoryRepository +from tests.api_assertions import assert_error_response + + +def _fake_clone(_: GitHubClient, __: str, destination: Path, ___: str | None = None) -> None: + destination.mkdir(parents=True, exist_ok=True) + (destination / "README.md").write_text("# demo\n", encoding="utf-8") + + +def _mock_github(monkeypatch: pytest.MonkeyPatch, commits: list[str], ref: str = "refs/heads/main") -> None: + commit_iter = iter(commits) + monkeypatch.setattr(GitHubClient, "clone_public_repository", _fake_clone) + monkeypatch.setattr(GitHubClient, "read_head_commit", lambda *_: next(commit_iter)) + monkeypatch.setattr(GitHubClient, "read_head_ref", lambda *_: ref) + + +def _lineage_of(response_json: dict) -> tuple[str | None, int | None]: + with SessionLocal() as db: + record = db.get(RepositoryRecord, response_json["id"]) + assert record is not None + return record.lineage_id, record.sequence + + +# -------------------------------------------------------------------------- +# Live import: lineage assignment +# -------------------------------------------------------------------------- + + +def test_first_github_import_creates_a_lineage_with_sequence_one(auth_client, monkeypatch: pytest.MonkeyPatch): + _mock_github(monkeypatch, ["a" * 40]) + + response = auth_client.post("/repositories/github", json={"url": "https://github.com/acme/widgets"}) + + assert response.status_code == 201, response.text + lineage_id, sequence = _lineage_of(response.json()) + assert lineage_id is not None + assert sequence == 1 + with SessionLocal() as db: + lineage = db.get(RepositoryLineage, lineage_id) + assert lineage is not None + assert lineage.canonical_source_key == "github.com/acme/widgets" + assert lineage.canonical_branch == "refs/heads/main" + assert lineage.display_name == "widgets" + assert lineage.latest_repository_id == response.json()["id"] + assert lineage.next_sequence == 2 + + +def test_second_commit_on_same_source_and_ref_reuses_the_lineage_and_increments( + auth_client, monkeypatch: pytest.MonkeyPatch +): + _mock_github(monkeypatch, ["a" * 40, "b" * 40]) + + first = auth_client.post("/repositories/github", json={"url": "https://github.com/acme/widgets"}) + second = auth_client.post("/repositories/github", json={"url": "https://github.com/acme/widgets"}) + + assert first.status_code == 201 and second.status_code == 201 + first_lineage, first_sequence = _lineage_of(first.json()) + second_lineage, second_sequence = _lineage_of(second.json()) + assert first_lineage == second_lineage + assert (first_sequence, second_sequence) == (1, 2) + with SessionLocal() as db: + lineage = db.get(RepositoryLineage, first_lineage) + assert lineage.latest_repository_id == second.json()["id"] + assert lineage.next_sequence == 3 + + +def test_owner_repo_case_variants_of_the_same_url_match_the_same_lineage(auth_client, monkeypatch: pytest.MonkeyPatch): + """Live validation allows mixed-case owner/repo (only the host must be + exact-case), so two spellings of the same repository must still land in + the same lineage once case-folded (#299 §8.1).""" + _mock_github(monkeypatch, ["a" * 40, "b" * 40]) + + first = auth_client.post("/repositories/github", json={"url": "https://github.com/Acme/Widgets"}) + second = auth_client.post("/repositories/github", json={"url": "https://github.com/acme/widgets"}) + + assert first.status_code == 201 and second.status_code == 201 + first_lineage, _ = _lineage_of(first.json()) + second_lineage, second_sequence = _lineage_of(second.json()) + assert first_lineage == second_lineage + assert second_sequence == 2 + + +def test_different_ref_gets_a_different_lineage(auth_client, monkeypatch: pytest.MonkeyPatch): + _mock_github(monkeypatch, ["a" * 40], ref="refs/heads/main") + main_response = auth_client.post("/repositories/github", json={"url": "https://github.com/acme/widgets"}) + _mock_github(monkeypatch, ["b" * 40], ref="refs/heads/dev") + dev_response = auth_client.post( + "/repositories/github", json={"url": "https://github.com/acme/widgets", "branch": "dev"} + ) + + assert main_response.status_code == 201 and dev_response.status_code == 201 + main_lineage, _ = _lineage_of(main_response.json()) + dev_lineage, dev_sequence = _lineage_of(dev_response.json()) + assert main_lineage != dev_lineage + assert dev_sequence == 1 + + +def test_different_repository_gets_a_different_lineage(auth_client, monkeypatch: pytest.MonkeyPatch): + _mock_github(monkeypatch, ["a" * 40, "b" * 40]) + + widgets = auth_client.post("/repositories/github", json={"url": "https://github.com/acme/widgets"}) + gadgets = auth_client.post("/repositories/github", json={"url": "https://github.com/acme/gadgets"}) + + assert widgets.status_code == 201 and gadgets.status_code == 201 + widgets_lineage, _ = _lineage_of(widgets.json()) + gadgets_lineage, gadgets_sequence = _lineage_of(gadgets.json()) + assert widgets_lineage != gadgets_lineage + assert gadgets_sequence == 1 + + +def test_different_owner_gets_a_different_lineage_even_for_the_same_source_and_ref( + auth_client, make_auth_headers, monkeypatch: pytest.MonkeyPatch +): + _mock_github(monkeypatch, ["a" * 40]) + primary = auth_client.post("/repositories/github", json={"url": "https://github.com/acme/widgets"}) + assert primary.status_code == 201 + + other = make_auth_headers("other-owner@example.com") + _mock_github(monkeypatch, ["a" * 40]) + other_response = auth_client.post( + "/repositories/github", json={"url": "https://github.com/acme/widgets"}, headers=other["headers"] + ) + + assert other_response.status_code == 201 + primary_lineage, _ = _lineage_of(primary.json()) + other_lineage, other_sequence = _lineage_of(other_response.json()) + assert primary_lineage != other_lineage + assert other_sequence == 1 + with SessionLocal() as db: + assert db.get(RepositoryLineage, other_lineage).owner_id == other["user"]["id"] + + +def test_same_commit_is_allowed_in_two_different_branch_lineages(auth_client, monkeypatch: pytest.MonkeyPatch): + shared_commit = "c" * 40 + _mock_github(monkeypatch, [shared_commit], ref="refs/heads/main") + main_response = auth_client.post("/repositories/github", json={"url": "https://github.com/acme/widgets"}) + _mock_github(monkeypatch, [shared_commit], ref="refs/heads/release") + release_response = auth_client.post( + "/repositories/github", json={"url": "https://github.com/acme/widgets", "branch": "release"} + ) + + assert main_response.status_code == 201 + assert release_response.status_code == 201 + assert main_response.json()["revision"]["value"] == shared_commit + assert release_response.json()["revision"]["value"] == shared_commit + main_lineage, _ = _lineage_of(main_response.json()) + release_lineage, _ = _lineage_of(release_response.json()) + assert main_lineage != release_lineage + + +def test_the_same_commit_twice_in_one_lineage_is_still_rejected_as_a_duplicate( + auth_client, monkeypatch: pytest.MonkeyPatch +): + """The pre-existing 409 behaviour must survive #299's rewrite of the + persistence path -- this is the authoritative, transactional duplicate + check now, not the old owner+source_url pre-clone check.""" + _mock_github(monkeypatch, ["a" * 40, "a" * 40]) + + first = auth_client.post("/repositories/github", json={"url": "https://github.com/acme/widgets"}) + duplicate = auth_client.post("/repositories/github", json={"url": "https://github.com/acme/widgets"}) + + assert first.status_code == 201 + error = assert_error_response(duplicate, 409, "conflict_error") + assert error.details == {"repositoryId": first.json()["id"], "name": first.json()["name"]} + + with SessionLocal() as db: + lineage_id, _ = _lineage_of(first.json()) + lineage = db.get(RepositoryLineage, lineage_id) + # The rejected duplicate must not have burned a sequence number. + assert lineage.next_sequence == 2 + + +def test_upload_never_creates_or_touches_a_lineage(auth_client): + response = auth_client.post( + "/repositories/upload", + files={"file": ("demo.zip", _minimal_zip(), "application/octet-stream")}, + ) + + assert response.status_code == 201, response.text + lineage_id, sequence = _lineage_of(response.json()) + assert (lineage_id, sequence) == (None, None) + + +def _minimal_zip() -> bytes: + import io + import zipfile + + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as archive: + archive.writestr("README.md", "# demo\n") + return buffer.getvalue() + + +def test_a_failed_lineage_duplicate_insert_cleans_up_the_staged_repository_directory( + auth_client, monkeypatch: pytest.MonkeyPatch +): + """#299 §5.3 extends the existing pre-clone cleanup across the + transactional insert phase: a rejected duplicate must not leave an + orphaned directory under storage.""" + _mock_github(monkeypatch, ["a" * 40, "a" * 40]) + + first = auth_client.post("/repositories/github", json={"url": "https://github.com/acme/widgets"}) + assert first.status_code == 201 + + from app.core.config import get_settings + from app.storage.local import LocalStorage + + storage = LocalStorage(get_settings()) + before = set(storage.repositories_root.iterdir()) if storage.repositories_root.exists() else set() + + duplicate = auth_client.post("/repositories/github", json={"url": "https://github.com/acme/widgets"}) + assert duplicate.status_code == 409 + + after = set(storage.repositories_root.iterdir()) if storage.repositories_root.exists() else set() + assert after == before, "the duplicate's staged directory must be removed, not left behind" + + +# -------------------------------------------------------------------------- +# Deletion: latest-pointer rollback +# -------------------------------------------------------------------------- + + +def test_deleting_a_non_latest_member_leaves_the_latest_pointer_unchanged(auth_client, monkeypatch: pytest.MonkeyPatch): + _mock_github(monkeypatch, ["a" * 40, "b" * 40, "c" * 40]) + auth_client.post("/repositories/github", json={"url": "https://github.com/acme/widgets"}) + second = auth_client.post("/repositories/github", json={"url": "https://github.com/acme/widgets"}) + third = auth_client.post("/repositories/github", json={"url": "https://github.com/acme/widgets"}) + lineage_id, _ = _lineage_of(third.json()) + + delete_response = auth_client.delete(f"/repositories/{second.json()['id']}") + assert delete_response.status_code == 204 + + with SessionLocal() as db: + lineage = db.get(RepositoryLineage, lineage_id) + assert lineage.latest_repository_id == third.json()["id"] + assert lineage.next_sequence == 4 # counter never decreases + + +def test_deleting_the_latest_member_rolls_back_to_the_next_highest_surviving_sequence( + auth_client, monkeypatch: pytest.MonkeyPatch +): + _mock_github(monkeypatch, ["a" * 40, "b" * 40, "c" * 40]) + first = auth_client.post("/repositories/github", json={"url": "https://github.com/acme/widgets"}) + second = auth_client.post("/repositories/github", json={"url": "https://github.com/acme/widgets"}) + third = auth_client.post("/repositories/github", json={"url": "https://github.com/acme/widgets"}) + lineage_id, _ = _lineage_of(first.json()) + + delete_response = auth_client.delete(f"/repositories/{third.json()['id']}") + assert delete_response.status_code == 204 + + with SessionLocal() as db: + lineage = db.get(RepositoryLineage, lineage_id) + assert lineage.latest_repository_id == second.json()["id"] + assert lineage.next_sequence == 4 + + +def test_deleting_the_last_member_keeps_an_empty_lineage_and_never_reuses_its_sequence( + auth_client, monkeypatch: pytest.MonkeyPatch +): + _mock_github(monkeypatch, ["a" * 40]) + only = auth_client.post("/repositories/github", json={"url": "https://github.com/acme/widgets"}) + lineage_id, _ = _lineage_of(only.json()) + + delete_response = auth_client.delete(f"/repositories/{only.json()['id']}") + assert delete_response.status_code == 204 + + with SessionLocal() as db: + lineage = db.get(RepositoryLineage, lineage_id) + assert lineage is not None, "an empty lineage is kept, not garbage collected" + assert lineage.latest_repository_id is None + assert lineage.next_sequence == 2 + + _mock_github(monkeypatch, ["b" * 40]) + reimport = auth_client.post("/repositories/github", json={"url": "https://github.com/acme/widgets"}) + assert reimport.status_code == 201 + reimport_lineage, reimport_sequence = _lineage_of(reimport.json()) + assert reimport_lineage == lineage_id + assert reimport_sequence == 2 # sequence 1 is never reused + + +# -------------------------------------------------------------------------- +# Cross-owner isolation +# -------------------------------------------------------------------------- + + +def test_cross_owner_lineage_lookup_never_matches_another_owners_row(auth_client, make_auth_headers): + """A pre-existing lineage owned by another user, with the exact same + canonical key a fresh import will compute, must never be reused -- + proven through the real allocation path (`add_with_lineage`), not by + asserting a private lookup helper's return value in isolation.""" + other = make_auth_headers("owner-b@example.com") + with SessionLocal() as db: + other_lineage = RepositoryLineage( + id=str(uuid.uuid4()), + owner_id=other["user"]["id"], + canonical_source_key="github.com/shared/repo", + canonical_branch="refs/heads/main", + display_name="repo", + latest_repository_id=None, + next_sequence=1, + created_at=datetime.now(UTC), + ) + db.add(other_lineage) + db.commit() + other_lineage_id = other_lineage.id + + with SessionLocal() as db: + repository_repo = RepositoryRepository(db) + record = RepositoryRecord( + id=str(uuid.uuid4()), + owner_id=auth_client.default_user["id"], # type: ignore[attr-defined] + name="repo", + source="github", + source_url="https://github.com/shared/repo", + branch="main", + revision_kind="git", + revision_value="e" * 40, + revision_ref="refs/heads/main", + local_path="/tmp/x", + status="analysing", + ) + persisted = repository_repo.add_with_lineage( + record, + owner_id=auth_client.default_user["id"], # type: ignore[attr-defined] + canonical_source_key="github.com/shared/repo", + canonical_branch="refs/heads/main", + display_name="repo", + ) + assert persisted.lineage_id != other_lineage_id + assert persisted.sequence == 1 + + +# Direct proof that the composite ownership FK rejects a forced cross-owner +# attachment lives in tests/test_repository_lineage_migration.py (SQLite, via +# a real Alembic-migrated database) and +# tests/test_repository_lineage_concurrency.py (real PostgreSQL). See the +# commit history on this file for why: the same assertion built on top of +# this file's `auth_client` fixture (which bootstraps its schema via +# `Base.metadata.create_all()`, not migrations) was not reliable across +# SQLite builds -- `create_all()` must resolve this table pair's genuine +# foreign-key cycle by emitting one table with an inline FK referencing the +# other table before it exists, and at least one SQLite build encountered in +# CI does not enforce a deferred FK declared that way, even though it stores +# and reports the declaration correctly. The Alembic migration never does +# this -- it adds the constraint in a second revision after both tables +# already exist -- which is why the migration-backed version of this +# assertion is reliable and this file's version was removed rather than +# patched a third time. diff --git a/apps/backend/tests/test_repository_ownership.py b/apps/backend/tests/test_repository_ownership.py new file mode 100644 index 00000000..e535ccd1 --- /dev/null +++ b/apps/backend/tests/test_repository_ownership.py @@ -0,0 +1,113 @@ +"""Owner-scoping of the repository routes, proven with real access tokens. + +The pre-auth ``X-Dev-User`` fallback was removed in E1.3 (#63); ownership is now +established solely by the authenticated user behind the Bearer token. A repo is +seeded directly (bypassing the import pipeline) and attributed to a registered +user's id, so requests carrying that user's token own it. +""" + +import uuid + +from sqlalchemy import select + +from tests.api_assertions import assert_error_response +from tests.conftest import register_user + + +def _seed_repository(owner_id: str, name: str = "sample-repo") -> str: + from app.core.database import SessionLocal + from app.models.repository import RepositoryRecord + + db = SessionLocal() + try: + repository_id = str(uuid.uuid4()) + db.add( + RepositoryRecord( + id=repository_id, + owner_id=owner_id, + name=name, + source="upload", + local_path=f"/tmp/{repository_id}", + status="completed", + ) + ) + db.commit() + return repository_id + finally: + db.close() + + +def test_get_returns_404_for_another_users_repository(client, make_auth_headers): + alice = make_auth_headers("alice@example.com") + bob = make_auth_headers("bob@example.com") + repository_id = _seed_repository(alice["user"]["id"]) + + denied = client.get(f"/repositories/{repository_id}", headers=bob["headers"]) + assert denied.status_code == 404 + + allowed = client.get(f"/repositories/{repository_id}", headers=alice["headers"]) + assert allowed.status_code == 200 + assert allowed.json()["id"] == repository_id + + +def test_list_only_returns_the_current_users_repositories(client, make_auth_headers): + alice = make_auth_headers("alice@example.com") + bob = make_auth_headers("bob@example.com") + _seed_repository(alice["user"]["id"]) + + alice_view = client.get("/repositories", headers=alice["headers"]) + assert alice_view.status_code == 200 + assert alice_view.json()["total"] == 1 + + bob_view = client.get("/repositories", headers=bob["headers"]) + assert bob_view.status_code == 200 + assert bob_view.json() == {"data": [], "total": 0} + + +def test_delete_returns_404_for_another_users_repository(client, make_auth_headers): + alice = make_auth_headers("alice@example.com") + bob = make_auth_headers("bob@example.com") + repository_id = _seed_repository(alice["user"]["id"]) + + denied = client.delete(f"/repositories/{repository_id}", headers=bob["headers"]) + assert denied.status_code == 404 + + # The repository still exists for its owner: the denial did not delete it. + still_there = client.get(f"/repositories/{repository_id}", headers=alice["headers"]) + assert still_there.status_code == 200 + + # And it was never removed from the database by the cross-user delete. + from app.core.database import SessionLocal + from app.models.repository import RepositoryRecord + + db = SessionLocal() + try: + assert db.scalars(select(RepositoryRecord).where(RepositoryRecord.id == repository_id)).first() is not None + finally: + db.close() + + +def test_owner_can_delete_a_repository(client, make_auth_headers): + alice = make_auth_headers("alice@example.com") + repository_id = _seed_repository(alice["user"]["id"]) + + deleted = client.delete(f"/repositories/{repository_id}", headers=alice["headers"]) + assert deleted.status_code == 204 + assert deleted.content == b"" + + missing = client.get(f"/repositories/{repository_id}", headers=alice["headers"]) + assert_error_response(missing, 404, "not_found") + + +def test_unauthenticated_repository_access_is_rejected(client): + # With the pre-auth fallback removed, no token means 401 — not an empty + # list attributed to a seed user. + assert client.get("/repositories").status_code == 401 + assert client.get(f"/repositories/{uuid.uuid4()}").status_code == 401 + + +def test_registered_user_starts_with_no_repositories(client): + auth = register_user(client, "fresh@example.com") + view = client.get("/repositories", headers=auth["headers"]) + assert view.status_code == 200 + assert view.json() == {"data": [], "total": 0} diff --git a/apps/backend/tests/test_repository_parser.py b/apps/backend/tests/test_repository_parser.py index 8a1d483d..175a3019 100644 --- a/apps/backend/tests/test_repository_parser.py +++ b/apps/backend/tests/test_repository_parser.py @@ -1,6 +1,9 @@ from pathlib import Path -from app.parsers.repository_parser import RepositoryParser +import pytest + +import app.parsers.repository_parser as repository_parser_module +from app.parsers.repository_parser import RepositoryFileLimitExceeded, RepositoryParser, UnsafeRepositoryPath def test_repository_parser_detects_basic_typescript_project(tmp_path: Path): @@ -17,3 +20,214 @@ def test_repository_parser_detects_basic_typescript_project(tmp_path: Path): assert meta.has_readme is True assert meta.entry_point == "/src/main.tsx" assert total_size > 0 + + +def test_repository_parser_preflight_stops_before_materializing_the_directory( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + class FileEntry: + def __init__(self, index: int) -> None: + self.name = f"file-{index}.py" + self.path = str(tmp_path / self.name) + + def is_dir(self) -> bool: + return False + + def is_file(self) -> bool: + return True + + def is_symlink(self) -> bool: + return False + + class BoundedScandir: + def __init__(self) -> None: + self.index = 0 + self.closed = False + + def __enter__(self): + return self + + def __exit__(self, *_args): + self.closed = True + + def __iter__(self): + return self + + def __next__(self): + if self.index == 6: + raise AssertionError("parser requested an entry after the limit was exceeded") + entry = FileEntry(self.index) + self.index += 1 + return entry + + entries = BoundedScandir() + + def scandir(path: Path): + assert path == tmp_path + return entries + + def unexpected_iterdir(_path: Path): + raise AssertionError("preflight used Path.iterdir instead of os.scandir") + + monkeypatch.setattr(repository_parser_module.os, "scandir", scandir) + monkeypatch.setattr(Path, "iterdir", unexpected_iterdir) + + with pytest.raises(RepositoryFileLimitExceeded) as caught: + RepositoryParser().parse(tmp_path, max_file_count=5) + + assert caught.value.file_count == 6 + assert caught.value.max_file_count == 5 + assert entries.index == 6 + assert entries.closed is True + + +def test_repository_parser_does_not_count_ignored_directories(tmp_path: Path): + ignored = tmp_path / "node_modules" + ignored.mkdir() + for index in range(5): + (ignored / f"dependency-{index}.js").write_text("ignored", encoding="utf-8") + (tmp_path / "main.py").write_text("print('ok')\n", encoding="utf-8") + + tree, meta, _ = RepositoryParser().parse(tmp_path, max_file_count=1) + + assert meta.total_files == 1 + assert [node.name for node in tree] == ["main.py"] + + +def test_repository_parser_drops_macos_archive_artifacts(tmp_path: Path): + """#398: __MACOSX/, ._ AppleDouble sidecars, and .DS_Store are + archiver/Finder bookkeeping, not repository content -- an AppleDouble + sidecar in particular shares its real counterpart's extension + (``._app.py`` next to ``app.py``) while being opaque binary, so it must + never reach a downstream extension-based extractor as a second module.""" + + macosx = tmp_path / "__MACOSX" + macosx.mkdir() + (macosx / "._app.py").write_bytes(b"\x00\x05\x16\x07 not real python") + (tmp_path / "._app.py").write_bytes(b"\x00\x05\x16\x07 not real python") + (tmp_path / ".DS_Store").write_bytes(b"junk") + (tmp_path / "app.py").write_text("print('ok')\n", encoding="utf-8") + + tree, meta, _ = RepositoryParser().parse(tmp_path, max_file_count=10) + + assert meta.total_files == 1 + assert [node.name for node in tree] == ["app.py"] + + +def test_repository_parser_file_count_preflight_ignores_macos_artifacts(tmp_path: Path): + (tmp_path / ".DS_Store").write_bytes(b"junk") + (tmp_path / "._app.py").write_bytes(b"junk") + (tmp_path / "app.py").write_text("print('ok')\n", encoding="utf-8") + + tree, meta, _ = RepositoryParser().parse(tmp_path, max_file_count=1) + + assert meta.total_files == 1 + + +# --- symlink safety ----------------------------------------------------------- +# +# Archive uploads can't reach this: TAR extraction rejects symlink/link/device +# members before writing (storage/local.py), and zipfile.extractall() never +# materializes a real OS symlink from a zip entry (confirmed empirically: it +# writes the "target" as literal file content instead). A GitHub import has no +# such guard -- git clone faithfully recreates whatever real symlinks the +# source repository committed. Path.is_dir()/is_file()/stat() (and +# os.DirEntry.is_dir()/is_file()) all follow symlinks by default, so an +# unguarded parser walking a git checkout would recurse into and catalog +# arbitrary host filesystem content reachable through a symlink that points +# outside the checkout. + + +def test_repository_parser_rejects_a_symlink_that_escapes_the_checkout(tmp_path: Path): + checkout = tmp_path / "checkout" + checkout.mkdir() + (checkout / "README.md").write_text("hello\n", encoding="utf-8") + outside = tmp_path / "outside" + outside.mkdir() + (outside / "secret.txt").write_text("host file content that must never be reachable\n", encoding="utf-8") + (checkout / "evil_link").symlink_to(outside) + + with pytest.raises(UnsafeRepositoryPath) as caught: + RepositoryParser().parse(checkout) + + assert caught.value.relative_path == "/evil_link" + + +def test_repository_parser_file_count_preflight_also_rejects_a_symlink(tmp_path: Path): + """The same escape via the separate max_file_count preflight scan. + + _enforce_file_count streams the tree with os.scandir before _build_tree + ever runs, as its own independent walk -- it needed its own guard, not + just _build_tree's, or a request with max_file_count set would still be + exploitable. + """ + + checkout = tmp_path / "checkout" + checkout.mkdir() + outside = tmp_path / "outside" + outside.mkdir() + (outside / "secret.txt").write_text("marker\n", encoding="utf-8") + (checkout / "evil_link").symlink_to(outside) + + with pytest.raises(UnsafeRepositoryPath): + RepositoryParser().parse(checkout, max_file_count=1000) + + +def test_repository_parser_rejects_a_symlink_even_when_it_resolves_inside_the_checkout(tmp_path: Path): + """Deliberately as strict as the archive-upload path: any symlink at all. + + A symlink that points at a file within the same checkout can't be used to + reach host content, but the file tree walk rejects it anyway rather than + trying to special-case "safe" symlinks -- matching the existing TAR + extraction policy (reject any symlink member, full stop) rather than + inventing a second, more permissive policy for this path. + """ + + checkout = tmp_path / "checkout" + checkout.mkdir() + (checkout / "real.py").write_text("value = 1\n", encoding="utf-8") + (checkout / "alias.py").symlink_to(checkout / "real.py") + + with pytest.raises(UnsafeRepositoryPath): + RepositoryParser().parse(checkout) + + +def test_repository_parser_framework_detection_ignores_a_symlink_that_escapes_the_checkout(tmp_path: Path): + """Defence in depth for the metadata-detection helpers specifically. + + In practice the file-tree walk above already rejects the whole import + before _detect_framework ever runs, for any repository containing any + symlink anywhere. This tests the helper in isolation anyway: unlike the + tree walk, it reads file content by a fixed, predictable name + (package.json), so if the tree-walk guard were ever loosened to skip + rather than reject individual symlinks, this is the layer that stops a + symlinked package.json from being read as JSON from an arbitrary host + path. + """ + + checkout = tmp_path / "checkout" + checkout.mkdir() + outside = tmp_path / "outside.json" + outside.write_text('{"dependencies": {"react": "18.0.0"}}', encoding="utf-8") + (checkout / "package.json").symlink_to(outside) + + assert RepositoryParser()._detect_framework(checkout) == "Unknown" + + +def test_repository_parser_framework_detection_still_works_through_an_in_root_symlink(tmp_path: Path): + checkout = tmp_path / "checkout" + checkout.mkdir() + (checkout / "real_package.json").write_text('{"dependencies": {"react": "18.0.0"}}', encoding="utf-8") + (checkout / "package.json").symlink_to(checkout / "real_package.json") + + assert RepositoryParser()._detect_framework(checkout) == "React" + + +def test_repository_parser_license_detection_ignores_a_symlink_that_escapes_the_checkout(tmp_path: Path): + checkout = tmp_path / "checkout" + checkout.mkdir() + outside = tmp_path / "outside_license.txt" + outside.write_text("MIT License\n", encoding="utf-8") + (checkout / "LICENSE").symlink_to(outside) + + assert RepositoryParser()._detect_license(checkout) is None diff --git a/apps/backend/tests/test_review_evidence.py b/apps/backend/tests/test_review_evidence.py deleted file mode 100644 index 8a893b5a..00000000 --- a/apps/backend/tests/test_review_evidence.py +++ /dev/null @@ -1,69 +0,0 @@ -from datetime import UTC, datetime -from pathlib import Path - -from app.intelligence.engine import RepositoryIntelligenceEngine -from app.models.repository import RepositoryRecord -from app.parsers.repository_parser import RepositoryParser -from app.review.review_service import EngineeringReviewBuilder - -_OLD_PLACEHOLDER_IMPACT = "This can reduce maintainability, correctness, or operational confidence." - - -def _repository_with_gaps(root: Path) -> None: - (root / "src").mkdir(parents=True) - (root / "package.json").write_text('{"dependencies":{"react":"^18.0.0"}}', encoding="utf-8") - (root / "src" / "main.tsx").write_text("import React from 'react';\nexport function App() { return null; }", encoding="utf-8") - (root / "src" / "big.ts").write_text("export const value = 1;\n" * 3000, encoding="utf-8") # > 40 KB - (root / ".env").write_text("API_SECRET=super-secret\n", encoding="utf-8") - # Deliberately no README, LICENSE, tests, or CI workflow. - - -def _review(root: Path): - tree, meta, total_size = RepositoryParser().parse(root) - intelligence = RepositoryIntelligenceEngine().build("repo-1", "sample", root, tree, meta, total_size) - metadata = intelligence.metadata.model_dump(mode="json", by_alias=True) - metadata["intelligence"] = intelligence.model_dump(mode="json", by_alias=True) - record = RepositoryRecord( - id="repo-1", - name="sample", - source="upload", - local_path=str(root), - size=intelligence.discovery.statistics.total_size, - file_count=intelligence.discovery.statistics.total_files, - status="completed", - data_source="real", - analysis_stage="completed", - analysis_progress=100, - uploaded_at=datetime.now(UTC), - analysed_at=datetime.now(UTC), - repo_metadata=metadata, - file_tree=[], - ) - return EngineeringReviewBuilder().build(record) - - -def test_findings_carry_specific_impact_and_evidence(tmp_path: Path): - _repository_with_gaps(tmp_path) - review = _review(tmp_path) - findings = {finding.id: finding for finding in review.findings} - - # No finding should reuse the old constant placeholder impact. - assert all(finding.impact != _OLD_PLACEHOLDER_IMPACT for finding in review.findings) - # Problem text is finding-specific, not a copy of the title. - assert all(finding.problem != finding.title for finding in review.findings) - - # Real, file-level evidence is attached where it exists. - assert ".env" in findings["env-file-present"].affected_files - assert findings["large-files"].affected_files - assert any("big.ts" in path for path in findings["large-files"].affected_files) - - -def test_roadmap_is_derived_from_findings(tmp_path: Path): - _repository_with_gaps(tmp_path) - review = _review(tmp_path) - - finding_ids = {finding.id for finding in review.findings} - assert len(review.roadmap) >= 2 # not the single hardcoded step - for step in review.roadmap: - assert step.related_findings # each step links back to real findings - assert set(step.related_findings) <= finding_ids diff --git a/apps/backend/tests/test_revision_manifest.py b/apps/backend/tests/test_revision_manifest.py new file mode 100644 index 00000000..b3c04076 --- /dev/null +++ b/apps/backend/tests/test_revision_manifest.py @@ -0,0 +1,341 @@ +"""Revision manifest verification contract (#113). + +The manifest is the product's answer to "which exact revision produced this, +and can I check that later". These tests hold it to that claim: the digest must +be reproducible for a sealed snapshot, tampering must be detected, another +owner must not be able to read it, and the citations a user follows must belong +to the revision the manifest names. +""" + +from __future__ import annotations + +import io +import zipfile + +from app.analysis.manifest import build_manifest, manifest_digest +from app.intelligence.snapshot_store import Evidence +from app.models.repository import RepositoryRecord + +from tests.analysis_helpers import run_analysis_jobs + +_SOURCES = { + "README.md": b"# manifest fixture\n", + "package.json": b'{\n "dependencies": {\n "react": "18.3.0"\n }\n}\n', + "src/alpha/index.ts": (b"import { beta } from '../beta';\nexport const alpha = beta();\n"), + "src/beta/index.ts": b"export function beta() { return 1; }\n", +} + + +def _archive(files: dict[str, bytes]) -> bytes: + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as archive: + for path, content in files.items(): + archive.writestr(path, content) + return buffer.getvalue() + + +def _analysed_repository(auth_client) -> dict: + response = auth_client.post( + "/repositories/upload", + files={"file": ("manifest.zip", _archive(_SOURCES), "application/zip")}, + ) + assert response.status_code == 201, response.text + repository = response.json() + assert auth_client.post(f"/analysis/{repository['id']}/start").status_code == 200 + assert run_analysis_jobs() == 1 + return repository + + +def test_manifest_exposes_the_sealed_revision_identity(auth_client): + repository = _analysed_repository(auth_client) + + response = auth_client.get(f"/analysis/{repository['id']}/revision-manifest") + assert response.status_code == 200, response.text + body = response.json() + manifest = body["manifest"] + + assert manifest["schemaVersion"] == "revision-manifest.v1" + assert manifest["repositoryId"] == repository["id"] + assert manifest["snapshotSchemaVersion"] == "ri.v1" + assert manifest["revisionKind"] == "upload" + assert manifest["revisionValue"].startswith("sha256:") + assert manifest["snapshotId"] + assert manifest["canonicalGraphHash"] + assert manifest["createdAt"] + assert manifest["sealedAt"] + + # Extractors are named and versioned individually, not as opaque strings. + assert manifest["extractors"] + for extractor in manifest["extractors"]: + assert extractor["name"] + assert extractor["version"] + + assert body["manifestDigest"].startswith("sha256:") + assert body["verificationState"] == "verified" + assert body["verificationMethod"] == "sha256-canonical-json" + + +def test_manifest_never_claims_to_be_digitally_signed(auth_client): + """A canonical hash is not a signature; the copy must not imply otherwise.""" + + repository = _analysed_repository(auth_client) + body = auth_client.get(f"/analysis/{repository['id']}/revision-manifest").json() + + note = body["verificationNote"].lower() + assert "not a digital signature" in note + assert "signed" not in body["verificationMethod"].lower() + + +def test_same_sealed_snapshot_produces_the_same_manifest_digest(auth_client): + """The digest is a pure function of sealed facts, stable across reads.""" + + repository = _analysed_repository(auth_client) + + first = auth_client.get(f"/analysis/{repository['id']}/revision-manifest").json() + second = auth_client.get(f"/analysis/{repository['id']}/revision-manifest").json() + + assert first["manifestDigest"] == second["manifestDigest"] + assert first["manifest"] == second["manifest"] + + # Recomputing from the stored snapshot independently reproduces the digest, + # so the value is not just cached alongside the response. + from app.core.database import SessionLocal + + with SessionLocal() as session: + record = session.get(RepositoryRecord, repository["id"]) + assert record is not None + from app.intelligence.query_service import SnapshotQueryService + + snapshot = SnapshotQueryService(session, record.owner_id).latest_snapshot_for_owner(record.id) + assert snapshot is not None + assert manifest_digest(build_manifest(snapshot)) == first["manifestDigest"] + + +def test_verification_accepts_an_unaltered_manifest(auth_client): + repository = _analysed_repository(auth_client) + exported = auth_client.get(f"/analysis/{repository['id']}/revision-manifest").json() + + response = auth_client.post( + f"/analysis/{repository['id']}/revision-manifest/verify", + json={"manifest": exported["manifest"], "manifestDigest": exported["manifestDigest"]}, + ) + + assert response.status_code == 200, response.text + body = response.json() + assert body["verificationState"] == "verified" + assert body["matchesStoredSnapshot"] is True + assert body["mismatchedFields"] == [] + + +def test_altered_manifest_content_fails_verification(auth_client): + """Editing a field while keeping the original digest must be detected.""" + + repository = _analysed_repository(auth_client) + exported = auth_client.get(f"/analysis/{repository['id']}/revision-manifest").json() + + tampered = dict(exported["manifest"]) + tampered["revisionValue"] = "sha256:" + "f" * 64 + + response = auth_client.post( + f"/analysis/{repository['id']}/revision-manifest/verify", + json={"manifest": tampered, "manifestDigest": exported["manifestDigest"]}, + ) + + assert response.status_code == 200, response.text + body = response.json() + assert body["verificationState"] == "mismatch" + assert body["matchesStoredSnapshot"] is False + + +def test_manifest_rehashed_after_tampering_still_fails_against_stored_facts(auth_client): + """A self-consistent forgery is still caught by comparing to the snapshot. + + Recomputing the digest over altered content defeats the digest check alone, + so verification also compares the submitted body to the stored snapshot. + """ + + repository = _analysed_repository(auth_client) + exported = auth_client.get(f"/analysis/{repository['id']}/revision-manifest").json() + + from app.schemas.manifest import RevisionManifest + + tampered = dict(exported["manifest"]) + tampered["canonicalGraphHash"] = "sha256:" + "a" * 64 + forged_digest = manifest_digest(RevisionManifest.model_validate(tampered)) + + response = auth_client.post( + f"/analysis/{repository['id']}/revision-manifest/verify", + json={"manifest": tampered, "manifestDigest": forged_digest}, + ) + + body = response.json() + assert body["verificationState"] == "mismatch" + assert body["matchesStoredSnapshot"] is False + assert "canonicalGraphHash" in body["mismatchedFields"] + + +def test_a_manifest_naming_a_superseded_snapshot_is_authentic_not_a_mismatch(auth_client): + """An exported manifest must survive an extractor upgrade. + + A repository revision is immutable and re-analysis is content-addressed, so + the snapshot identity for one record changes only when the producer set or + config changes -- an extractor upgrade. The manifest a user exported before + that upgrade still describes a snapshot this deployment stores, so it is + authentic. Comparing it against whichever snapshot is merely *newest* would + report `mismatch`, telling the user their evidence had been altered when + nothing had been. That case is `superseded`. + """ + + repository = _analysed_repository(auth_client) + exported = auth_client.get(f"/analysis/{repository['id']}/revision-manifest").json() + original_snapshot_id = exported["manifest"]["snapshotId"] + + # Seal a second snapshot for the same immutable revision under a newer + # extractor version, which is what upgrading a producer does. + from app.core.database import SessionLocal + from app.intelligence.snapshot_store import Revision, SnapshotStore + + with SessionLocal() as session: + record = session.get(RepositoryRecord, repository["id"]) + assert record is not None + store = SnapshotStore(session) + snapshot = store.begin( + repository_id=record.id, + revision=Revision(record.revision_kind, record.revision_value, record.revision_ref), + schema_version="ri.v1", + producer_version_set=["repository-inventory@9.9.9"], + ) + store.add_node( + snapshot, + node_kind="repository", + stable_key="repo:root", + name="repository", + language=None, + evidence=[ + Evidence( + path="README.md", + start_line=1, + end_line=1, + logical_line_count=2, + extractor="repository-inventory", + extractor_version="9.9.9", + granularity="file", + ) + ], + ) + superseding_id = store.seal(snapshot).snapshot_id + session.commit() + + assert superseding_id != original_snapshot_id + # The surface now publishes the newer snapshot, so a fresh read and the + # older export legitimately disagree about snapshot identity. + current = auth_client.get(f"/analysis/{repository['id']}/revision-manifest").json() + assert current["manifest"]["snapshotId"] == superseding_id + assert current["manifest"]["revisionValue"] == exported["manifest"]["revisionValue"] + + verify = auth_client.post( + f"/analysis/{repository['id']}/revision-manifest/verify", + json={"manifest": exported["manifest"], "manifestDigest": exported["manifestDigest"]}, + ) + + assert verify.status_code == 200, verify.text + body = verify.json() + assert body["verificationState"] == "superseded" + # Authentic: it matches the snapshot it names, which this deployment stores. + assert body["matchesStoredSnapshot"] is True + assert body["mismatchedFields"] == [] + + # Tampering is still caught while a superseded snapshot is the named one. + forged = dict(exported["manifest"]) + forged["canonicalGraphHash"] = "sha256:" + "b" * 64 + tampered = auth_client.post( + f"/analysis/{repository['id']}/revision-manifest/verify", + json={"manifest": forged, "manifestDigest": exported["manifestDigest"]}, + ).json() + assert tampered["verificationState"] == "mismatch" + assert tampered["matchesStoredSnapshot"] is False + + +def test_a_manifest_naming_an_unknown_snapshot_is_a_mismatch(auth_client): + """A fabricated snapshot identity must not pass as superseded.""" + + repository = _analysed_repository(auth_client) + exported = auth_client.get(f"/analysis/{repository['id']}/revision-manifest").json() + + from app.schemas.manifest import RevisionManifest + + forged = dict(exported["manifest"]) + forged["snapshotId"] = "snap_" + "0" * 32 + forged_digest = manifest_digest(RevisionManifest.model_validate(forged)) + + body = auth_client.post( + f"/analysis/{repository['id']}/revision-manifest/verify", + json={"manifest": forged, "manifestDigest": forged_digest}, + ).json() + + assert body["verificationState"] == "mismatch" + assert body["matchesStoredSnapshot"] is False + assert "snapshotId" in body["mismatchedFields"] + + +def test_another_owner_cannot_retrieve_or_verify_the_manifest(auth_client, make_auth_headers): + repository = _analysed_repository(auth_client) + exported = auth_client.get(f"/analysis/{repository['id']}/revision-manifest").json() + + intruder = make_auth_headers("intruder@example.com") + + read = auth_client.get( + f"/analysis/{repository['id']}/revision-manifest", + headers=intruder["headers"], + ) + assert read.status_code == 404 + + verify = auth_client.post( + f"/analysis/{repository['id']}/revision-manifest/verify", + headers=intruder["headers"], + json={"manifest": exported["manifest"], "manifestDigest": exported["manifestDigest"]}, + ) + assert verify.status_code == 404 + + +def test_manifest_requires_authentication(client): + unauthenticated = client.get("/analysis/11111111-1111-1111-1111-111111111111/revision-manifest") + assert unauthenticated.status_code == 401 + + +def test_citations_reference_the_revision_the_manifest_names(auth_client): + """The evidence a user opens must belong to the manifest's snapshot. + + This is the link that makes the manifest meaningful: if an explanation's + citations pointed at a different snapshot, the revision identity would + describe something other than the answer on screen. + """ + + repository = _analysed_repository(auth_client) + manifest = auth_client.get(f"/analysis/{repository['id']}/revision-manifest").json()["manifest"] + + architecture = auth_client.get(f"/analysis/{repository['id']}/architecture").json() + assert architecture["relationshipSnapshotId"] == manifest["snapshotId"] + + cited = [evidence for edge in architecture["edges"] for evidence in edge.get("evidence", [])] + assert cited, "expected at least one evidence-backed architecture edge" + + for evidence in cited: + assert evidence["snapshotId"] == manifest["snapshotId"] + + # And the source behind a citation resolves against that same snapshot. + sample = cited[0] + source = auth_client.get( + f"/analysis/{repository['id']}/evidence", + params={ + "snapshotId": sample["snapshotId"], + "factId": sample["factId"], + "path": sample["path"], + "startLine": sample["startLine"], + "endLine": sample["endLine"], + }, + ) + assert source.status_code == 200, source.text + resolved = source.json() + assert resolved["snapshotId"] == manifest["snapshotId"] + assert resolved["revisionValue"] == manifest["revisionValue"] diff --git a/apps/backend/tests/test_route_authorization.py b/apps/backend/tests/test_route_authorization.py new file mode 100644 index 00000000..6c6b3f76 --- /dev/null +++ b/apps/backend/tests/test_route_authorization.py @@ -0,0 +1,209 @@ +"""Cross-route authorization sweep (E1.3 / #63). + +Two guards, both derived from the app's own router table so a newly added route +fails by omission rather than shipping unscoped: + +1. Every ownership-required route returns 401 without a valid token. +2. Every route that resolves a repository by id returns 404 (never 403) for a + caller who is not the owner, and 200 for the owner — so a cross-user request + is indistinguishable from a missing resource and never confirms it exists. +""" + +import re +import uuid + +from fastapi.routing import APIRoute + +from tests.api_assertions import assert_error_response + +# The routers guarded by get_current_user at the router level. +PROTECTED_PREFIXES = ("/repositories", "/analysis", "/ai", "/documentation", "/export") + +PLACEHOLDER_ID = "00000000-0000-0000-0000-000000000000" + + +def _collect_api_routes(router, acc: list[APIRoute]) -> None: + # FastAPI keeps an included router nested (as a ``_IncludedRouter`` exposing + # ``original_router``) rather than flattening it into app.routes, so walk the + # tree to reach every real APIRoute — a new route is discovered automatically. + for route in getattr(router, "routes", []) or []: + if isinstance(route, APIRoute): + acc.append(route) + original = getattr(route, "original_router", None) + if original is not None: + _collect_api_routes(original, acc) + elif getattr(route, "routes", None): + _collect_api_routes(route, acc) + + +def _protected_routes(app) -> list[tuple[str, str]]: + found: list[APIRoute] = [] + _collect_api_routes(app, found) + routes: set[tuple[str, str]] = set() + for route in found: + if not route.path.startswith(PROTECTED_PREFIXES): + continue + for method in sorted(route.methods - {"HEAD", "OPTIONS"}): + routes.add((method, route.path)) + return sorted(routes) + + +def _concrete_path(path: str) -> str: + return re.sub(r"\{[^}]+\}", PLACEHOLDER_ID, path) + + +def test_every_protected_route_requires_authentication(client): + routes = _protected_routes(client.app) + # The list is derived from the live router; if it is empty the discovery is + # broken and the guard would silently pass, so assert it found routes. + assert routes, "expected to discover protected routes from the router table" + + failures = [] + for method, path in routes: + url = _concrete_path(path) + # A required query param (e.g. the file route's `path`) is supplied so a + # missing-param 422 can never mask the 401 we are asserting; auth is + # enforced ahead of the handler regardless. + response = client.request(method, url, params={"path": "README.md"}) + try: + assert_error_response(response, 401, "unauthorized") + except AssertionError as exc: + failures.append((method, path, str(exc))) + + assert not failures, f"routes missing the standard unauthenticated response: {failures}" + + +def _seed_repository(owner_id: str) -> str: + from app.core.database import SessionLocal + from app.models.repository import RepositoryRecord + + db = SessionLocal() + try: + repository_id = str(uuid.uuid4()) + db.add( + RepositoryRecord( + id=repository_id, + owner_id=owner_id, + name="owned-repo", + source="upload", + local_path=f"/tmp/{repository_id}", + status="completed", + file_tree=[], + ) + ) + db.commit() + return repository_id + finally: + db.close() + + +# Each entry is (method, path template, optional json body key for repository id). +# For body-carrying routes the id travels in the payload, not the URL. +_CROSS_OWNER_ROUTES = [ + ("GET", "/repositories/{id}", None), + ("GET", "/repositories/{id}/file?path=README.md", None), + ("DELETE", "/repositories/{id}", None), + ("POST", "/analysis/{id}/start", None), + ("GET", "/analysis/{id}/status", None), + ("GET", "/analysis/{id}/architecture", None), + ("GET", "/analysis/{id}/dependencies", None), + ("GET", "/analysis/{id}/review", None), + ("GET", "/analysis/{id}/insights", None), + ("POST", "/documentation/generate", "repositoryId"), + ("POST", "/ai/query", "repositoryId"), + ("POST", "/export", "repositoryId"), +] + + +def test_repository_scoped_routes_return_404_for_a_non_owner(client, make_auth_headers): + alice = make_auth_headers("alice@example.com") + bob = make_auth_headers("bob@example.com") + repository_id = _seed_repository(alice["user"]["id"]) + + failures = [] + for method, template, body_key in _CROSS_OWNER_ROUTES: + url = template.replace("{id}", repository_id) + json_body = None + if body_key is not None: + url = template # body-carrying routes use a static path + json_body = {body_key: repository_id} + if template == "/documentation/generate": + json_body["format"] = "markdown" + elif template == "/ai/query": + json_body["query"] = "hello" + elif template == "/export": + json_body.update({"target": "review", "format": "json"}) + response = client.request(method, url, headers=bob["headers"], json=json_body) + # 404, never 403: the non-owner is told the resource does not exist + # rather than that it exists but is forbidden. + if response.status_code != 404: + failures.append((method, template, response.status_code)) + + assert not failures, f"routes leaking another user's repository (expected 404): {failures}" + + +def _repository_id_routes(app) -> list[tuple[str, str]]: + """Protected routes that carry the repository id in the URL, from the router. + + Every route with a ``{repository_id}`` path segment is discovered here, so a + newly added owner-scoped route is swept for the 404 contract by omission — + the same drift-proofing the issue comments require for the auth sweep. + """ + found: list[APIRoute] = [] + _collect_api_routes(app, found) + routes: set[tuple[str, str]] = set() + for route in found: + if "{repository_id}" not in route.path or not route.path.startswith(PROTECTED_PREFIXES): + continue + for method in sorted(route.methods - {"HEAD", "OPTIONS"}): + routes.add((method, route.path)) + return sorted(routes) + + +def test_repository_id_routes_return_404_for_a_non_owner_derived_from_router(client, make_auth_headers): + alice = make_auth_headers("alice@example.com") + bob = make_auth_headers("bob@example.com") + repository_id = _seed_repository(alice["user"]["id"]) + + routes = _repository_id_routes(client.app) + assert routes, "expected to discover repository-id routes from the router table" + + failures = [] + for method, path in routes: + url = path.replace("{repository_id}", repository_id) + # `path` query satisfies the file route; the owner check runs first for + # the rest, so 404 is returned before any body/param handling. + response = client.request(method, url, headers=bob["headers"], params={"path": "README.md"}) + if response.status_code != 404: + failures.append((method, path, response.status_code)) + + assert not failures, f"repository-id routes leaking another user's data (expected 404): {failures}" + + +def test_body_carrying_repository_routes_are_covered_by_the_cross_owner_sweep(client): + # The routes that take the repository id in the body (not the URL) can't be + # derived by path substitution, so they are listed explicitly above; guard + # that the explicit list still matches the live router so it can't silently + # drift as routes are added or renamed. + found: list[APIRoute] = [] + _collect_api_routes(client.app, found) + live_body_routes = { + (method, route.path) + for route in found + if route.path in {"/documentation/generate", "/ai/query", "/export"} + for method in sorted(route.methods - {"HEAD", "OPTIONS"}) + } + listed = {(m, t) for m, t, key in _CROSS_OWNER_ROUTES if key is not None} + assert listed == live_body_routes, ( + "body-carrying repository routes drifted from the cross-owner sweep list; " + f"router={live_body_routes} listed={listed}" + ) + + +def test_owner_can_read_their_own_repository(client, make_auth_headers): + alice = make_auth_headers("alice@example.com") + repository_id = _seed_repository(alice["user"]["id"]) + + response = client.get(f"/repositories/{repository_id}", headers=alice["headers"]) + assert response.status_code == 200 + assert response.json()["id"] == repository_id diff --git a/apps/backend/tests/test_schema_sync.py b/apps/backend/tests/test_schema_sync.py new file mode 100644 index 00000000..84967a1e --- /dev/null +++ b/apps/backend/tests/test_schema_sync.py @@ -0,0 +1,159 @@ +from pathlib import Path + +import pytest +from alembic import command +from alembic.config import Config +from sqlalchemy import create_engine, inspect + +from app.core.schema_sync import ( + SchemaDriftError, + current_revision, + ensure_schema_in_sync, + head_revision, + stamp_head, +) + +BACKEND_ROOT = Path(__file__).resolve().parents[1] + + +def _alembic_cfg() -> Config: + cfg = Config(str(BACKEND_ROOT / "alembic.ini")) + cfg.set_main_option("script_location", str(BACKEND_ROOT / "alembic")) + return cfg + + +@pytest.fixture() +def db_url(tmp_path, monkeypatch): + """Point global settings at a throwaway SQLite file. + + ``ensure_schema_in_sync``'s auto-upgrade path calls + ``alembic.command.upgrade``, which reads the database URL from process + settings (``alembic/env.py``) rather than the ``Engine`` object passed + to it, so the two must agree for these tests to target the same file. + """ + + database_path = tmp_path / "schema-sync-test.db" + url = f"sqlite:///{database_path}" + monkeypatch.setenv("DATABASE_URL", url) + monkeypatch.setenv("CORS_ORIGINS", "http://testserver") + + from app.core import config + + config.get_settings.cache_clear() + yield url + config.get_settings.cache_clear() + + +def test_fresh_database_via_create_all_gets_stamped_at_head(db_url): + from app.models.base import Base + + engine = create_engine(db_url) + try: + assert inspect(engine).get_table_names() == [] + Base.metadata.create_all(bind=engine) + stamp_head(engine) + assert current_revision(engine) == head_revision() + finally: + engine.dispose() + + +def test_behind_head_database_auto_upgrades_when_no_physical_conflict(db_url): + """A cleanly-stamped, genuinely-behind database upgrades without incident. + + 0007 only drops a column -- there is no table it could collide with, so + this is exactly the case that should auto-upgrade rather than refuse. + """ + + engine = create_engine(db_url) + try: + command.upgrade(_alembic_cfg(), "0006_analysis_jobs") + assert current_revision(engine) == "0006_analysis_jobs" + + ensure_schema_in_sync(engine, app_env="development") + + assert current_revision(engine) == head_revision() + columns = {column["name"] for column in inspect(engine).get_columns("repositories")} + assert "data_source" not in columns + finally: + engine.dispose() + + +def test_physical_drift_refuses_with_actionable_recovery_instead_of_crashing(db_url): + """The create_all-without-stamp state must never attempt a blind upgrade. + + Simulates the reported bug: stamped at 0005, but 0006's table already + exists physically, as if an earlier ``create_all`` built it without ever + advancing the stamp. A blind ``alembic upgrade head`` would crash with + "table analysis_jobs already exists"; this must instead raise a clear, + actionable error and leave the stamp untouched -- no crash loop. + """ + + from app.models.analysis_job import AnalysisJob + + engine = create_engine(db_url) + try: + command.upgrade(_alembic_cfg(), "0005_revision_snapshots") + AnalysisJob.__table__.create(bind=engine) + + with pytest.raises(SchemaDriftError) as excinfo: + ensure_schema_in_sync(engine, app_env="development") + + message = str(excinfo.value) + assert "analysis_jobs" in message + assert "0006_analysis_jobs" in message + assert "alembic stamp 0006_analysis_jobs" in message + assert "alembic upgrade head" in message + # Refusing must not leave the database half-upgraded or looping. + assert current_revision(engine) == "0005_revision_snapshots" + finally: + engine.dispose() + + +def test_physical_drift_does_not_silently_skip_a_pending_column_migration(db_url): + """The recommended stamp target must never jump past an unverified migration. + + 0007 (a plain column drop) gives no physical evidence of having already + run, so the recovery recommendation must stop at 0006 -- not 0007 -- + even though 0007 is technically 'pending' too. Recommending a stamp at + 0007 would silently skip the real, still-needed column drop forever. + """ + + from app.models.analysis_job import AnalysisJob + + engine = create_engine(db_url) + try: + command.upgrade(_alembic_cfg(), "0005_revision_snapshots") + AnalysisJob.__table__.create(bind=engine) + + with pytest.raises(SchemaDriftError) as excinfo: + ensure_schema_in_sync(engine, app_env="development") + + assert "alembic stamp 0006_analysis_jobs" in str(excinfo.value) + assert "0007" not in str(excinfo.value).split("Recover with:")[1].split("\n")[1] + finally: + engine.dispose() + + +@pytest.mark.parametrize("app_env", ["production", "staging"]) +def test_production_and_staging_never_auto_migrate(db_url, app_env): + engine = create_engine(db_url) + try: + command.upgrade(_alembic_cfg(), "0006_analysis_jobs") + + ensure_schema_in_sync(engine, app_env=app_env) + + assert current_revision(engine) == "0006_analysis_jobs" + finally: + engine.dispose() + + +def test_up_to_date_database_is_a_no_op(db_url): + engine = create_engine(db_url) + try: + command.upgrade(_alembic_cfg(), "head") + + ensure_schema_in_sync(engine, app_env="development") + + assert current_revision(engine) == head_revision() + finally: + engine.dispose() diff --git a/apps/backend/tests/test_security_headers.py b/apps/backend/tests/test_security_headers.py new file mode 100644 index 00000000..51b2c51e --- /dev/null +++ b/apps/backend/tests/test_security_headers.py @@ -0,0 +1,53 @@ +ALLOWED_ORIGIN = "http://testserver" # matches CORS_ORIGINS set by the client fixture + + +def test_security_headers_present_on_responses(client): + response = client.get("/health") + + assert response.status_code == 200 + assert response.headers["X-Content-Type-Options"] == "nosniff" + assert response.headers["X-Frame-Options"] == "DENY" + assert response.headers["Referrer-Policy"] == "no-referrer" + assert "max-age=" in response.headers["Strict-Transport-Security"] + assert response.headers["Content-Security-Policy"].startswith("default-src 'none'") + + +def test_csp_skipped_on_interactive_docs(client): + # The strict default-src 'none' policy would blank Swagger UI, so it must not + # be applied to the docs routes (the other headers still are). + response = client.get("/docs") + + assert response.status_code == 200 + assert "Content-Security-Policy" not in response.headers + assert response.headers["X-Content-Type-Options"] == "nosniff" + + +def test_cors_allows_configured_origin(client): + response = client.get("/health", headers={"Origin": ALLOWED_ORIGIN}) + + assert response.status_code == 200 + assert response.headers["access-control-allow-origin"] == ALLOWED_ORIGIN + + +def test_cors_rejects_unlisted_origin(client): + response = client.get("/health", headers={"Origin": "http://evil.example"}) + + assert response.status_code == 200 # the request still runs... + # ...but the browser is told it may not read the response. + assert "access-control-allow-origin" not in response.headers + + +def test_cors_preflight_reports_allowed_methods_not_wildcard(client): + response = client.options( + "/repositories", + headers={ + "Origin": ALLOWED_ORIGIN, + "Access-Control-Request-Method": "POST", + "Access-Control-Request-Headers": "content-type", + }, + ) + + assert response.status_code == 200 + allow_methods = response.headers["access-control-allow-methods"] + assert "POST" in allow_methods + assert "*" not in allow_methods diff --git a/apps/backend/tests/test_snapshot_persistence.py b/apps/backend/tests/test_snapshot_persistence.py new file mode 100644 index 00000000..97931dd2 --- /dev/null +++ b/apps/backend/tests/test_snapshot_persistence.py @@ -0,0 +1,922 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from uuid import uuid4 + +import pytest +from sqlalchemy import create_engine, func, select, update +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session, sessionmaker + +from app.core.database import register_sqlite_foreign_key_enforcement +from app.extraction.pipeline import ExtractionPipeline +from app.extraction.python import PythonExtractor +from app.intelligence import canonical +from app.intelligence.snapshot_store import ( + Evidence, + Revision, + SnapshotAlreadySealedError, + SnapshotImmutableError, + SnapshotSealError, + SnapshotStateError, + SnapshotStore, + edge_ref, + node_ref, + observation_ref, +) +from app.models import ( + RepositoryRecord, + RiDerivation, + RiDiagnostic, + RiEdge, + RiEvidence, + RiNode, + RiSnapshot, + User, +) +from app.models.base import Base + +UPLOAD_REVISION = "sha256:" + "a" * 64 +PRODUCERS = ["inventory@1.0.0", "resolver@1.0.0", "classifier@1.0.0"] + + +@pytest.fixture() +def db(tmp_path): + # These snapshot tables rely on foreign-key enforcement; SQLite only honors + # it when this listener is registered on the Engine class (idempotent). + register_sqlite_foreign_key_enforcement() + engine = create_engine(f"sqlite:///{tmp_path / 'snapshots.db'}") + Base.metadata.create_all(engine) + factory = sessionmaker(bind=engine) + with factory() as session: + yield session, factory + engine.dispose() + + +def _owner(session: Session, email: str = "owner@example.com") -> User: + owner = User(id=str(uuid4()), email=email, password_hash=None) + session.add(owner) + session.commit() + return owner + + +def _repository( + session: Session, + owner: User, + *, + revision_value: str = UPLOAD_REVISION, + source: str = "upload", + metadata: dict | None = None, +) -> RepositoryRecord: + kind = "upload" if revision_value.startswith("sha256:") else "git" + record = RepositoryRecord( + id=str(uuid4()), + owner_id=owner.id, + name=f"repo-{uuid4().hex[:6]}", + source=source, + source_url="https://github.com/example/repo" if source == "github" else None, + branch="main" if source == "github" else None, + revision_kind=kind, + revision_value=revision_value, + revision_ref="refs/heads/main" if kind == "git" else None, + local_path="/stored/revision", + status="completed", + repo_metadata=metadata, + file_tree=[], + ) + session.add(record) + session.commit() + return record + + +def _evidence( + path: str, + start: int = 1, + end: int = 1, + *, + producer: str = "inventory", + version: str = "1.0.0", + logical_lines: int = 100, +) -> Evidence: + return Evidence( + path=path, + start_line=start, + end_line=end, + extractor=producer, + extractor_version=version, + logical_line_count=logical_lines, + ) + + +def _populate( + store: SnapshotStore, + snapshot: RiSnapshot, + *, + reverse_evidence: bool = False, + logical_lines: int = 100, +): + root_evidence = [ + _evidence("README.md", 1, 2, logical_lines=logical_lines), + _evidence("README.md", 4, 4, logical_lines=logical_lines), + ] + if reverse_evidence: + root_evidence.reverse() + store.add_node( + snapshot, + node_kind="repository", + stable_key="repo:root", + name="repository", + evidence=root_evidence, + ) + store.add_node( + snapshot, + node_kind="file", + stable_key="file:src/main.py", + name="main.py", + language="python", + evidence=[_evidence("src/main.py", 1, 20, logical_lines=logical_lines)], + ) + observation = store.add_observation( + snapshot, + observed_kind="contains", + subject_kind="repository", + subject_key="repo:root", + referent_text="src/main.py", + evidence=_evidence("src/main.py", 1, 20, logical_lines=logical_lines), + ) + edge = store.add_edge( + snapshot, + subject_kind="repository", + subject_key="repo:root", + predicate="contains", + object_kind="file", + object_key="file:src/main.py", + producer="resolver", + producer_version="1.0.0", + evidence=[_evidence("src/main.py", 1, 20, producer="resolver", logical_lines=logical_lines)], + derived_from=[observation_ref(observation.observation_id)], + ) + store.add_assertion( + snapshot, + subject_kind="file", + subject_key="file:src/main.py", + predicate="classified_as", + value={"classification": "entrypoint", "confidence": "heuristic"}, + producer="classifier", + producer_version="1.0.0", + derived_from=[node_ref("file:src/main.py"), edge_ref(edge.edge_id)], + ) + + +def _begin(store: SnapshotStore, repository: RepositoryRecord, **kwargs) -> RiSnapshot: + return store.begin( + repository_id=repository.id, + revision=Revision(repository.revision_kind, repository.revision_value, repository.revision_ref), + producer_version_set=kwargs.pop("producer_version_set", PRODUCERS), + **kwargs, + ) + + +def _seal(store: SnapshotStore, repository: RepositoryRecord, **kwargs) -> RiSnapshot: + snapshot = _begin(store, repository, **kwargs) + _populate(store, snapshot) + return store.seal(snapshot) + + +def test_semantic_identity_reuse_new_inputs_and_failed_attempts(db): + session, _ = db + owner = _owner(session) + repository = _repository(session, owner) + store = SnapshotStore(session) + sealed = _seal(store, repository) + + reused, was_reused = store.get_or_reuse( + repository_id=repository.id, + revision=Revision("upload", UPLOAD_REVISION), + producer_version_set=list(reversed(PRODUCERS)) + [PRODUCERS[0]], + ) + assert was_reused is True + assert reused.snapshot_id == sealed.snapshot_id + + changed_config, reused_config = store.get_or_reuse( + repository_id=repository.id, + revision=Revision("upload", UPLOAD_REVISION), + producer_version_set=PRODUCERS, + config={"pipeline": ["extract", "resolve"]}, + ) + assert reused_config is False and changed_config.state == "building" + store.mark_failed(changed_config, code="RI-INT-FAILURE") + assert ( + store.find_completed( + repository_id=repository.id, + revision_value=UPLOAD_REVISION, + schema_version="ri.v1", + producer_version_set=PRODUCERS, + config_hash=changed_config.config_hash, + ) + is None + ) + + changed_producers = _begin(store, repository, producer_version_set=PRODUCERS + ["new@2.0.0"]) + changed_schema = _begin(store, repository, schema_version="ri.v2") + assert changed_producers.snapshot_id != sealed.snapshot_id + assert changed_schema.snapshot_id != sealed.snapshot_id + assert session.get(RiSnapshot, sealed.snapshot_id).canonical_graph_hash == sealed.canonical_graph_hash + with pytest.raises(SnapshotSealError, match="does not match"): + store.begin( + repository_id=repository.id, + revision=Revision("upload", UPLOAD_REVISION), + producer_version_set=PRODUCERS, + config={"pipeline": ["extract", "resolve"]}, + config_hash=canonical.compute_config_hash({}), + ) + + +def test_changed_repository_revision_creates_new_record_and_snapshot_without_rewriting_history(db): + session, _ = db + owner = _owner(session) + first_repository = _repository(session, owner, revision_value="sha256:" + "a" * 64) + first_snapshot = _seal(SnapshotStore(session), first_repository) + first_hash = first_snapshot.canonical_graph_hash + + second_repository = _repository(session, owner, revision_value="sha256:" + "b" * 64) + second_snapshot = _seal(SnapshotStore(session), second_repository) + + assert first_repository.id != second_repository.id + assert first_snapshot.snapshot_id != second_snapshot.snapshot_id + assert first_snapshot.revision_value != second_snapshot.revision_value + assert session.get(RiSnapshot, first_snapshot.snapshot_id).canonical_graph_hash == first_hash + + +def test_revision_must_match_repository_and_owner_scoped_accessors_hide_cross_owner(db): + session, _ = db + owner = _owner(session) + other = _owner(session, "other@example.com") + repository = _repository(session, owner) + sealed = _seal(SnapshotStore(session), repository) + store = SnapshotStore(session) + + with pytest.raises(SnapshotSealError, match="does not match"): + store.begin( + repository_id=repository.id, + revision=Revision("upload", "sha256:" + "b" * 64), + producer_version_set=PRODUCERS, + ) + assert store.get_for_owner(sealed.snapshot_id, owner.id) is not None + assert store.get_for_owner(sealed.snapshot_id, other.id) is None + assert store.get_for_owner("snap_missing", owner.id) is None + assert ( + store.find_completed_for_owner( + owner_id=other.id, + repository_id=repository.id, + revision_value=repository.revision_value, + schema_version=sealed.schema_version, + producer_version_set=sealed.producer_version_set, + config_hash=sealed.config_hash, + ) + is None + ) + + +def test_repository_revision_identity_is_constrained_and_immutable(db): + session, _ = db + owner = _owner(session) + repository = _repository(session, owner) + with pytest.raises(ValueError, match="immutable"): + repository.revision_value = "sha256:" + "b" * 64 + with pytest.raises(ValueError, match="immutable"): + repository.revision_kind = "git" + + invalid = RepositoryRecord( + id=str(uuid4()), + owner_id=owner.id, + name="invalid", + source="github", + revision_kind="git", + revision_value="NOT-LOWERCASE-HEX".ljust(40, "x"), + revision_ref="main", + local_path="/invalid", + status="completed", + ) + session.add(invalid) + with pytest.raises(IntegrityError): + session.commit() + session.rollback() + + +def test_provenance_paths_spans_truth_classes_and_declared_arrays_are_enforced(db): + session, _ = db + repository = _repository(session, _owner(session)) + store = SnapshotStore(session) + snapshot = _begin(store, repository) + + with pytest.raises(canonical.PathEscapeError): + store.add_node( + snapshot, + node_kind="file", + stable_key="file:../secret.txt", + evidence=[_evidence("../secret.txt")], + ) + with pytest.raises(SnapshotSealError, match="invalid evidence span"): + store.add_node( + snapshot, + node_kind="file", + stable_key="file:short.py", + evidence=[_evidence("short.py", 1, 4, logical_lines=3)], + ) + with pytest.raises(canonical.CanonicalizationError, match="no declared set/order semantics"): + store.add_node( + snapshot, + node_kind="repository", + stable_key="repo:root", + properties={"tags": ["one", "two"]}, + evidence=[_evidence("README.md")], + ) + with pytest.raises(IntegrityError): + store.add_node( + snapshot, + node_kind="repository", + stable_key="repo:root", + truth_class="generated", + evidence=[_evidence("README.md")], + ) + session.rollback() + + +def test_unresolved_and_cyclic_derivations_fail_and_never_become_authoritative(db): + session, _ = db + repository = _repository(session, _owner(session)) + store = SnapshotStore(session) + + unresolved = _begin(store, repository, config={"attempt": "unresolved"}) + store.add_node( + unresolved, + node_kind="repository", + stable_key="repo:root", + evidence=[_evidence("README.md")], + ) + store.add_assertion( + unresolved, + subject_kind="repository", + subject_key="repo:root", + predicate="classified_as", + value={"classification": "service"}, + producer="classifier", + producer_version="1.0.0", + derived_from=[node_ref("file:missing.py")], + ) + with pytest.raises(SnapshotSealError, match="does not resolve"): + store.seal(unresolved) + assert unresolved.state == "failed" + + cyclic = _begin(store, repository, config={"attempt": "cycle"}) + store.add_node(cyclic, node_kind="repository", stable_key="repo:root", evidence=[_evidence("README.md")]) + for path in ("a.py", "b.py"): + store.add_node( + cyclic, + node_kind="file", + stable_key=f"file:{path}", + evidence=[_evidence(path)], + ) + obs_a = store.add_observation( + cyclic, + observed_kind="contains", + subject_kind="repository", + subject_key="repo:root", + referent_text="a.py", + evidence=_evidence("a.py"), + ) + obs_b = store.add_observation( + cyclic, + observed_kind="contains", + subject_kind="repository", + subject_key="repo:root", + referent_text="b.py", + evidence=_evidence("b.py"), + ) + edge_a_id = canonical.compute_edge_id("repo:root", "contains", "file:a.py") + edge_b_id = canonical.compute_edge_id("repo:root", "contains", "file:b.py") + store.add_edge( + cyclic, + subject_kind="repository", + subject_key="repo:root", + predicate="contains", + object_kind="file", + object_key="file:a.py", + producer="resolver", + producer_version="1.0.0", + evidence=[_evidence("a.py", producer="resolver")], + derived_from=[observation_ref(obs_a.observation_id), edge_ref(edge_b_id)], + ) + store.add_edge( + cyclic, + subject_kind="repository", + subject_key="repo:root", + predicate="contains", + object_kind="file", + object_key="file:b.py", + producer="resolver", + producer_version="1.0.0", + evidence=[_evidence("b.py", producer="resolver")], + derived_from=[observation_ref(obs_b.observation_id), edge_ref(edge_a_id)], + ) + with pytest.raises(SnapshotSealError, match="cycle"): + store.seal(cyclic) + assert cyclic.state == "failed" + + +def test_fatal_diagnostic_fails_snapshot_but_nonfatal_diagnostic_can_seal(db): + session, _ = db + repository = _repository(session, _owner(session)) + store = SnapshotStore(session) + fatal = _begin(store, repository, config={"attempt": "fatal"}) + store.add_diagnostic( + fatal, + code="RI-INT-FAILURE", + category="internal failure", + severity="fatal", + message="The pipeline could not produce a coherent graph.", + producer="inventory@1.0.0", + ) + with pytest.raises(SnapshotSealError, match="fatal diagnostic"): + store.seal(fatal) + assert fatal.state == "failed" and fatal.failure_code == "RI-FATAL-DIAGNOSTIC" + + nonfatal = _begin(store, repository, config={"attempt": "warning"}) + _populate(store, nonfatal) + diagnostic = store.add_diagnostic( + nonfatal, + code="RI-RES-UNRESOLVED", + category="unresolved reference", + severity="warning", + message="A reference could not be resolved.", + producer="resolver@1.0.0", + path="src/main.py", + span=(3, 3), + details={"candidates": ["src/z.py::target", "src/a.py::target", "src/a.py::target"]}, + ) + assert diagnostic.details["candidates"] == ["src/a.py::target", "src/z.py::target"] + assert store.seal(nonfatal).state == "completed" + + +def test_diagnostics_only_real_extraction_emits_root_and_seals(db): + """A non-fatal parse error must not violate the mandatory root invariant.""" + + session, _ = db + repository = _repository(session, _owner(session)) + runs = ExtractionPipeline((PythonExtractor(),)).run({"src/broken.py": b"def broken(:\n return\n"}) + producers = [run.producer for run in runs] + store = SnapshotStore(session) + snapshot = _begin(store, repository, producer_version_set=producers) + + for run in runs: + for node in run.result.nodes: + store.add_node( + snapshot, + node_kind=node.node_kind, + stable_key=node.stable_key, + name=node.name, + language=node.language, + properties=node.properties, + evidence=[ + Evidence( + path=evidence.path, + start_line=evidence.start_line, + end_line=evidence.end_line, + extractor=run.producer_name, + extractor_version=run.producer_version, + logical_line_count=evidence.logical_line_count, + granularity=evidence.granularity, + ) + for evidence in node.evidence + ], + ) + for diagnostic in run.result.diagnostics: + store.add_diagnostic( + snapshot, + code=diagnostic.code, + category=diagnostic.category, + severity=diagnostic.severity, + message=diagnostic.message, + producer=run.producer, + path=diagnostic.path, + span=diagnostic.span, + subject=diagnostic.subject, + details=diagnostic.details, + ) + + assert [node.stable_key for run in runs for node in run.result.nodes] == ["repo:root"] + assert [diagnostic.code for run in runs for diagnostic in run.result.diagnostics] == ["RI-SRC-MALFORMED"] + assert store.seal(snapshot).state == "completed" + + +def test_every_completed_snapshot_mutation_route_is_rejected(db): + session, factory = db + repository = _repository(session, _owner(session)) + sealed = _seal(SnapshotStore(session), repository) + snapshot_id = sealed.snapshot_id + session.close() + + with factory() as isolated: + snapshot = isolated.get(RiSnapshot, snapshot_id) + snapshot.actual_producers.append("tampered@9.9.9") + with pytest.raises(SnapshotImmutableError): + isolated.commit() + isolated.rollback() + + with factory() as isolated: + node = isolated.scalars(select(RiNode).where(RiNode.snapshot_id == snapshot_id)).first() + node.name = "tampered" + with pytest.raises(SnapshotImmutableError): + isolated.commit() + isolated.rollback() + + with factory() as isolated: + node = isolated.scalars(select(RiNode).where(RiNode.snapshot_id == snapshot_id)).first() + isolated.delete(node) + with pytest.raises(SnapshotImmutableError): + isolated.commit() + isolated.rollback() + + with factory() as isolated: + isolated.add( + RiDiagnostic( + snapshot_id=snapshot_id, + code="RI-TEST", + category="internal failure", + severity="info", + message="late write", + producer="inventory@1.0.0", + ) + ) + with pytest.raises(SnapshotImmutableError): + isolated.commit() + isolated.rollback() + + with factory() as isolated: + with pytest.raises(SnapshotImmutableError): + isolated.execute(update(RiNode).where(RiNode.snapshot_id == snapshot_id).values(name="bulk")) + + with factory() as isolated: + snapshot = isolated.get(RiSnapshot, snapshot_id) + isolated.delete(snapshot) + with pytest.raises(SnapshotImmutableError): + isolated.commit() + + +def test_database_uniqueness_foreign_keys_and_same_snapshot_provenance(db): + session, _ = db + repository = _repository(session, _owner(session)) + store = SnapshotStore(session) + first = _begin(store, repository, config={"attempt": "first"}) + second = _begin(store, repository, config={"attempt": "second"}) + node_one = store.add_node(first, node_kind="repository", stable_key="repo:root", evidence=[_evidence("README.md")]) + node_two = store.add_node(second, node_kind="repository", stable_key="repo:root", evidence=[_evidence("README.md")]) + session.commit() + + session.add( + RiNode(snapshot_id=first.snapshot_id, stable_key="repo:root", node_kind="repository", truth_class="observed") + ) + with pytest.raises(IntegrityError): + session.commit() + session.rollback() + + session.add( + RiEvidence( + snapshot_id=first.snapshot_id, + node_ref=node_two.id, + path="README.md", + start_line=1, + end_line=1, + granularity="span", + extractor="inventory", + extractor_version="1.0.0", + ) + ) + with pytest.raises(IntegrityError): + session.commit() + session.rollback() + assert node_one.snapshot_id != node_two.snapshot_id + + session.add( + RiSnapshot( + snapshot_id=f"snap_{uuid4().hex}", + repository_id=repository.id, + revision_kind="upload", + revision_value="sha256:" + "c" * 64, + revision_ref=None, + schema_version="ri.v1", + producer_version_set=[], + producer_set_hash=canonical.producer_set_hash([]), + config_hash=canonical.compute_config_hash({}), + state="building", + created_at=datetime.now(UTC), + updated_at=datetime.now(UTC), + ) + ) + with pytest.raises(IntegrityError): + session.commit() + session.rollback() + + +def test_equivalent_concurrent_attempts_allow_only_one_completed_snapshot(db): + session, _ = db + repository = _repository(session, _owner(session)) + store = SnapshotStore(session) + first = _begin(store, repository) + second = _begin(store, repository) + _populate(store, first) + _populate(store, second, reverse_evidence=True) + sealed = store.seal(first) + with pytest.raises(SnapshotAlreadySealedError) as error: + store.seal(second) + assert error.value.existing.snapshot_id == sealed.snapshot_id + assert session.scalar(select(func.count()).select_from(RiSnapshot).where(RiSnapshot.state == "completed")) == 1 + + +def test_duplicate_semantic_edges_collapse_and_union_normalized_provenance(db): + session, _ = db + repository = _repository(session, _owner(session)) + store = SnapshotStore(session) + snapshot = _begin(store, repository) + store.add_node(snapshot, node_kind="repository", stable_key="repo:root", evidence=[_evidence("README.md")]) + store.add_node( + snapshot, + node_kind="file", + stable_key="file:main.py", + evidence=[_evidence("main.py", 1, 10)], + ) + first_observation = store.add_observation( + snapshot, + observed_kind="contains", + subject_kind="repository", + subject_key="repo:root", + referent_text="main.py", + ordinal=1, + evidence=_evidence("main.py", 1, 1), + ) + second_observation = store.add_observation( + snapshot, + observed_kind="contains", + subject_kind="repository", + subject_key="repo:root", + referent_text="main.py", + ordinal=2, + evidence=_evidence("main.py", 7, 7), + ) + first_edge = store.add_edge( + snapshot, + subject_kind="repository", + subject_key="repo:root", + predicate="contains", + object_kind="file", + object_key="file:main.py", + producer="resolver", + producer_version="1.0.0", + evidence=[_evidence("main.py", 1, 1, producer="resolver")], + derived_from=[observation_ref(first_observation.observation_id)], + ) + second_edge = store.add_edge( + snapshot, + subject_kind="repository", + subject_key="repo:root", + predicate="contains", + object_kind="file", + object_key="file:main.py", + producer="resolver", + producer_version="1.0.0", + evidence=[_evidence("main.py", 7, 7, producer="resolver")], + derived_from=[observation_ref(second_observation.observation_id)], + ) + assert first_edge.id == second_edge.id + assert ( + session.scalar(select(func.count()).select_from(RiEdge).where(RiEdge.snapshot_id == snapshot.snapshot_id)) == 1 + ) + assert session.scalar(select(func.count()).select_from(RiEvidence).where(RiEvidence.edge_ref == first_edge.id)) == 2 + assert ( + session.scalar(select(func.count()).select_from(RiDerivation).where(RiDerivation.edge_ref == first_edge.id)) + == 2 + ) + assert store.seal(snapshot).state == "completed" + + +def test_equivalent_graphs_hash_identically_across_insertion_orders_and_repo_ids(db): + session, _ = db + owner = _owner(session) + first_repo = _repository(session, owner) + second_repo = _repository(session, owner) + first = _begin(SnapshotStore(session), first_repo) + _populate(SnapshotStore(session), first, reverse_evidence=False) + first = SnapshotStore(session).seal(first) + second = _begin(SnapshotStore(session), second_repo) + _populate(SnapshotStore(session), second, reverse_evidence=True) + second = SnapshotStore(session).seal(second) + assert first.repository_id != second.repository_id + assert first.canonical_graph_hash == second.canonical_graph_hash + + +def test_legacy_regex_metadata_is_not_promoted_into_ri_v1_facts(db): + session, _ = db + repository = _repository( + session, + _owner(session), + metadata={"intelligence": {"nodes": [{"id": "regex-node"}], "relationships": []}}, + ) + snapshot = _begin(SnapshotStore(session), repository) + assert ( + session.scalar(select(func.count()).select_from(RiNode).where(RiNode.snapshot_id == snapshot.snapshot_id)) == 0 + ) + assert repository.repo_metadata["intelligence"]["nodes"][0]["id"] == "regex-node" + + +def test_store_rejects_writes_after_failed_snapshot(db): + session, _ = db + repository = _repository(session, _owner(session)) + store = SnapshotStore(session) + snapshot = _begin(store, repository) + store.mark_failed(snapshot, code="RI-INT-FAILURE") + snapshot.state = "completed" + snapshot.canonical_graph_hash = "sha256:" + "f" * 64 + snapshot.sealed_at = datetime.now(UTC) + with pytest.raises(SnapshotStateError, match="must go through SnapshotStore"): + session.commit() + session.rollback() + with pytest.raises(SnapshotStateError): + store.add_node(snapshot, node_kind="repository", stable_key="repo:root", evidence=[_evidence("README.md")]) + + +def _single_evidence(session: Session, snapshot: RiSnapshot) -> RiEvidence: + return session.scalars(select(RiEvidence).where(RiEvidence.snapshot_id == snapshot.snapshot_id)).one() + + +def test_post_insert_span_mutation_beyond_logical_lines_cannot_seal_and_fails(db): + session, _ = db + repository = _repository(session, _owner(session)) + store = SnapshotStore(session) + snapshot = _begin(store, repository) + store.add_node( + snapshot, + node_kind="repository", + stable_key="repo:root", + evidence=[_evidence("README.md", 1, 1, logical_lines=1)], + ) + # Stretch a persisted span past the single logical line it was allowed. + _single_evidence(session, snapshot).end_line = 999 + + with pytest.raises(SnapshotSealError, match="not bounded by"): + store.seal(snapshot) + + assert snapshot.state == "failed" + assert session.scalar(select(func.count()).select_from(RiSnapshot).where(RiSnapshot.state == "completed")) == 0 + # The rejected mutation never reached the persisted row. + assert _single_evidence(session, snapshot).end_line == 1 + + +def test_post_insert_traversing_evidence_path_cannot_seal_and_fails(db): + session, _ = db + repository = _repository(session, _owner(session)) + store = SnapshotStore(session) + snapshot = _begin(store, repository) + store.add_node( + snapshot, + node_kind="repository", + stable_key="repo:root", + evidence=[_evidence("README.md", 1, 1)], + ) + # A traversal survives the DB's leading-slash check but must never seal. + _single_evidence(session, snapshot).path = "../../etc/passwd" + + with pytest.raises(SnapshotSealError, match="escapes the repository root"): + store.seal(snapshot) + + assert snapshot.state == "failed" + assert _single_evidence(session, snapshot).path == "README.md" + + +def test_post_insert_unnormalized_evidence_path_cannot_seal(db): + session, _ = db + repository = _repository(session, _owner(session)) + store = SnapshotStore(session) + snapshot = _begin(store, repository) + store.add_node( + snapshot, + node_kind="repository", + stable_key="repo:root", + evidence=[_evidence("README.md", 1, 1)], + ) + # Legal to store (no leading slash) but not in normalized form. + _single_evidence(session, snapshot).path = "./README.md" + + with pytest.raises(SnapshotSealError, match="not in normalized form"): + store.seal(snapshot) + assert snapshot.state == "failed" + + +def test_database_rejects_evidence_span_beyond_logical_line_count(db): + session, _ = db + repository = _repository(session, _owner(session)) + store = SnapshotStore(session) + snapshot = _begin(store, repository) + node = store.add_node( + snapshot, + node_kind="repository", + stable_key="repo:root", + evidence=[_evidence("README.md", 1, 1, logical_lines=1)], + ) + session.commit() + + session.add( + RiEvidence( + snapshot_id=snapshot.snapshot_id, + node_ref=node.id, + path="README.md", + start_line=1, + end_line=999, + logical_line_count=1, + granularity="span", + extractor="inventory", + extractor_version="1.0.0", + ) + ) + with pytest.raises(IntegrityError): + session.commit() + session.rollback() + + +def test_conflicting_logical_line_count_on_duplicate_evidence_is_rejected(db): + session, _ = db + repository = _repository(session, _owner(session)) + store = SnapshotStore(session) + snapshot = _begin(store, repository) + with pytest.raises(SnapshotSealError, match="conflicting logical_line_count"): + store.add_node( + snapshot, + node_kind="repository", + stable_key="repo:root", + evidence=[ + _evidence("README.md", 1, 1, logical_lines=10), + _evidence("README.md", 1, 1, logical_lines=20), + ], + ) + session.rollback() + + +def test_get_or_reuse_validates_revision_and_repository_before_reuse(db): + session, _ = db + owner = _owner(session) + repository = _repository(session, owner) + store = SnapshotStore(session) + sealed = _seal(store, repository) + + # Malformed: a 'git' request carrying the sealed snapshot's upload value. + # The old code matched find_completed on value alone and would have reused + # the upload snapshot; the revision must be validated first. + with pytest.raises(SnapshotSealError, match="git revisions require"): + store.get_or_reuse( + repository_id=repository.id, + revision=Revision("git", UPLOAD_REVISION), + producer_version_set=PRODUCERS, + ) + + # A valid but mismatched revision is rejected before the reuse lookup. + with pytest.raises(SnapshotSealError, match="does not match"): + store.get_or_reuse( + repository_id=repository.id, + revision=Revision("upload", "sha256:" + "b" * 64), + producer_version_set=PRODUCERS, + ) + + # A valid identical semantic identity still reuses the completed snapshot. + reused, was_reused = store.get_or_reuse( + repository_id=repository.id, + revision=Revision("upload", UPLOAD_REVISION), + producer_version_set=PRODUCERS, + ) + assert was_reused is True and reused.snapshot_id == sealed.snapshot_id + + # A different valid semantic identity still starts a new building attempt. + fresh, reused_fresh = store.get_or_reuse( + repository_id=repository.id, + revision=Revision("upload", UPLOAD_REVISION), + producer_version_set=PRODUCERS, + config={"pipeline": ["extract"]}, + ) + assert reused_fresh is False and fresh.state == "building" + + +def test_logical_line_count_is_internal_and_does_not_alter_the_canonical_hash(db): + session, _ = db + owner = _owner(session) + first_repo = _repository(session, owner) + second_repo = _repository(session, owner) + first = _begin(SnapshotStore(session), first_repo) + _populate(SnapshotStore(session), first, logical_lines=100) + first = SnapshotStore(session).seal(first) + second = _begin(SnapshotStore(session), second_repo) + _populate(SnapshotStore(session), second, logical_lines=500) + second = SnapshotStore(session).seal(second) + + assert first.state == "completed" and second.state == "completed" + # Producers reported different logical-line counts for identical graphs; the + # canonical hash excludes that internal metadata, so the hashes still match. + assert first.canonical_graph_hash == second.canonical_graph_hash diff --git a/apps/backend/tests/test_sqlite_concurrency.py b/apps/backend/tests/test_sqlite_concurrency.py new file mode 100644 index 00000000..74da474a --- /dev/null +++ b/apps/backend/tests/test_sqlite_concurrency.py @@ -0,0 +1,113 @@ +"""SQLite concurrency hardening (#162): WAL journaling and a busy-wait timeout. + +Without WAL, SQLite's default rollback-journal mode briefly locks out +readers around each write commit; a writer committing rapidly (mirrors the +analysis worker persisting many facts stage by stage) alongside readers +polling concurrently (mirrors repeated status/list requests) adds up to real +"database is locked" errors -- exactly what #161's validation produced. WAL +lets a reader always see the last committed snapshot without waiting on the +writer at all. PostgreSQL is unaffected: it uses MVCC and never takes this +kind of lock for an ordinary transaction. +""" + +import sqlite3 +import threading +import time +from unittest.mock import MagicMock + +from sqlalchemy import text + +from app.core.database import SQLITE_BUSY_TIMEOUT_MS, _configure_sqlite_concurrency +from tests.conftest import register_user + + +def test_wal_and_busy_timeout_applied_on_a_real_sqlite_connection(tmp_path): + connection = sqlite3.connect(str(tmp_path / "pragma-check.db")) + try: + _configure_sqlite_concurrency(connection, None) + cursor = connection.cursor() + assert cursor.execute("PRAGMA journal_mode").fetchone()[0].lower() == "wal" + assert cursor.execute("PRAGMA busy_timeout").fetchone()[0] == SQLITE_BUSY_TIMEOUT_MS + finally: + connection.close() + + +def test_pragmas_are_never_attempted_on_a_non_sqlite_connection(): + not_sqlite = MagicMock() + + _configure_sqlite_concurrency(not_sqlite, None) + + not_sqlite.cursor.assert_not_called() + + +def test_concurrent_reads_survive_a_writer_committing_rapidly(client): + """The reader/writer shape from #162's reproduction, deterministically. + + SQLite's default rollback-journal mode briefly locks out readers around + each write commit (not only for genuinely long-held transactions) -- + with a writer committing continuously (mirrors the analysis worker + persisting many nodes/observations/diagnostics stage by stage) and + readers polling concurrently (mirrors repeated status/list requests), + that adds up to real "database is locked" errors, reproduced below with + the reader's own busy-wait removed so contention cannot hide behind a + generous default retry. WAL removes the collision entirely: a WAL reader + always sees the last committed snapshot without waiting on the writer at + all, regardless of how often it commits. + """ + + from app.core.database import SessionLocal + + auth = register_user(client, "sqlite-concurrency@example.com") + user_id = auth["user"]["id"] + + stop = threading.Event() + errors: list[BaseException] = [] + reads_done = 0 + + def commit_rapidly() -> None: + session = SessionLocal() + counter = 0 + try: + while not stop.is_set(): + session.execute( + text("UPDATE users SET email = :email WHERE id = :id"), + {"email": f"concurrency-{counter}@example.com", "id": user_id}, + ) + session.commit() + counter += 1 + except BaseException as exc: # noqa: BLE001 - surfaced to the assertion below + errors.append(exc) + finally: + session.rollback() + session.close() + + def read_repeatedly() -> None: + nonlocal reads_done + session = SessionLocal() + # Zero tolerance: fail immediately on any lock instead of masking + # real contention behind the driver's own generous default retry. + session.execute(text("PRAGMA busy_timeout=0")) + try: + while not stop.is_set(): + session.execute(text("SELECT * FROM users WHERE id = :id"), {"id": user_id}).fetchall() + reads_done += 1 + except BaseException as exc: # noqa: BLE001 - surfaced to the assertion below + errors.append(exc) + finally: + session.close() + + writer = threading.Thread(target=commit_rapidly) + readers = [threading.Thread(target=read_repeatedly) for _ in range(3)] + writer.start() + for reader in readers: + reader.start() + + time.sleep(0.5) + stop.set() + writer.join(timeout=5) + for reader in readers: + reader.join(timeout=5) + + assert not writer.is_alive() and all(not reader.is_alive() for reader in readers), "a thread did not finish" + assert reads_done > 0, "the read loop never completed a single read -- test is not exercising real contention" + assert not errors, errors diff --git a/apps/backend/tests/test_system.py b/apps/backend/tests/test_system.py index b6cc6388..cf63713a 100644 --- a/apps/backend/tests/test_system.py +++ b/apps/backend/tests/test_system.py @@ -9,7 +9,7 @@ def test_health_endpoint(client): assert response.status_code == 200 assert response.json()["status"] == "ok" - assert response.json()["environment"] == "development" + assert response.json()["environment"] == "test" def test_readiness_endpoint(client): @@ -18,15 +18,15 @@ def test_readiness_endpoint(client): assert response.status_code == 200 assert response.json() == { "status": "ready", - "environment": "development", + "environment": "test", "checks": {"database": "ok", "storage": "ok"}, } -def test_metrics_endpoint_exposes_request_counters(client): - client.get("/health") +def test_metrics_endpoint_exposes_request_counters(auth_client): + auth_client.get("/health") - response = client.get("/metrics") + response = auth_client.get("/metrics") assert response.status_code == 200 assert "text/plain" in response.headers["content-type"] @@ -51,7 +51,7 @@ def test_readiness_endpoint_reports_database_failure(client, monkeypatch): assert response.status_code == 503 assert response.json() == { "status": "not_ready", - "environment": "development", + "environment": "test", "checks": {"database": "error", "storage": "ok"}, } @@ -66,7 +66,7 @@ def test_readiness_endpoint_reports_storage_failure(client, monkeypatch): assert response.status_code == 503 assert response.json() == { "status": "not_ready", - "environment": "development", + "environment": "test", "checks": {"database": "ok", "storage": "error"}, } @@ -93,8 +93,10 @@ def test_http_errors_use_standard_shape(client): } -def test_request_validation_errors_use_standard_shape(client): - response = client.post("/repositories/github", json={}) +def test_request_validation_errors_use_standard_shape(auth_client): + # Authenticated so the request reaches body validation: /repositories is + # auth-guarded at the router level, and an anonymous call would 401 first. + response = auth_client.post("/repositories/github", json={}) assert response.status_code == 422 body = response.json() @@ -125,6 +127,10 @@ def boom() -> None: "details": None, "request_id": response.headers["X-Request-ID"], } + body = response.text.lower() + assert "boom" not in body + assert "runtimeerror" not in body + assert "traceback" not in body def test_json_logging_includes_structured_fields(capsys): @@ -172,6 +178,24 @@ def test_settings_rejects_invalid_log_format(): Settings(log_format="pretty") +def test_production_analysis_worker_ids_are_unique_with_the_same_pid(): + """Two workers in one process must be two distinct queue owners (#324). + + Every control-plane ownership guard is ``worker_id`` equality, so a token + that collided between two workers in the same process would let each mutate + the other's job. + """ + + from app.workers.runner import new_worker_id + + first = new_worker_id(pid=42) + second = new_worker_id(pid=42) + + assert first != second + assert first.startswith("analysis-worker-42-") + assert len(first) <= 64 + + def test_settings_rejects_invalid_database_url(): from pydantic import ValidationError diff --git a/apps/backend/tests/test_upload_path_safety.py b/apps/backend/tests/test_upload_path_safety.py new file mode 100644 index 00000000..b754bcec --- /dev/null +++ b/apps/backend/tests/test_upload_path_safety.py @@ -0,0 +1,84 @@ +import asyncio +import io +import zipfile +from pathlib import Path + +import pytest +from fastapi import UploadFile + +from app.core.config import get_settings +from app.storage.local import LocalStorage + + +class _FakeUpload: + """Minimal UploadFile-like object with an attacker-controlled filename.""" + + def __init__(self, filename: str, data: bytes) -> None: + self.filename = filename + self._buffer = io.BytesIO(data) + + async def read(self, size: int = -1) -> bytes: + return self._buffer.read(size) + + +@pytest.fixture() +def storage(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> LocalStorage: + monkeypatch.setenv("STORAGE_PATH", str(tmp_path / "storage")) + monkeypatch.setenv("CORS_ORIGINS", "http://testserver") + get_settings.cache_clear() + try: + yield LocalStorage(get_settings()) + finally: + get_settings.cache_clear() + + +@pytest.mark.parametrize( + "filename", + [ + "../../evil.py", + "../../../etc/passwd", + "a/b/c.py", + "..\\..\\evil.zip", + "/abs/path/evil.tar.gz", + ], +) +def test_save_upload_never_escapes_or_nests_uploads_dir(storage: LocalStorage, filename: str) -> None: + upload = UploadFile(filename=filename, file=io.BytesIO(b"payload")) + saved = asyncio.run(storage.save_upload("11111111-1111-1111-1111-111111111111", upload, 1024)) + + resolved = saved.resolve() + uploads_root = storage.uploads_root.resolve() + # The stored file must be a direct child of uploads_root — no traversal, no nesting. + assert resolved.parent == uploads_root + assert resolved.name.startswith("11111111-1111-1111-1111-111111111111") + # No path separators from the client filename survive into the stored name. + assert "/" not in resolved.name and "\\" not in resolved.name + + +def test_save_upload_preserves_only_allowlisted_suffix(storage: LocalStorage) -> None: + upload = UploadFile(filename="whatever.tar.gz", file=io.BytesIO(b"payload")) + saved = asyncio.run(storage.save_upload("22222222-2222-2222-2222-222222222222", upload, 1024)) + assert saved.name == "22222222-2222-2222-2222-222222222222.tar.gz" + + +def test_extract_archive_strips_macos_finder_artifacts(storage: LocalStorage, tmp_path: Path) -> None: + """#398: a zip made by macOS Finder's "Compress" carries __MACOSX/ plus a + ._ AppleDouble sidecar per real file, and Finder itself may drop a + .DS_Store into any directory -- none of it is repository content, and it + must not still be sitting in the extracted repository's storage tree.""" + + archive_path = tmp_path / "archive.zip" + with zipfile.ZipFile(archive_path, "w") as archive: + archive.writestr("project/app.py", "print('ok')\n") + archive.writestr("project/.DS_Store", "junk") + archive.writestr("__MACOSX/project/._app.py", "\x00\x05\x16\x07 resource fork") + archive.writestr("project/._app.py", "\x00\x05\x16\x07 resource fork") + + root = storage.extract_archive(archive_path, "33333333-3333-3333-3333-333333333333") + + remaining = sorted(path.name for path in root.rglob("*")) + assert remaining == ["app.py"] + + unsafe = UploadFile(filename="whatever.py", file=io.BytesIO(b"payload")) + saved_unsafe = asyncio.run(storage.save_upload("33333333-3333-3333-3333-333333333333", unsafe, 1024)) + assert saved_unsafe.name == "33333333-3333-3333-3333-333333333333" diff --git a/apps/backend/tests/test_waitlist.py b/apps/backend/tests/test_waitlist.py new file mode 100644 index 00000000..282273ec --- /dev/null +++ b/apps/backend/tests/test_waitlist.py @@ -0,0 +1,59 @@ +from app.core.database import SessionLocal +from app.models.waitlist_entry import WaitlistEntry + + +def test_join_waitlist_records_a_new_signup(client): + response = client.post("/waitlist", json={"email": "interested@example.com", "name": "Jane Doe"}) + + assert response.status_code == 201 + assert response.json() == {"status": "ok"} + + with SessionLocal() as db: + entry = db.query(WaitlistEntry).filter_by(email="interested@example.com").one() + assert entry.name == "Jane Doe" + + +def test_join_waitlist_does_not_require_a_name(client): + response = client.post("/waitlist", json={"email": "no-name@example.com"}) + + assert response.status_code == 201 + with SessionLocal() as db: + entry = db.query(WaitlistEntry).filter_by(email="no-name@example.com").one() + assert entry.name is None + + +def test_join_waitlist_normalizes_email_case_and_whitespace(client): + client.post("/waitlist", json={"email": " Mixed.Case@Example.com "}) + + with SessionLocal() as db: + assert db.query(WaitlistEntry).filter_by(email="mixed.case@example.com").one() is not None + + +def test_join_waitlist_is_idempotent_for_a_repeat_signup(client): + """A visitor resubmitting the form must never see an error (#334) -- + same 201/"ok" response, and no duplicate row.""" + first = client.post("/waitlist", json={"email": "repeat@example.com", "name": "First Name"}) + second = client.post("/waitlist", json={"email": "repeat@example.com", "name": "Different Name"}) + + assert first.status_code == second.status_code == 201 + assert first.json() == second.json() == {"status": "ok"} + + with SessionLocal() as db: + entries = db.query(WaitlistEntry).filter_by(email="repeat@example.com").all() + assert len(entries) == 1 + # First submission wins; a resubmission is a no-op, not an update. + assert entries[0].name == "First Name" + + +def test_join_waitlist_rejects_an_invalid_email(client): + response = client.post("/waitlist", json={"email": "not-an-email"}) + + assert response.status_code == 422 + + +def test_join_waitlist_requires_no_authentication(client): + # No Authorization header attached -- unlike `auth_client`, `client` is + # not pre-authenticated, which is exactly the point of this assertion. + response = client.post("/waitlist", json={"email": "anonymous@example.com"}) + + assert response.status_code == 201 diff --git a/apps/frontend/README.md b/apps/frontend/README.md index a3af7732..e590a080 100644 --- a/apps/frontend/README.md +++ b/apps/frontend/README.md @@ -1,22 +1,93 @@ # PARTHA Frontend -Vite + React frontend for PARTHA. +Vite + React + TypeScript frontend for PARTHA. + +This app lives at `apps/frontend` in the PARTHA monorepo. For contributor workflow and engineering rules, see the root [CONTRIBUTING.md](../../CONTRIBUTING.md). For a full local-setup walkthrough and troubleshooting, see [docs/DEVELOPMENT.md](../../docs/DEVELOPMENT.md). ## Structure ```text src/ - app/ App shell, router, pages, store, entrypoint - features/ Domain features with colocated hooks/components - shared/ Reusable components, hooks, services, types, utilities - assets/ Static frontend assets - styles/ Global styles + app/ Shell, router, pages, stores, entrypoint + features/ Domain features with colocated hooks, components, and tests + shared/ API clients, reusable UI, config, hooks, types, utilities + styles/ Global styles and design tokens + test/ Vitest setup ``` -## Local Commands +Every application route is behind the `RequireAuth` guard; only `/login` and `/register` are public. All backend calls go through the shared API client in `src/shared/services/api/`, which owns access-token attachment and 401 handling — do not call `fetch` directly from a feature. + +## Navigation readiness and grouping + +Top-level product routes and primary navigation are defined together in +`src/app/routes/productSurfaces.tsx`. A deferred surface must remain out of +primary navigation and record its delivery phase and blocking issues there. +Its direct route renders the shared unavailable-for-this-phase state; do not +restore a deferred page until its readiness gate changes. + +That registry is also the single source of truth for **where** a surface +appears. Each entry carries a `navGroup`, and the sidebar renders groups in +registry order — it never hardcodes a route: + +| `navGroup` | Contains | Rendering | +| --- | --- | --- | +| `flagship` | Dashboard, Repositories, Upload Repository | Top of the sidebar, unlabelled, full weight. | +| `analysis` | Architecture, Dependency Graph, Engineering Review, Insights, Documentation | Under a muted **Analysis** heading. Read-only evidence views. | +| `assist` | AI Workspace | Under a muted **Assist** heading, deliberately separate from `analysis`: an interactive tool over the same facts is not another view of record. | +| `utility` | Settings | Pinned to the footer above the account row, in its own `nav` landmark. | + +Two rules the tests enforce, so changing them means changing a test on purpose: +reordering a surface in the registry reorders the sidebar, and every group +member stays a real focusable link — reducing emphasis never means hiding a +surface. A pinned surface still belongs to a navigation landmark; a link in a +bare `div` is invisible to landmark-based screen-reader navigation. + +## Commands + +Requires Node.js 22 (CI pins 22). ```bash -npm --prefix apps/frontend run dev -npm --prefix apps/frontend run build -npm --prefix apps/frontend run lint +npm ci --prefix apps/frontend # install from the lockfile + +npm --prefix apps/frontend run dev # dev server on http://localhost:5173 +npm --prefix apps/frontend run lint # eslint +npm --prefix apps/frontend run test # vitest, with coverage +npm --prefix apps/frontend run test:watch +npm --prefix apps/frontend run build # tsc -b && vite build +npm run generate:api-contract # regenerate DTOs from FastAPI OpenAPI +npm --prefix apps/frontend run generate:api-contract -- --check # fail on drift +npm run test:e2e # disposable fixtures + Playwright journeys ``` + +The generated contract lives at +`src/shared/services/api/generated.ts` and must not be edited directly. The +generator imports backend module `app.main:app` from `apps/backend`, strips only import-time dynamic +datetime defaults, and runs the pinned `openapi-typescript@7.13.0` tool. The CI +API Contract Drift job installs both app lockfiles and runs the check command. + +From the repository root: `npm run dev:frontend`, `npm run lint:frontend`, +`npm run build:frontend`, and `npm run test:e2e`. There is no root alias +for the Vitest suite — run `npm --prefix apps/frontend run test`. + +Type errors surface in `build`, not `lint`. Run the build before opening a PR. + +## Environment + +`VITE_API_URL` sets the backend origin. It is **optional** — leave it unset for the default (`http://localhost:8000`). Copy `.env.example` to `.env` only if you need to point elsewhere. Vite inlines this at build time, so a change requires a restart. + +## Backend + +The app expects the PARTHA backend on `http://localhost:8000` (`npm run dev:backend` from the root). It requires an account: register through the UI, then sign in. + +## Test coverage + +Vitest covers shared infrastructure plus feature components, hooks, stores, +routing, repository switching, and architecture layout behavior. The browser +acceptance runner creates disposable repositories, starts isolated backend and +frontend processes, and exercises the review-ready Architecture, Engineering +Review, and Insights journeys in Chromium. This executable acceptance coverage +is deliberately focused; it is not a claim of complete product coverage. + +Vitest writes coverage reports to the local `apps/frontend/coverage/` directory. +Coverage is local-only; CI enforces the test exit status but does not upload a +coverage artifact. diff --git a/apps/frontend/e2e/accessibility.spec.ts b/apps/frontend/e2e/accessibility.spec.ts new file mode 100644 index 00000000..f5ee86b2 --- /dev/null +++ b/apps/frontend/e2e/accessibility.spec.ts @@ -0,0 +1,247 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { + expect, + test, + type Page, +} from '@playwright/test'; +import type { + AxeResults, + NodeResult, + RunOptions, + Result, +} from 'axe-core'; + +interface Fixture { + label: string; + id: string; + name: string; +} + +interface Fixtures { + email: string; + password: string; + repos: Fixture[]; +} + +const FIXTURES: Fixtures = JSON.parse( + readFileSync(process.env.PARTHA_VISUAL_FIXTURES ?? '/tmp/partha-e2e-fixtures.json', 'utf8'), +); +const AXE_SOURCE_PATH = fileURLToPath( + new URL('../node_modules/axe-core/axe.min.js', import.meta.url), +); +const WCAG_22_AA_TAGS = ['wcag2a', 'wcag2aa', 'wcag21aa', 'wcag22aa']; + +interface KnownFinding { + issue: number; + rule: string; + testId: string; + maxCount: number; +} + +// #236's `notification-menu-trigger`/`user-menu-trigger` button-name +// allowances are intentionally gone: both controls now carry aria-label, so +// the allowance would only have masked a future regression. +// #238's `secondary-navigation-label` allowance is intentionally gone: the +// "More" label it covered was replaced by the AA-contrast "Analysis" and +// "Assist" labels (#289), so the allowance matched nothing and would only +// have masked a future regression on an element that no longer exists. +const SHELL_FINDINGS: KnownFinding[] = []; + +function byLabel(label: string) { + const fixture = FIXTURES.repos.find((repository) => repository.label === label); + if (!fixture) throw new Error(`fixture "${label}" missing`); + return fixture; +} + +function nodeHasTestId(node: NodeResult, testId: string) { + return node.html.includes(`data-testid="${testId}"`); +} + +function describeViolations(state: string, violations: Result[]) { + if (violations.length === 0) return `${state}: no axe violations`; + return [ + `${state}: ${violations.length} axe violation(s)`, + ...violations.flatMap((violation) => [ + `${violation.id} (${violation.impact ?? 'unknown'}): ${violation.help}`, + ` ${violation.helpUrl}`, + ...violation.nodes.map((node) => [ + ` target: ${node.target.join(' > ')}`, + ` html: ${node.html}`, + ` ${node.failureSummary ?? 'No failure summary supplied.'}`, + ].join('\n')), + ]), + ].join('\n'); +} + +function animationsAreSettled() { + return document.getAnimations().every((animation) => { + const iterations = animation.effect?.getComputedTiming().iterations; + return iterations === Infinity + || animation.playState === 'finished' + || animation.playState === 'idle'; + }); +} + +async function waitForAnimationsSettled(page: Page) { + await page.waitForFunction(animationsAreSettled); + // AnimatePresence mode="wait" (e.g. the upload page's file/GitHub tab + // switch, #237) runs the exit animation to completion before starting the + // paired enter animation. document.getAnimations() can be momentarily + // empty in the gap between the two, which satisfies the check above via + // vacuous truth even though a new animation is about to start and the + // entering content is still at its initial (often zero-opacity) state. + // Re-checking after a beat longer than a single transition closes that + // sampling window so axe never measures a mid cross-fade frame. + await page.waitForTimeout(300); + await page.waitForFunction(animationsAreSettled); +} + +async function expectWcagBaseline( + page: Page, + state: string, + knownFindings: KnownFinding[] = [], +) { + await page.evaluate(() => document.fonts.ready); + await waitForAnimationsSettled(page); + await page.addScriptTag({ path: AXE_SOURCE_PATH }); + const violations = await page.evaluate( + async ({ tags }) => { + const axe = (window as typeof window & { + axe: { + run( + context?: Document, + options?: RunOptions, + ): Promise; + }; + }).axe; + const results = await axe.run(document, { + runOnly: { type: 'tag', values: tags }, + resultTypes: ['violations'], + }); + return results.violations; + }, + { tags: WCAG_22_AA_TAGS }, + ); + + const actualNodes = violations.flatMap((violation) => violation.nodes.map((node) => ({ + rule: violation.id, + node, + }))); + for (const finding of knownFindings) { + const matches = actualNodes.filter( + ({ rule, node }) => rule === finding.rule && nodeHasTestId(node, finding.testId), + ); + expect( + matches.length, + `${state}: exceeded the known baseline for #${finding.issue} ` + + `(${finding.rule}, ${finding.testId})`, + ).toBeLessThanOrEqual(finding.maxCount); + } + + const unexpected = violations + .map((violation) => ({ + ...violation, + nodes: violation.nodes.filter((node) => !knownFindings.some( + (finding) => finding.rule === violation.id && nodeHasTestId(node, finding.testId), + )), + })) + .filter((violation) => violation.nodes.length > 0); + + expect(unexpected, describeViolations(state, unexpected)).toHaveLength(0); +} + +async function login(page: Page) { + await page.goto('/login'); + await page.getByLabel(/email/i).fill(FIXTURES.email); + await page.getByLabel(/password/i).fill(FIXTURES.password); + await page.getByRole('button', { name: /sign in|log ?in/i }).click(); + await expect(page.getByRole('heading', { level: 1 })).toBeVisible({ timeout: 15_000 }); +} + +async function openSurface(page: Page, name: string) { + await page + .getByRole('navigation', { name: 'Primary navigation' }) + .getByRole('link', { name, exact: true }) + .click(); + await expect(page.getByRole('heading', { name, level: 1 })).toBeVisible({ timeout: 15_000 }); +} + +async function selectRepository(page: Page, fixture: Fixture) { + const trigger = page.locator('header button').filter({ + hasText: /^(No repository|small|medium|large-multi|large-single|long-labels|disconnected-unresolved|unanalysed)$/, + }).first(); + await trigger.click(); + await page.locator('button').filter({ hasText: new RegExp(`^${fixture.name}$`) }).last().click(); + await expect(trigger).toHaveText(fixture.name); +} + +test.describe('WCAG 2.2 AA automated baseline (#118)', () => { + test('login form', async ({ page }) => { + await page.goto('/login'); + await expect(page.getByRole('heading', { name: 'Sign in to PARTHA' })).toBeVisible(); + + // #240's `login-register-link` link-in-text-block allowance is + // intentionally gone: the link now carries a persistent underline at + // rest, so it no longer relies on colour alone. + await expectWcagBaseline(page, 'login: initial sign-in form', []); + }); + + test('authenticated application shell and sidebar', async ({ page }) => { + await login(page); + await expect(page.getByRole('navigation', { name: 'Primary navigation' })).toBeVisible(); + + await expectWcagBaseline( + page, + 'application shell/sidebar: authenticated dashboard', + SHELL_FINDINGS, + ); + }); + + test('repository list', async ({ page }) => { + await login(page); + await openSurface(page, 'Repositories'); + await expect(page.getByRole('button', { name: byLabel('small').name, exact: true })).toBeVisible(); + for (const repository of FIXTURES.repos) { + await expect(page.getByRole('button', { name: `Open ${repository.name}`, exact: true })).toBeVisible(); + await expect(page.getByRole('button', { name: `Delete ${repository.name}`, exact: true })).toBeVisible(); + } + await expectWcagBaseline(page, 'repositories: seeded success list', SHELL_FINDINGS); + }); + + test('GitHub repository import form', async ({ page }) => { + await login(page); + await openSurface(page, 'Repositories'); + await page.getByRole('button', { name: 'Upload New' }).click(); + await expect(page.getByRole('heading', { name: 'Upload Repository' })).toBeVisible(); + await page.getByRole('tab', { name: 'GitHub URL', exact: true }).click(); + await expect(page.getByText('Import from GitHub')).toBeVisible(); + // #237's title/helper/label/url-placeholder color-contrast allowances are + // intentionally gone: they were axe measuring the panel mid cross-fade + // (see waitForAnimationsSettled), not an actual failing color pair. The + // steady-state colors already clear 4.5:1; the allowances would only have + // masked a future regression on this panel. + await expectWcagBaseline(page, 'repository import: empty GitHub URL form', SHELL_FINDINGS); + }); + + test('architecture graph', async ({ page }) => { + await login(page); + await openSurface(page, 'Architecture'); + await selectRepository(page, byLabel('small')); + await expect(page.locator('.react-flow__node').first()).toBeVisible({ timeout: 20_000 }); + + await expectWcagBaseline(page, 'architecture: seeded small graph', SHELL_FINDINGS); + }); + + test('architecture node inspector', async ({ page }) => { + await login(page); + await openSurface(page, 'Architecture'); + await selectRepository(page, byLabel('small')); + const node = page.locator('.react-flow__node').first(); + await expect(node).toBeVisible({ timeout: 20_000 }); + await node.click(); + await expect(page.getByRole('dialog', { name: /architecture node/i })).toBeVisible(); + + await expectWcagBaseline(page, 'node inspector: first node selected', SHELL_FINDINGS); + }); +}); diff --git a/apps/frontend/e2e/architecture-visual.spec.ts b/apps/frontend/e2e/architecture-visual.spec.ts new file mode 100644 index 00000000..d62ec399 --- /dev/null +++ b/apps/frontend/e2e/architecture-visual.spec.ts @@ -0,0 +1,318 @@ +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { expect, test, type Locator, type Page, type TestInfo } from '@playwright/test'; + +interface Fixture { + label: string; + id: string; + name: string; + revisionValue: string | null; + snapshotId: string | null; + nodes: number; + edges: number; + reviewFindings: number; +} + +interface Fixtures { + email: string; + password: string; + repos: Fixture[]; +} + +const FIXTURES: Fixtures = JSON.parse( + readFileSync(process.env.PARTHA_VISUAL_FIXTURES ?? '/tmp/partha-e2e-fixtures.json', 'utf8'), +); + +const byLabel = (label: string) => { + const fixture = FIXTURES.repos.find((repository) => repository.label === label); + if (!fixture) throw new Error(`fixture "${label}" missing`); + return fixture; +}; + +async function login(page: Page) { + await page.goto('/login'); + await page.getByLabel(/email/i).fill(FIXTURES.email); + await page.getByLabel(/password/i).fill(FIXTURES.password); + await page.getByRole('button', { name: /sign in|log ?in/i }).click(); + await expect(page.getByRole('heading', { level: 1 })).toBeVisible({ timeout: 15_000 }); +} + +async function openPrimaryNavigation(page: Page) { + if ((page.viewportSize()?.width ?? 1440) < 768) { + await page.getByRole('button', { name: 'Open navigation drawer' }).click(); + await expect(page.getByRole('navigation', { name: 'Primary navigation' })).toBeVisible(); + } +} + +async function openSurface(page: Page, name: string) { + await openPrimaryNavigation(page); + await page.getByRole('link', { name, exact: true }).click(); +} + +async function selectRepository(page: Page, fixture: Fixture) { + const trigger = page.locator('header button').filter({ + hasText: /^(No repository|small|medium|large-multi|large-single|long-labels|disconnected-unresolved|unanalysed)$/, + }).first(); + await trigger.click(); + await page.locator('button').filter({ hasText: new RegExp(`^${fixture.name}$`) }).last().click(); + await expect(trigger).toHaveText(fixture.name); +} + +async function openArchitecture(page: Page, fixture: Fixture) { + await openSurface(page, 'Architecture'); + await expect(page.getByRole('heading', { name: 'Architecture', level: 1 })).toBeVisible({ + timeout: 15_000, + }); + await selectRepository(page, fixture); +} + +async function capture(target: Page | Locator, name: string, testInfo: TestInfo) { + const body = await target.screenshot(); + await testInfo.attach(name, { body, contentType: 'image/png' }); + const directory = process.env.PARTHA_VISUAL_SCREENSHOT_DIR; + if (directory) { + mkdirSync(directory, { recursive: true }); + writeFileSync(join(directory, `${name}.png`), body); + } +} + +const graphNodes = (page: Page) => page.locator('.react-flow__node'); + +async function waitForGraph(page: Page) { + await expect(graphNodes(page).first()).toBeVisible({ timeout: 20_000 }); + await page.waitForTimeout(500); +} + +async function nodeMeasurements(page: Page) { + return graphNodes(page).evaluateAll((elements) => + elements.map((element) => { + const rect = element.getBoundingClientRect(); + const label = element.querySelector('[data-testid="architecture-node-label"]'); + const labelStyle = label ? getComputedStyle(label) : null; + const scale = rect.width / 220; + return { + x: rect.x, + y: rect.y, + width: rect.width, + height: rect.height, + label: label?.textContent?.trim() ?? '', + effectiveFontSize: Number.parseFloat(labelStyle?.fontSize ?? '0') * scale, + }; + }), + ); +} + +function overlapCount(boxes: Awaited>) { + let overlaps = 0; + for (let left = 0; left < boxes.length; left += 1) { + for (let right = left + 1; right < boxes.length; right += 1) { + const a = boxes[left]; + const b = boxes[right]; + if ( + a.x < b.x + b.width + && a.x + a.width > b.x + && a.y < b.y + b.height + && a.y + a.height > b.y + ) overlaps += 1; + } + } + return overlaps; +} + +test.describe('architecture graph visual acceptance', () => { + test.beforeEach(async ({ page }) => { + await login(page); + }); + + for (const label of ['small', 'medium', 'large-multi', 'large-single'] as const) { + test(`${label} graph is readable at its default view`, async ({ page }, testInfo) => { + const fixture = byLabel(label); + await openArchitecture(page, fixture); + await waitForGraph(page); + + const boxes = await nodeMeasurements(page); + expect(boxes).toHaveLength(fixture.nodes); + expect(boxes.every((box) => box.label.length > 0), 'a visible graph node has no label').toBe(true); + expect( + boxes.filter((box) => box.width < 180 || box.height < 85), + 'node geometry fell below the readable 0.85x floor', + ).toHaveLength(0); + expect( + boxes.filter((box) => box.effectiveFontSize < 10), + 'a node label renders below 10 CSS pixels after graph scaling', + ).toHaveLength(0); + expect(overlapCount(boxes), 'graph nodes overlap').toBe(0); + + const graphBounds = await page.locator('.react-flow').boundingBox(); + expect(graphBounds).not.toBeNull(); + const clipped = boxes.filter( + (box) => + box.x < graphBounds!.x - 1 + || box.y < graphBounds!.y - 1 + || box.x + box.width > graphBounds!.x + graphBounds!.width + 1 + || box.y + box.height > graphBounds!.y + graphBounds!.height + 1, + ); + expect(clipped, 'the default view partially clips a graph node').toHaveLength(0); + + const distinctColumns = new Set(boxes.map((box) => Math.round(box.x / 40))).size; + const distinctRows = new Set(boxes.map((box) => Math.round(box.y / 40))).size; + if (label === 'large-single') { + expect(distinctColumns, 'busy single semantic layer did not wrap into subcolumns').toBeGreaterThan(1); + expect(distinctRows, 'busy single semantic layer stayed in one row').toBeGreaterThan(1); + } else if (boxes.length > 2) { + expect(distinctColumns).toBeGreaterThan(1); + expect(distinctRows).toBeGreaterThan(1); + } + + if (label === 'small') { + const labels = boxes.map((box) => box.label); + expect(labels).toEqual(expect.arrayContaining(['Api', 'Services', 'Domain', 'Repositories'])); + } + + const viewport = page.viewportSize()!; + const visible = boxes.filter( + (box) => + box.x + box.width > 0 + && box.y + box.height > 0 + && box.x < viewport.width + && box.y < viewport.height, + ); + expect(visible.length, 'the default view contains no graph nodes').toBeGreaterThan(0); + if (visible.length < boxes.length) await expect(page.locator('.react-flow__minimap')).toBeVisible(); + + await capture(page, `architecture-${label}`, testInfo); + }); + } + + test('long module names are truncated visually and recoverable accessibly', async ({ page }, testInfo) => { + await openArchitecture(page, byLabel('long-labels')); + await waitForGraph(page); + + const group = page.locator('.react-flow__node [role="group"]').first(); + const accessibleName = await group.getAttribute('aria-label'); + expect(accessibleName).toContain('Customer Subscription Entitlement Orchestration'); + expect(await group.getAttribute('title')).toBe(accessibleName); + await capture(page, 'architecture-long-labels', testInfo); + }); + + test('focused nodes remain visible, have a real focus ring, and open with the keyboard', async ({ + page, + }, testInfo) => { + await openArchitecture(page, byLabel('medium')); + await waitForGraph(page); + + let reachedNode = false; + for (let index = 0; index < 50 && !reachedNode; index += 1) { + await page.keyboard.press('Tab'); + reachedNode = await page.evaluate(() => document.activeElement?.closest('.react-flow__node') !== null); + } + expect(reachedNode, 'no graph node is reachable by Tab').toBe(true); + + const focusState = await page.evaluate(() => { + const node = document.activeElement?.closest('.react-flow__node'); + if (!node) return null; + const style = getComputedStyle(node); + const box = node.getBoundingClientRect(); + return { + outlineStyle: style.outlineStyle, + outlineWidth: Number.parseFloat(style.outlineWidth), + boxShadow: style.boxShadow, + fullyVisible: + box.left >= 0 + && box.top >= 0 + && box.right <= document.documentElement.clientWidth + && box.bottom <= document.documentElement.clientHeight, + }; + }); + expect(focusState?.fullyVisible).toBe(true); + expect( + (focusState?.outlineStyle === 'solid' && (focusState?.outlineWidth ?? 0) >= 2) + || focusState?.boxShadow !== 'none', + 'focused graph node has no browser-painted indicator', + ).toBe(true); + await capture(page, 'architecture-keyboard-focus', testInfo); + + await page.keyboard.press('Enter'); + await expect(page.getByRole('dialog', { name: /architecture node/i })).toBeVisible(); + await page.keyboard.press('Escape'); + await expect(page.getByRole('dialog', { name: /architecture node/i })).toHaveCount(0); + + await page.keyboard.press('Space'); + await expect(page.getByRole('dialog', { name: /architecture node/i })).toBeVisible(); + await page.keyboard.press('Escape'); + await expect(page.getByRole('dialog', { name: /architecture node/i })).toHaveCount(0); + }); + + test('fit and reset preserve readable node geometry', async ({ page }) => { + await openArchitecture(page, byLabel('large-single')); + await waitForGraph(page); + const initial = await nodeMeasurements(page); + + await page.getByRole('button', { name: 'Fit View' }).click(); + await page.waitForTimeout(400); + const fitted = await nodeMeasurements(page); + await page.getByRole('button', { name: 'Reset Layout' }).click(); + await page.waitForTimeout(500); + const reset = await nodeMeasurements(page); + + for (const boxes of [fitted, reset]) { + expect(boxes.filter((box) => box.width < 180 || box.height < 85)).toHaveLength(0); + expect(overlapCount(boxes)).toBe(0); + } + expect(reset.map((box) => box.label)).toEqual(initial.map((box) => box.label)); + }); + + test('narrow viewport keeps navigation, graph, toolbar, and minimap reachable', async ({ + page, + }, testInfo) => { + await page.setViewportSize({ width: 390, height: 844 }); + await openArchitecture(page, byLabel('medium')); + await waitForGraph(page); + + expect( + await page.evaluate( + () => document.documentElement.scrollWidth > document.documentElement.clientWidth + 1, + ), + 'page scrolls horizontally on a narrow viewport', + ).toBe(false); + const completeNodes = (await nodeMeasurements(page)).filter( + (box) => box.x >= 0 && box.y >= 0 && box.x + box.width <= 390 && box.y + box.height <= 844, + ); + expect(completeNodes.length, 'no complete architecture node is visible on mobile').toBeGreaterThan(0); + await expect(page.getByRole('button', { name: 'Fit View' })).toBeVisible(); + await expect(page.locator('.react-flow__minimap')).toBeVisible(); + + const openButton = page.getByRole('button', { name: 'Open navigation drawer' }); + await openButton.click(); + const navigation = page.getByRole('navigation', { name: 'Primary navigation' }); + await expect(navigation).toBeVisible(); + expect(await page.evaluate(() => document.activeElement?.getAttribute('aria-label'))).toBe( + 'Close navigation drawer', + ); + await page.keyboard.press('Escape'); + await expect(navigation).not.toBeInViewport(); + await expect(openButton).toBeFocused(); + + await capture(page, 'architecture-narrow-viewport', testInfo); + }); + + test('unanalysed repository has an honest empty state', async ({ page }, testInfo) => { + await openArchitecture(page, byLabel('unanalysed')); + await expect(graphNodes(page)).toHaveCount(0); + await expect(page.getByText(/analysis|analysed|sealed snapshot/i).first()).toBeVisible(); + await capture(page, 'architecture-empty-state', testInfo); + }); + + test('revision manifest exposes verifiable snapshot identity without a signature claim', async ({ + page, + }, testInfo) => { + await openArchitecture(page, byLabel('small')); + const manifest = page.getByTestId('revision-manifest'); + await expect(manifest).toBeVisible({ timeout: 15_000 }); + await expect(manifest.getByTestId('verification-state')).toHaveText(/verified/i); + await expect(manifest).toContainText(byLabel('small').snapshotId!); + await manifest.getByRole('button', { name: /details/i }).click(); + await expect(manifest).toContainText(/not a digital signature/i); + await capture(manifest, 'revision-manifest', testInfo); + }); +}); diff --git a/apps/frontend/e2e/surfaces.spec.ts b/apps/frontend/e2e/surfaces.spec.ts new file mode 100644 index 00000000..8c3a0ffc --- /dev/null +++ b/apps/frontend/e2e/surfaces.spec.ts @@ -0,0 +1,208 @@ +import { execFile } from 'node:child_process'; +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; +import { expect, test, type Locator, type Page, type TestInfo } from '@playwright/test'; + +const execFileAsync = promisify(execFile); +const approveEmailScript = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'backend', 'scripts', 'approve_email.py'); + +async function approveEmail(email: string): Promise { + // Registration requires an admin-approved email (#374); approve it the + // same way an operator would, through the real CLI, rather than reaching + // around it. + const python = process.env.PARTHA_FIXTURE_PYTHON ?? (process.platform === 'win32' ? 'python' : 'python3'); + await execFileAsync(python, [approveEmailScript, '--email', email, '--note', 'e2e second-owner spec']); +} + +interface Fixture { + label: string; + id: string; + name: string; + revisionValue: string | null; + snapshotId: string | null; + reviewFindings: number; +} + +interface Fixtures { + apiUrl: string; + email: string; + password: string; + repos: Fixture[]; +} + +const FIXTURES: Fixtures = JSON.parse( + readFileSync(process.env.PARTHA_VISUAL_FIXTURES ?? '/tmp/partha-e2e-fixtures.json', 'utf8'), +); + +function byLabel(label: string) { + const fixture = FIXTURES.repos.find((repository) => repository.label === label); + if (!fixture) throw new Error(`fixture "${label}" missing`); + return fixture; +} + +async function login(page: Page) { + await page.goto('/login'); + await page.getByLabel(/email/i).fill(FIXTURES.email); + await page.getByLabel(/password/i).fill(FIXTURES.password); + await page.getByRole('button', { name: /sign in|log ?in/i }).click(); + await expect(page.getByRole('heading', { level: 1 })).toBeVisible({ timeout: 15_000 }); +} + +async function openSurface(page: Page, name: string) { + if ((page.viewportSize()?.width ?? 1440) < 768) { + await page.getByRole('button', { name: 'Open navigation drawer' }).click(); + } + await page + .getByRole('navigation', { name: 'Primary navigation' }) + .getByRole('link', { name, exact: true }) + .click(); + await expect(page.getByRole('heading', { name, level: 1 })).toBeVisible({ timeout: 15_000 }); +} + +async function selectRepository(page: Page, fixture: Fixture) { + const trigger = page.locator('header button').filter({ + hasText: /^(No repository|small|medium|large-multi|large-single|long-labels|disconnected-unresolved|unanalysed)$/, + }).first(); + await trigger.click(); + await page.locator('button').filter({ hasText: new RegExp(`^${fixture.name}$`) }).last().click(); + await expect(trigger).toHaveText(fixture.name); +} + +async function capture(target: Page | Locator, name: string, testInfo: TestInfo) { + const body = await target.screenshot(); + await testInfo.attach(name, { body, contentType: 'image/png' }); + const directory = process.env.PARTHA_VISUAL_SCREENSHOT_DIR; + if (directory) { + mkdirSync(directory, { recursive: true }); + writeFileSync(join(directory, `${name}.png`), body); + } +} + +test.describe('snapshot-backed Review and Insights journeys', () => { + test.beforeEach(async ({ page }) => { + await login(page); + }); + + test('Review shows supported findings, exact evidence navigation, and no score', async ({ + page, + }, testInfo) => { + const fixture = byLabel('disconnected-unresolved'); + expect(fixture.reviewFindings).toBeGreaterThan(0); + await openSurface(page, 'Engineering Review'); + await selectRepository(page, fixture); + + await expect(page.getByText(fixture.snapshotId!, { exact: true })).toBeVisible(); + await expect(page.getByText(/No overall score, grade, health percentage/i)).toBeVisible(); + await expect(page.getByText(/matching supported findings/i)).toContainText( + String(fixture.reviewFindings), + ); + await expect(page.getByText(/Not assessed/i).first()).toBeVisible(); + expect((await page.locator('body').innerText()).toLowerCase()).not.toMatch( + /\boverall score:\s*\d|\bhealth score\b|\bgrade:\s*[a-f]/, + ); + + await page.getByRole('button').filter({ hasText: 'RI-RES-UNRESOLVED' }).click(); + const findingDialog = page.getByRole('dialog', { name: /engineering finding/i }); + await expect(findingDialog).toBeVisible(); + await expect(findingDialog).toContainText(fixture.snapshotId!); + const evidence = findingDialog.getByRole('link'); + const href = await evidence.getAttribute('href'); + expect(href).toContain(`snapshotId=${encodeURIComponent(fixture.snapshotId!)}`); + expect(href).toContain('factId='); + await evidence.click(); + await expect(page).toHaveURL(/\/repositories\/.+snapshotId=.+factId=/); + await expect(page.getByText(/Cited lines \d+.*snapshot/i)).toBeVisible(); + await expect(page.getByText('Verifying evidence...')).toHaveCount(0); + await expect(page.locator('.monaco-editor')).toBeVisible(); + await page.waitForTimeout(500); + + await capture(page, 'review-supported-finding-evidence', testInfo); + }); + + test('Review no-finding state still marks unassessed categories honestly', async ({ page }, testInfo) => { + const fixture = byLabel('large-single'); + expect(fixture.reviewFindings).toBe(0); + await openSurface(page, 'Engineering Review'); + await selectRepository(page, fixture); + + await expect(page.getByRole('heading', { name: 'No evidence-backed findings' })).toBeVisible(); + await expect(page.getByText(/does not mean every engineering category was assessed/i)).toBeVisible(); + await expect(page.getByText('Not assessed', { exact: true }).first()).toBeVisible(); + await capture(page, 'review-no-findings-not-assessed', testInfo); + }); + + test('Insights exposes defined counts, provenance, diagnostics, and unavailable change history', async ({ + page, + }, testInfo) => { + const fixture = byLabel('disconnected-unresolved'); + await openSurface(page, 'Insights'); + await selectRepository(page, fixture); + + await expect(page.getByText(fixture.snapshotId!, { exact: true })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Defined metrics' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'File nodes' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Unresolved relationships' })).toBeVisible(); + await expect(page.getByText('Change-over-time insights are not available yet.')).toBeVisible(); + await expect(page.getByText(/no legacy metadata is used/i)).toBeVisible(); + await expect(page.getByRole('link', { name: 'RI-RES-UNRESOLVED' })).toBeVisible(); + await capture(page, 'insights-defined-metrics', testInfo); + }); + + test('Architecture, Review, and Insights retain the same revision and snapshot identity', async ({ page }) => { + const fixture = byLabel('small'); + + await openSurface(page, 'Architecture'); + await selectRepository(page, fixture); + await expect(page.getByTestId('revision-manifest')).toContainText(fixture.snapshotId!); + await page.getByTestId('revision-manifest').getByRole('button', { name: 'Details' }).click(); + await expect(page.getByTestId('revision-manifest')).toContainText(fixture.revisionValue!); + + await openSurface(page, 'Engineering Review'); + await expect(page.getByText(fixture.snapshotId!, { exact: true })).toBeVisible(); + await expect(page.getByText(fixture.revisionValue!, { exact: false })).toBeVisible(); + + await openSurface(page, 'Insights'); + await expect(page.getByText(fixture.snapshotId!, { exact: true })).toBeVisible(); + await expect(page.getByText(fixture.revisionValue!, { exact: false })).toBeVisible(); + }); + + test('switching repositories clears the previous Review and Insights identity', async ({ page }) => { + const first = byLabel('disconnected-unresolved'); + const second = byLabel('small'); + + await openSurface(page, 'Engineering Review'); + await selectRepository(page, first); + await expect(page.getByText(first.snapshotId!, { exact: true })).toBeVisible(); + await selectRepository(page, second); + await expect(page.getByText(first.snapshotId!, { exact: true })).toHaveCount(0); + await expect(page.getByText(second.snapshotId!, { exact: true })).toBeVisible(); + + await openSurface(page, 'Insights'); + await expect(page.getByText(second.snapshotId!, { exact: true })).toBeVisible(); + await selectRepository(page, first); + await expect(page.getByText(second.snapshotId!, { exact: true })).toHaveCount(0); + await expect(page.getByText(first.snapshotId!, { exact: true })).toBeVisible(); + }); + + test('a second owner receives 404 for another owner’s Review and Insights', async ({ request }) => { + const email = `e2e-second-owner-${Date.now()}@example.com`; + const password = 'Second-owner-fixture-2026'; + await approveEmail(email); + const registration = await request.post(`${FIXTURES.apiUrl}/auth/register`, { + data: { email, password }, + }); + expect(registration.status()).toBe(201); + const accessToken = (await registration.json()).accessToken; + const repositoryId = byLabel('small').id; + + for (const surface of ['review', 'insights']) { + const response = await request.get(`${FIXTURES.apiUrl}/analysis/${repositoryId}/${surface}`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + expect(response.status(), surface).toBe(404); + expect((await response.json()).code).toBe('not_found'); + } + }); +}); diff --git a/apps/frontend/eslint.config.js b/apps/frontend/eslint.config.js index 95482494..9754d64c 100644 --- a/apps/frontend/eslint.config.js +++ b/apps/frontend/eslint.config.js @@ -11,6 +11,9 @@ export default tseslint.config( 'build', 'coverage', 'node_modules', + 'e2e-report', + 'playwright-report', + 'test-results', ], }, js.configs.recommended, diff --git a/apps/frontend/index.html b/apps/frontend/index.html index 16b2fd4e..d74c7595 100644 --- a/apps/frontend/index.html +++ b/apps/frontend/index.html @@ -1,15 +1,33 @@ - + - - PARTHA - Understand Any Codebase in Minutes + PARTHA — Repository Intelligence + + - +
diff --git a/apps/frontend/package-lock.json b/apps/frontend/package-lock.json index bfaac01c..a8f15ce1 100644 --- a/apps/frontend/package-lock.json +++ b/apps/frontend/package-lock.json @@ -8,39 +8,54 @@ "name": "@partha/frontend", "version": "1.0.0", "dependencies": { - "@dagrejs/dagre": "^3.0.0", + "@dagrejs/dagre": "^3.1.1", "@monaco-editor/react": "^4.7.0", - "@xyflow/react": "^12.11.2", + "@xyflow/react": "^12.11.5", "clsx": "^2.1.1", - "framer-motion": "^11.5.0", + "framer-motion": "^13.1.1", "html-to-image": "^1.11.13", - "lucide-react": "^0.446.0", + "lucide-react": "^0.577.0", "react": "^18.3.1", "react-dom": "^18.3.1", - "react-dropzone": "^14.2.9", - "react-router-dom": "^6.26.0", - "sonner": "^1.5.0", + "react-dropzone": "^20.1.1", + "react-router-dom": "^7.18.3", + "sonner": "^2.0.8", "tailwind-merge": "^2.5.2", "tailwindcss-animate": "^1.0.7", - "zustand": "^4.5.5" + "zustand": "^5.0.15" }, "devDependencies": { "@eslint/js": "^10.0.1", + "@playwright/test": "^1.62.1", + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "^16.3.3", "@types/react": "^18.3.5", "@types/react-dom": "^18.3.0", - "@vitejs/plugin-react": "^6.0.3", - "autoprefixer": "^10.4.20", - "eslint": "^10.6.0", + "@vitejs/plugin-react": "^6.1.1", + "@vitest/coverage-v8": "^4.1.11", + "autoprefixer": "^10.5.4", + "axe-core": "^4.13.0", + "eslint": "^10.9.1", "eslint-plugin-react-hooks": "^7.1.1", - "eslint-plugin-react-refresh": "^0.5.3", - "globals": "^17.7.0", - "postcss": "^8.4.45", + "eslint-plugin-react-refresh": "^0.5.5", + "globals": "^17.11.0", + "jsdom": "^30.0.1", + "openapi-typescript": "7.13.0", + "postcss": "^8.5.26", "tailwindcss": "^3.4.10", "typescript": "^5.5.4", - "typescript-eslint": "^8.63.0", - "vite": "^8.1.3" + "typescript-eslint": "^8.69.0", + "vite": "^8.2.2", + "vitest": "^4.1.10" } }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -53,6 +68,59 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.5.tgz", + "integrity": "sha512-mbhpPMmnw/kwW19aRNmSUl1QzLbdGo1SCuE49BT98MNwqF6zaHb3o2owssFc/PEO/4t2UjqtCNwocuDtJornzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.2.1", + "@csstools/css-color-parser": "^4.1.9", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-8.3.2.tgz", + "integrity": "sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -245,6 +313,16 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", @@ -293,55 +371,184 @@ "node": ">=6.9.0" } }, - "node_modules/@dagrejs/dagre": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@dagrejs/dagre/-/dagre-3.0.0.tgz", - "integrity": "sha512-ZzhnTy1rfuoew9Ez3EIw4L2znPGnYYhfn8vc9c4oB8iw6QAsszbiU0vRhlxWPFnmmNSFAkrYeF1PhM5m4lAN0Q==", + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, "license": "MIT", "dependencies": { - "@dagrejs/graphlib": "4.0.1" + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" } }, - "node_modules/@dagrejs/graphlib": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@dagrejs/graphlib/-/graphlib-4.0.1.tgz", - "integrity": "sha512-IvcV6FduIIAmLwnH+yun+QtV36SC7mERqa86aClNqmMN09WhmPPYU8ckHrZBozErf+UvHPWOTJYaGYiIcs0DgA==", - "license": "MIT" + "node_modules/@csstools/color-helpers": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", + "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } }, - "node_modules/@emnapi/core": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", - "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "node_modules/@csstools/css-calc": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", + "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.2", - "tslib": "^2.4.0" + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" } }, - "node_modules/@emnapi/runtime": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", - "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "node_modules/@csstools/css-color-parser": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.10.tgz", + "integrity": "sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], "license": "MIT", - "optional": true, "dependencies": { - "tslib": "^2.4.0" + "@csstools/color-helpers": "^6.1.0", + "@csstools/css-calc": "^3.3.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" } }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", - "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.7.tgz", + "integrity": "sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@dagrejs/dagre": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dagrejs/dagre/-/dagre-3.1.1.tgz", + "integrity": "sha512-zroZB1dFOFiGgv4Xcrn1DckB1o4aOikPqD2NDQPV0WM//CXGcS6xiD0rNkqHmw6FEg4tabt4nxPLwgCWT+Vb2A==", "license": "MIT", - "optional": true, "dependencies": { - "tslib": "^2.4.0" + "@dagrejs/graphlib": "4.0.5" } }, + "node_modules/@dagrejs/graphlib": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@dagrejs/graphlib/-/graphlib-4.0.5.tgz", + "integrity": "sha512-7xrBTqIts3o+PMUZX97wSc+7TUbW+/rULzGNCTP6yooNVDXbzw4Wutg/H/xOutTB/c/k0YqOAavgPh4/Zk9PFA==", + "license": "MIT" + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -400,9 +607,9 @@ } }, "node_modules/@eslint/config-helpers": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", - "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -470,6 +677,24 @@ "node": "^20.19.0 || ^22.13.0 || >=24" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -605,25 +830,6 @@ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", - "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@tybys/wasm-util": "^0.10.3" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - }, - "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" - } - }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -660,28 +866,118 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.139.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", - "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "version": "0.146.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.146.0.tgz", + "integrity": "sha512-XC0QsnnhVe7sLIWmYmdPw7x5P0h4W8vUU3Nv1ySgWXtvCz8NizoAEpGXA0sOYoJQV2Rl13LgURAHQ5cI5ILCSA==", "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/Boshen" } }, - "node_modules/@remix-run/router": { - "version": "1.23.3", - "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz", - "integrity": "sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==", + "node_modules/@playwright/test": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", + "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@redocly/ajv": { + "version": "8.11.2", + "resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz", + "integrity": "sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js-replace": "^1.0.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@redocly/ajv/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/@redocly/config": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@redocly/config/-/config-0.22.0.tgz", + "integrity": "sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@redocly/openapi-core": { + "version": "1.34.17", + "resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.17.tgz", + "integrity": "sha512-wsV2keCt6B806XpSdezbWZ9aFJYf14YVh+XQf0ESt7M90yqVuxH9//PxvtC70sgj9OCkRM3nRaLfu4MsGQZRig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@redocly/ajv": "8.11.2", + "@redocly/config": "0.22.0", + "colorette": "1.4.0", + "https-proxy-agent": "7.0.6", + "js-levenshtein": "1.1.6", + "js-yaml": "4.2.0", + "minimatch": "5.1.9", + "pluralize": "8.0.0", + "yaml-ast-parser": "0.0.43" + }, + "engines": { + "node": ">=18.17.0", + "npm": ">=9.5.0" + } + }, + "node_modules/@redocly/openapi-core/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.5.tgz", + "integrity": "sha512-DLe/i+l8ynIBY7XEQ191TeZvCoowIGa18R+dIV30GW7DiOtp74i/xX8hs8GUjW5ARV7VZuie3d6AumSmCwbeRA==", + "cpu": [ + "arm" + ], + "dev": true, "license": "MIT", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=14.0.0" + "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", - "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.5.tgz", + "integrity": "sha512-zXcwKlQApYAOELHd8PwKDFkagYF9Wy4e0RJ+0qnzl9Pjnpj75TEG8ufv40p2J7kCEfwZAsNiuzRIyNNMWT38ig==", "cpu": [ "arm64" ], @@ -696,9 +992,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", - "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.5.tgz", + "integrity": "sha512-dK4QakI42nzWgJT5sm4y4y/O//D4OxM75/cH28RLV+nzIN9AY+YsbuUVrUTjlLjXR6vpyxFbSsbmNuJ6BP9sww==", "cpu": [ "arm64" ], @@ -713,9 +1009,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", - "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.5.tgz", + "integrity": "sha512-fqSALaUu1Wjd1nK2uW2kJDWdLCc8lx1IcY+MTY26Aurfdx19anlzhqXOgCFbBFQnlFDTn4TC1/7Nz4Bl2mLP3A==", "cpu": [ "x64" ], @@ -730,9 +1026,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", - "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.5.tgz", + "integrity": "sha512-/vCnNxlkxs9tKxNDcyWUePpJ/PgTzxIaVhoM5SmG8UV+GR/IcPam4VYxi7GIMo7PSDuNqlJqvprqii9NqqVCMw==", "cpu": [ "x64" ], @@ -747,9 +1043,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", - "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.5.tgz", + "integrity": "sha512-abk0NLA519LxRCszmbE0jYKuQ9YPocOXTiOXOo6Yr+YAT95VH+PtqYAjOJvGKt3viEd/x4qzabAlwd5bHOOARg==", "cpu": [ "arm" ], @@ -764,9 +1060,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", - "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.5.tgz", + "integrity": "sha512-Y7eALiJ8lr0M2HH103Js+g7V34wf6snlpZLAsHI90uLhr3PVlNsbFVAXJC9d/V6BnPyKtpSwI+NcB/RLxsQxuA==", "cpu": [ "arm64" ], @@ -781,9 +1077,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", - "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.5.tgz", + "integrity": "sha512-xMvZgnbZg4YVnR/AX2b3oOPDTFYJvUVaJg5FedA/LuvexAtXibZQej4cnTkw3rjsJ/ggUROB64TdtETiim+FYA==", "cpu": [ "arm64" ], @@ -798,9 +1094,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", - "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.5.tgz", + "integrity": "sha512-GRjeqTUDHTo5GwntsLaAMcBahG3nlpjftXWZLN73HiYQlhwEowvarFgQnRnQZtIp4keXX7quXFbG38uPZBa2EA==", "cpu": [ "ppc64" ], @@ -815,9 +1111,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", - "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.5.tgz", + "integrity": "sha512-vLNTR45F2Uwc8AufkNXPmB4VliaXs+FvcheEogIzOXzO4l+LzieXF5A/TWxLy5HtqpsRCHUfd0lPVrrdgXdLHQ==", "cpu": [ "s390x" ], @@ -832,9 +1128,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", - "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.5.tgz", + "integrity": "sha512-Mgj59/HTuYeK9Gz2MA+mBWKnHsAgkBSec15ZMb1st3oIfFbX7gCjOae7GydHhzcyQi9Z/7M1QuN9bR3oFqF0jQ==", "cpu": [ "x64" ], @@ -849,9 +1145,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", - "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.5.tgz", + "integrity": "sha512-mY8AP0/ichsbhAxGnLa3d3+MwV0EfgrPND2bplI3Ym8T6R2pJ0N87bvrKVwNXmdy3jnr6eQBecdqx/HMknBmpA==", "cpu": [ "x64" ], @@ -866,9 +1162,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", - "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.5.tgz", + "integrity": "sha512-8SLssA2oweAxyRgDp789ACfRb/3P+zNRJpzZxSizxF9m8NUDQ4+3xjo8ttjhVGGw6Qxb70oZiEtIjaKikCO7Yw==", "cpu": [ "arm64" ], @@ -882,29 +1178,10 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", - "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "1.11.1", - "@emnapi/runtime": "1.11.1", - "@napi-rs/wasm-runtime": "^1.1.6" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", - "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.5.tgz", + "integrity": "sha512-vGbruD5zquhoc8D9SViXgN2FBJtNdTyQ4DtG+SWiEGlJiAzoKcZ2xp+xuXCffhubVdt0NJlTZqkeRuERy7g8Cw==", "cpu": [ "arm64" ], @@ -919,9 +1196,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", - "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.5.tgz", + "integrity": "sha512-e/SXpgISz+IoqVcSSI0rx/d/he8zqLex+/rCWpnHpmVfmPIUjag9H6P7zotf0gJHwPUhQxZ/mF8tr6acebT9yw==", "cpu": [ "x64" ], @@ -942,21 +1219,121 @@ "dev": true, "license": "MIT" }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", - "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", - "optional": true, + "peer": true, "dependencies": { - "tslib": "^2.4.0" + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" } }, - "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==", + "node_modules/@testing-library/jest-dom": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.1.tgz", + "integrity": "sha512-oMDTC3oA+6CXSO2JZnvOI7CA6oVub6kij5ggk9ohwye5slmkwxYDXcPOVxgMw/RQlticjtO0C1RZkR97HgrWMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=22", + "npm": ">=6", + "yarn": ">=1" + }, + "peerDependencies": { + "@testing-library/dom": ">=10 <11", + "vitest": ">= 0.32" + }, + "peerDependenciesMeta": { + "vitest": { + "optional": true + } + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.3", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz", + "integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "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": { @@ -1002,6 +1379,13 @@ "@types/d3-selection": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/esrecurse": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", @@ -1060,17 +1444,17 @@ "peer": true }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.63.0.tgz", - "integrity": "sha512-rvwSgqT+DHpWdzfSzPatRLm02a0GlESt++9iy3hLCDY4BgkaLcl8LBi9Yh7XGFBpwcBE/K3024QuXWTpbz4FfQ==", + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.69.0.tgz", + "integrity": "sha512-t5jQTKPIgVW1PE6dR6H6Qz5gm8zjMlX5/2gRaOGd9eO6V7J+tQc6iWKukEe7dY8u9HyYasQ0yfF0/FSSTEO2gA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.63.0", - "@typescript-eslint/type-utils": "8.63.0", - "@typescript-eslint/utils": "8.63.0", - "@typescript-eslint/visitor-keys": "8.63.0", + "@typescript-eslint/scope-manager": "8.69.0", + "@typescript-eslint/type-utils": "8.69.0", + "@typescript-eslint/utils": "8.69.0", + "@typescript-eslint/visitor-keys": "8.69.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -1083,15 +1467,15 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.63.0", + "@typescript-eslint/parser": "^8.69.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "version": "7.0.8", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.8.tgz", + "integrity": "sha512-YYNsSlXBjMk92SKnkwvB5LOVSa6OznlFUGcsvrFgNJbJCd0M1XKeFVRc8ZByeCqz32FivYNHJVooLmdqrmvp/Q==", "dev": true, "license": "MIT", "engines": { @@ -1099,16 +1483,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.63.0.tgz", - "integrity": "sha512-gwh4gvvlaVDKKxyfxMG+Gnu1u9X0OQBwyGLkbwB65dIzBKnxeRiJlNFqlI3zwVhNXJIs6qV7mlFCn/BIajlVig==", + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.69.0.tgz", + "integrity": "sha512-l4b0DhWioGg6Gt2ebGlvfkFMOjRsauxtsnDRwUSRX1qHq3HdTfQHV8wW9zEXeciai6HfeaKOedQn2Zoofx3WBw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.63.0", - "@typescript-eslint/types": "8.63.0", - "@typescript-eslint/typescript-estree": "8.63.0", - "@typescript-eslint/visitor-keys": "8.63.0", + "@typescript-eslint/scope-manager": "8.69.0", + "@typescript-eslint/types": "8.69.0", + "@typescript-eslint/typescript-estree": "8.69.0", + "@typescript-eslint/visitor-keys": "8.69.0", "debug": "^4.4.3" }, "engines": { @@ -1124,14 +1508,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.63.0.tgz", - "integrity": "sha512-e5dh0/UI0ok53AlZ5wRkXCB32z/f2jUZqPR/ygAw5WYaSw8j9EoJWlS7wQjr/dmOaqWjnPIn2m+HhVPCMWGZVQ==", + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.69.0.tgz", + "integrity": "sha512-yi4obFrHMmnsesWehHbkg9zMA7Jt8cXT+mKM08G999pH1yT6nqgsHx7MYm0uY1wAj8CqiBXYRJ7WAT0QdQHQXg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.63.0", - "@typescript-eslint/types": "^8.63.0", + "@typescript-eslint/tsconfig-utils": "^8.69.0", + "@typescript-eslint/types": "^8.69.0", "debug": "^4.4.3" }, "engines": { @@ -1146,14 +1530,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.63.0.tgz", - "integrity": "sha512-uUyfMWCnDSN8bCpcrY8nGP2BLkQ9Xn0GsipcONcpIDWhwhO4ZSyHvyS14U3X75mzxWxL3I2UZIrenTzdzcJO8A==", + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.69.0.tgz", + "integrity": "sha512-ewfspqWvSxKSOaplqAUNbaSFO0eB6w1EtQ+esfYFRm3614Ty4uNtExkcbgd6nWsXphbqKyf9ZYdbZdv2xEoWEQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.63.0", - "@typescript-eslint/visitor-keys": "8.63.0" + "@typescript-eslint/types": "8.69.0", + "@typescript-eslint/visitor-keys": "8.69.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1164,9 +1548,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.63.0.tgz", - "integrity": "sha512-sUAbkulqBAsncKnbRP3+7CtQFRKicexnj7ZwNC6ddCR7EmrXvjvdCYMJbUIqMd6lwoEriZjwLo08aS5tSjVMHg==", + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.69.0.tgz", + "integrity": "sha512-xNqK7YTDZsLniQMV/4rpFR8Z5JlqeRvVjuG1YgF/mdPVH84HSD19L8CczMA0qg2RfwEV231GHH3VnToJDo4MfQ==", "dev": true, "license": "MIT", "engines": { @@ -1181,15 +1565,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.63.0.tgz", - "integrity": "sha512-Nzzh/OGxVCOjObjaj1CQF2RUasyYy2Jfuh+zZ3PjLzG2fYRriAiZLib9UKtO+CpQAS3YHiAS+ckZDclwqI1TPA==", + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.69.0.tgz", + "integrity": "sha512-ZfoJAVg3JZndQEpEl9petVlxau3lRuElc4HRMuAlLCf8to04/iHz692RUSNmXKDjEuJmIL+KZ2/BsOcBc16dsA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.63.0", - "@typescript-eslint/typescript-estree": "8.63.0", - "@typescript-eslint/utils": "8.63.0", + "@typescript-eslint/types": "8.69.0", + "@typescript-eslint/typescript-estree": "8.69.0", + "@typescript-eslint/utils": "8.69.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -1206,9 +1590,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.63.0.tgz", - "integrity": "sha512-xyLtl9DUBBFrcJS4x2pIqGLH68/tC2uOa4Z7pUteW09D3bXnnXUom4dyPikzWgB7llmIc1zoeI3aoUdC4rPK/Q==", + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.69.0.tgz", + "integrity": "sha512-K3VrubUPhlo9VDBS6QdI8YB5j7ClpqLRdefcz6PFrhnwicehBweqQ9Evhl4l+FYz0HdDmMqIiSX0aldGRYtDCA==", "dev": true, "license": "MIT", "engines": { @@ -1220,16 +1604,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.63.0.tgz", - "integrity": "sha512-ygBkU+B7ex5UI/gKhaqexWev79uISfIv7XQCRNYO/jmD8rGLPyWLAb3KMRT6nd8Gt9bmUBi9+iX6tBdYfOY81Q==", + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.69.0.tgz", + "integrity": "sha512-AdFkgqck3Vudb/kWnxlyafU/4aBhHrbQ9locP2N4psXTy5mOBg0SHJumnLvx7r6g1gV4DKvUFwV2nJZBoqOD8w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.63.0", - "@typescript-eslint/tsconfig-utils": "8.63.0", - "@typescript-eslint/types": "8.63.0", - "@typescript-eslint/visitor-keys": "8.63.0", + "@typescript-eslint/project-service": "8.69.0", + "@typescript-eslint/tsconfig-utils": "8.69.0", + "@typescript-eslint/types": "8.69.0", + "@typescript-eslint/visitor-keys": "8.69.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -1261,16 +1645,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.63.0.tgz", - "integrity": "sha512-fUKaeAvrTuQg/Tgt3nliAUSZHJM6DlCcfyEmxCvlX8kieWSStBX+5O5Fnidtc3i2JrH+9c/GL4RY2iasd/GPTA==", + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.69.0.tgz", + "integrity": "sha512-tUbx60BBqQa31kXF5MCsOOLL5E/WzUuxIn7YpAvq+eaUlqvk8/NXnXMBNAdLCr0icjkzem7iUA5QqWHe/hJ1aw==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.63.0", - "@typescript-eslint/types": "8.63.0", - "@typescript-eslint/typescript-estree": "8.63.0" + "@typescript-eslint/scope-manager": "8.69.0", + "@typescript-eslint/types": "8.69.0", + "@typescript-eslint/typescript-estree": "8.69.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1285,13 +1669,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.63.0.tgz", - "integrity": "sha512-UexrHGnGTpbuQHct2ExOc2ZcFbGUS9FOesCxxqdBGcpI1BxYu/LZ6U8Aq6/72XtF/qRBk9nhuGHFJIXXMhPMdw==", + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.69.0.tgz", + "integrity": "sha512-+rmdgPA+EXkNgKYvHvFfhrs35utXbwaC5PGpDquSXcoXQDKUA5UjV0LmTucG/4JXkM31BTu4TilHtrN8IVBe8w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/types": "8.69.0", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -1303,9 +1687,9 @@ } }, "node_modules/@vitejs/plugin-react": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz", - "integrity": "sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==", + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz", + "integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==", "dev": true, "license": "MIT", "dependencies": { @@ -1317,6 +1701,7 @@ "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", + "oxc-transform-react": "^0.145.0", "vite": "^8.0.0" }, "peerDependenciesMeta": { @@ -1325,16 +1710,163 @@ }, "babel-plugin-react-compiler": { "optional": true + }, + "oxc-transform-react": { + "optional": true + } + } + }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.11.tgz", + "integrity": "sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.11", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.11", + "vitest": "4.1.11" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", + "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.11", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true } } }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", + "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", + "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.11", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", + "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.11", + "@vitest/utils": "4.1.11", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", + "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.11", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@xyflow/react": { - "version": "12.11.2", - "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.11.2.tgz", - "integrity": "sha512-eLAlDWJfWnQEhJwGMjlWdAXO9eYllKpliUmPQlAmOLxz6mExXuzMVDUKLMquixgkrtmMFFtug3jGKmYYld12cA==", + "version": "12.11.5", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.11.5.tgz", + "integrity": "sha512-QqoryGkqEhWBuQN9bZWRKhwr3Uoj9lCGj/tg0NnIWHHEOXV+5c8cYsc7Q9TST63V92lHqcNV9P5cjvJ9ZAmblQ==", "license": "MIT", "dependencies": { - "@xyflow/system": "0.0.79", + "@xyflow/system": "0.0.81", "classcat": "^5.0.3", "zustand": "^4.4.0" }, @@ -1353,10 +1885,38 @@ } } }, + "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==", + "version": "0.0.81", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.81.tgz", + "integrity": "sha512-hfbafW4i7uLq7ILok8QWFFm4KMFw22lbZNJHKfHOMSOOoCk5e5m8yfr84UV9NaJajmogWaLVnp2XFU9JQejlqg==", "license": "MIT", "dependencies": { "@types/d3-drag": "^3.0.7", @@ -1393,6 +1953,16 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", @@ -1410,6 +1980,41 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/any-promise": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", @@ -1435,19 +2040,65 @@ "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", "license": "MIT" }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.4.tgz", + "integrity": "sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/attr-accept": { - "version": "2.2.5", - "resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.5.tgz", - "integrity": "sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-4.0.0.tgz", + "integrity": "sha512-hmCnJClmeKNKlsBHgbM8yLZRiQZ4/20UXbLJb6OUT16eWcM5/xNZerr80a/zCYob768KIGq++aLrQNTuwPsIOQ==", "license": "MIT", "engines": { - "node": ">=4" + "node": ">= 22" } }, "node_modules/autoprefixer": { - "version": "10.5.2", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.2.tgz", - "integrity": "sha512-rD5t5DwOjJdmSORcTq64j8MawTC+tbQ+HHqjR4NDumamy/ambn1UJrlKL+KdwujWxMkFjPM3pPHOEA9tl4767Q==", + "version": "10.5.4", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.4.tgz", + "integrity": "sha512-MaU0U/za7N3r6brxD4YB/l4NSrFzLPlANv6wEuQVaIPlD3L4W9rFcQPbL/EilY9BHhHvhfcz3gInDLrEtWT4EA==", "dev": true, "funding": [ { @@ -1465,8 +2116,8 @@ ], "license": "MIT", "dependencies": { - "browserslist": "^4.28.4", - "caniuse-lite": "^1.0.30001799", + "browserslist": "^4.28.6", + "caniuse-lite": "^1.0.30001806", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" @@ -1481,6 +2132,16 @@ "postcss": "^8.1.0" } }, + "node_modules/axe-core": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.13.0.tgz", + "integrity": "sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -1492,9 +2153,9 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.10.42", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.42.tgz", - "integrity": "sha512-c/jurFrDLyui7o1J86yLkRu4LMsTYcBohveus7/I2Hzdn9KIP2bdJPTue/lR1KH46enoPbD77GKeSYNdyPoD3Q==", + "version": "2.11.21", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.21.tgz", + "integrity": "sha512-uh8vpY/1/YyFkunIDFH/12p7/7VdPKA1hejMVEbdkEaWnUz0Hesvx5EbiU6XxjyHZIOju+ZMbQJkRh+es3/spQ==", "dev": true, "license": "Apache-2.0", "bin": { @@ -1504,6 +2165,16 @@ "node": ">=6.0.0" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -1517,16 +2188,16 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", - "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/braces": { @@ -1542,9 +2213,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.5", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.5.tgz", - "integrity": "sha512-Cu2E6QejHWzuDMTkuwgpABFgDfZrXLQq5V13YOACZx4mFAG4IwGTbTfHPMr4WtxlHoXSM8FIuRwYYCz5XiabaQ==", + "version": "4.28.7", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz", + "integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==", "dev": true, "funding": [ { @@ -1562,10 +2233,10 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.10.42", - "caniuse-lite": "^1.0.30001800", - "electron-to-chromium": "^1.5.387", - "node-releases": "^2.0.50", + "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": { @@ -1585,9 +2256,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001803", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001803.tgz", - "integrity": "sha512-g/uHREV2ZpK9qMalCsWaxmA6ol+DX8GYhuf3T40RKoP+oL7vhRJh8LNt73PCjpnR6l14FzfPrB5Yux4PKm2meg==", + "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": [ { @@ -1605,6 +2276,23 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/change-case": { + "version": "5.4.4", + "resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz", + "integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==", + "dev": true, + "license": "MIT" + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -1656,6 +2344,13 @@ "node": ">=6" } }, + "node_modules/colorette": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz", + "integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==", + "dev": true, + "license": "MIT" + }, "node_modules/commander": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", @@ -1672,6 +2367,19 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -1687,6 +2395,27 @@ "node": ">= 8" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -1811,10 +2540,24 @@ "node": ">=12" } }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "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": { @@ -1829,6 +2572,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -1836,6 +2586,16 @@ "dev": true, "license": "MIT" }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -1858,10 +2618,18 @@ "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", "license": "MIT" }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/dompurify": { - "version": "3.4.11", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz", - "integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==", + "version": "3.4.13", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.13.tgz", + "integrity": "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==", "license": "(MPL-2.0 OR Apache-2.0)", "peer": true, "optionalDependencies": { @@ -1869,12 +2637,25 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.388", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.388.tgz", - "integrity": "sha512-Pl/aJaqOOxYxda3vcx1IKSJimwYXHDkEnGn0F+kG2EE68dDtx2uCinaS+Vih8Z91B9t8CSAbiF/HKyWcnXjhzw==", + "version": "1.5.393", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.393.tgz", + "integrity": "sha512-kiDJdIUawuEIcp9XoICKp1iTYDEbgguIPq526N1Q7jIQDeQ3CqoMx71025PI/7E48Ddtw2HuWsVjY7afEgNxmg==", "dev": true, "license": "ISC" }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-errors": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", @@ -1884,6 +2665,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.0.tgz", + "integrity": "sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==", + "dev": true, + "license": "MIT" + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -1908,9 +2696,9 @@ } }, "node_modules/eslint": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.6.0.tgz", - "integrity": "sha512-6lVbcqSodALYo+4ELD0heG6lFiFxnLMuLkiMi2qV8LMp54N8tE8FT1GMH+ev4Ti00nFjNze2+Su6DsV5OQW3Dg==", + "version": "10.9.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.9.1.tgz", + "integrity": "sha512-9VaAkDURekixUQJy0oJYl2DcN6oKMfxay7XzaGYAWQwsb6qfKf+x76R2k1L8kb1boc+FyCAaTA9GmiKaaiaF+A==", "dev": true, "license": "MIT", "workspaces": [ @@ -1920,7 +2708,7 @@ "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", - "@eslint/config-helpers": "^0.6.0", + "@eslint/config-helpers": "^0.7.0", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.2", "@humanfs/node": "^0.16.6", @@ -1944,7 +2732,7 @@ "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", - "minimatch": "^10.2.4", + "minimatch": "^10.2.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, @@ -1987,9 +2775,9 @@ } }, "node_modules/eslint-plugin-react-refresh": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.3.tgz", - "integrity": "sha512-5EMmLCV98Pi4o/f/3DP/v/tNqLHMIc9I8LKClNDWhZ9JTho89/kQcitCXQBMG7sAfVRK0Ie3T2EDOzp1YXYiVA==", + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.5.tgz", + "integrity": "sha512-vG7yLURXNvCHy0FBdbZRwIu0BLPJMlUUJS2Ep7ud9w1YCLftFZtuEjyjhym0Qq9yuZ6LJUitNlu/hMk0gakXAw==", "dev": true, "license": "MIT", "peerDependencies": { @@ -2082,6 +2870,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -2092,6 +2890,16 @@ "node": ">=0.10.0" } }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -2164,15 +2972,12 @@ } }, "node_modules/file-selector": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/file-selector/-/file-selector-2.1.2.tgz", - "integrity": "sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/file-selector/-/file-selector-5.0.1.tgz", + "integrity": "sha512-v0g/PTeuQgvKCBrVRsfVudvwXlRHSWHEQkVgKawgCGHkEpKA1clp3Om5jvEVhz8G9W/mOYjJH9FhkH4C888PgQ==", "license": "MIT", - "dependencies": { - "tslib": "^2.7.0" - }, "engines": { - "node": ">= 12" + "node": ">= 22" } }, "node_modules/fill-range": { @@ -2240,24 +3045,20 @@ } }, "node_modules/framer-motion": { - "version": "11.18.2", - "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-11.18.2.tgz", - "integrity": "sha512-5F5Och7wrvtLVElIpclDT0CBzMVg3dL22B64aZwHtsIY8RB4mXICLrkajK4G9R+ieSAGcgrLeae2SeUTg2pr6w==", + "version": "13.1.1", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-13.1.1.tgz", + "integrity": "sha512-B/xn2TPS4f61cEBLFjiYlQFnBZUW1YVj/LM+C+N4OP8Rs95VLEI2ot/RlfBg111la/EiyECFaJJi/A3FWA8MUA==", "license": "MIT", "dependencies": { - "motion-dom": "^11.18.1", - "motion-utils": "^11.18.1", + "motion-dom": "^13.1.1", + "motion-utils": "^13.0.0", "tslib": "^2.4.0" }, "peerDependencies": { - "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "peerDependenciesMeta": { - "@emotion/is-prop-valid": { - "optional": true - }, "react": { "optional": true }, @@ -2312,9 +3113,9 @@ } }, "node_modules/globals": { - "version": "17.7.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-17.7.0.tgz", - "integrity": "sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg==", + "version": "17.11.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.11.0.tgz", + "integrity": "sha512-Z2I8hM+PbJDXQDq3Icgpzv+mPdwr68iZUU9d5WW4FuXfDUQfkZaZuvjMv42/5crNyw154+9+VWXbYrUgDXbxNw==", "dev": true, "license": "MIT", "engines": { @@ -2324,6 +3125,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/hasown": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", @@ -2353,12 +3164,46 @@ "hermes-estree": "0.25.1" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/html-to-image": { "version": "1.11.13", "resolved": "https://registry.npmjs.org/html-to-image/-/html-to-image-1.11.13.tgz", "integrity": "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==", "license": "MIT" }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -2379,6 +3224,29 @@ "node": ">=0.8.19" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/index-to-position": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz", + "integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -2436,6 +3304,13 @@ "node": ">=0.12.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -2443,6 +3318,45 @@ "dev": true, "license": "ISC" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/jiti": { "version": "1.21.7", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", @@ -2452,12 +3366,111 @@ "jiti": "bin/jiti.js" } }, + "node_modules/js-levenshtein": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz", + "integrity": "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "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/js-yaml": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsdom": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.1.tgz", + "integrity": "sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^6.0.5", + "@asamuzakjp/dom-selector": "^8.3.0", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.7", + "@exodus/bytes": "^1.15.1", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.2", + "undici": "^8.9.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.1.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "canvas": "^3.2.3" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/jsdom/node_modules/whatwg-url": { + "version": "17.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.0.tgz", + "integrity": "sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -2530,9 +3543,9 @@ } }, "node_modules/lightningcss": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", - "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", "dev": true, "license": "MPL-2.0", "dependencies": { @@ -2546,23 +3559,23 @@ "url": "https://opencollective.com/parcel" }, "optionalDependencies": { - "lightningcss-android-arm64": "1.32.0", - "lightningcss-darwin-arm64": "1.32.0", - "lightningcss-darwin-x64": "1.32.0", - "lightningcss-freebsd-x64": "1.32.0", - "lightningcss-linux-arm-gnueabihf": "1.32.0", - "lightningcss-linux-arm64-gnu": "1.32.0", - "lightningcss-linux-arm64-musl": "1.32.0", - "lightningcss-linux-x64-gnu": "1.32.0", - "lightningcss-linux-x64-musl": "1.32.0", - "lightningcss-win32-arm64-msvc": "1.32.0", - "lightningcss-win32-x64-msvc": "1.32.0" + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" } }, "node_modules/lightningcss-android-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", - "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", "cpu": [ "arm64" ], @@ -2581,9 +3594,9 @@ } }, "node_modules/lightningcss-darwin-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", - "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", "cpu": [ "arm64" ], @@ -2602,9 +3615,9 @@ } }, "node_modules/lightningcss-darwin-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", - "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", "cpu": [ "x64" ], @@ -2623,9 +3636,9 @@ } }, "node_modules/lightningcss-freebsd-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", - "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", "cpu": [ "x64" ], @@ -2644,9 +3657,9 @@ } }, "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", - "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", "cpu": [ "arm" ], @@ -2665,9 +3678,9 @@ } }, "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", - "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", "cpu": [ "arm64" ], @@ -2686,9 +3699,9 @@ } }, "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", - "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", "cpu": [ "arm64" ], @@ -2707,9 +3720,9 @@ } }, "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", - "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", "cpu": [ "x64" ], @@ -2728,9 +3741,9 @@ } }, "node_modules/lightningcss-linux-x64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", - "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", "cpu": [ "x64" ], @@ -2749,9 +3762,9 @@ } }, "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", - "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", "cpu": [ "arm64" ], @@ -2770,9 +3783,9 @@ } }, "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", - "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", "cpu": [ "x64" ], @@ -2847,12 +3860,74 @@ } }, "node_modules/lucide-react": { - "version": "0.446.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.446.0.tgz", - "integrity": "sha512-BU7gy8MfBMqvEdDPH79VhOXSEgyG8TSPOKWaExWGCQVqnGH7wGgDngPbofu+KdtVjPQBWbEmnfMTq90CTiiDRg==", + "version": "0.577.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.577.0.tgz", + "integrity": "sha512-4LjoFv2eEPwYDPg/CUdBJQSDfPyzXCRrVW1X7jrx/trgxnxkHFjnVZINbzvzxjN70dxychOfg+FTYwBiS3pQ5A==", "license": "ISC", "peerDependencies": { - "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magicast": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz", + "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" } }, "node_modules/marked": { @@ -2868,6 +3943,13 @@ "node": ">= 18" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -2890,6 +3972,16 @@ "node": ">=8.6" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/minimatch": { "version": "10.2.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", @@ -2918,18 +4010,18 @@ } }, "node_modules/motion-dom": { - "version": "11.18.1", - "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-11.18.1.tgz", - "integrity": "sha512-g76KvA001z+atjfxczdRtw/RXOM3OMSdd1f4DL77qCTF/+avrRJiawSG4yDibEQ215sr9kpinSlX2pCTJ9zbhw==", + "version": "13.1.1", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-13.1.1.tgz", + "integrity": "sha512-XSf8VYWSB6G/0IY3rWVbyLcxWXtAVHkN1PQE2agTaCv3u8RGvbwu56TyyR/MNzBqqNavEBTZzErcxI1TxBrjcA==", "license": "MIT", "dependencies": { - "motion-utils": "^11.18.1" + "motion-utils": "^13.0.0" } }, "node_modules/motion-utils": { - "version": "11.18.1", - "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-11.18.1.tgz", - "integrity": "sha512-49Kt+HKjtbJKLtgO/LKj9Ld+6vw9BjH5d9sc40R/kVyH8GLAXgT42M2NnuPcJNuA3s9ZfZBUcwIgpmZWGEE+hA==", + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-13.0.0.tgz", + "integrity": "sha512-7DnN7TmbLcYXcG4RVadXIihWlyuM9afoUww8Y5Agg431kGKiuL2/OMyP4mJ5wLz+pvN3t5ySClLOaVXJ+wekRQ==", "license": "MIT" }, "node_modules/ms": { @@ -2951,9 +4043,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.15", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", - "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "funding": [ { "type": "github", @@ -2976,9 +4068,9 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.50", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz", - "integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==", + "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": { @@ -3012,6 +4104,54 @@ "node": ">= 6" } }, + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/openapi-typescript": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/openapi-typescript/-/openapi-typescript-7.13.0.tgz", + "integrity": "sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@redocly/openapi-core": "^1.34.6", + "ansi-colors": "^4.1.3", + "change-case": "^5.4.4", + "parse-json": "^8.3.0", + "supports-color": "^10.2.2", + "yargs-parser": "^21.1.1" + }, + "bin": { + "openapi-typescript": "bin/cli.js" + }, + "peerDependencies": { + "typescript": "^5.x" + } + }, + "node_modules/openapi-typescript/node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -3062,7 +4202,38 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/path-exists": { + "node_modules/parse-json": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", + "integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.26.2", + "index-to-position": "^1.1.0", + "type-fest": "^4.39.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", @@ -3088,6 +4259,13 @@ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "license": "MIT" }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -3124,10 +4302,67 @@ "node": ">= 6" } }, + "node_modules/playwright": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/pluralize": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", + "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/postcss": { - "version": "8.5.16", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", - "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", "funding": [ { "type": "opencollective", @@ -3144,7 +4379,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -3290,17 +4525,30 @@ "node": ">= 0.8.0" } }, - "node_modules/prop-types": { - "version": "15.8.1", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", - "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "loose-envify": "^1.4.0", - "object-assign": "^4.1.1", - "react-is": "^16.13.1" + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" } }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -3357,58 +4605,63 @@ } }, "node_modules/react-dropzone": { - "version": "14.4.1", - "resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-14.4.1.tgz", - "integrity": "sha512-QDuV76v3uKbHiH34SpwifZ+gOLi1+RdsCO1kl5vxMT4wW8R82+sthjvBw4th3NHF/XX6FBsqDYZVNN+pnhaw0g==", + "version": "20.1.1", + "resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-20.1.1.tgz", + "integrity": "sha512-2cilRFP8bsjDOHpV0sJ6XY8pzJhmz4/cQ6s9yeckOACWYDR+n4MGGtnJh3Rycq/9SLhDWugqDx1z5mtfmOOZhw==", "license": "MIT", "dependencies": { - "attr-accept": "^2.2.4", - "file-selector": "^2.1.0", - "prop-types": "^15.8.1" + "attr-accept": "^4.0.0", + "file-selector": "^5.0.0" }, "engines": { - "node": ">= 10.13" + "node": ">= 22" }, "peerDependencies": { - "react": ">= 16.8 || 18.0.0" + "@types/react": "*", + "react": ">= 18" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "license": "MIT" - }, "node_modules/react-router": { - "version": "6.30.4", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz", - "integrity": "sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==", + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.3.tgz", + "integrity": "sha512-gyXgtdr5uACJ5b1Q4udzjVV+tb/rlHIMJKuJ0e89R4Kzgz47z/rgP0dIKxktqIEUhDHluGTPJJH/wRha7CyqsA==", "license": "MIT", "dependencies": { - "@remix-run/router": "1.23.3" + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" }, "engines": { - "node": ">=14.0.0" + "node": ">=20.0.0" }, "peerDependencies": { - "react": ">=16.8" + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } } }, "node_modules/react-router-dom": { - "version": "6.30.4", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.4.tgz", - "integrity": "sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==", + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.3.tgz", + "integrity": "sha512-ytVbyBBM7vMfRCam25r0WMhSVSom909A8p+8m0/f1w853dz/xfFu6etAT2SEbVoSnI+ZoPRDqIsQXVT89gp7kg==", "license": "MIT", "dependencies": { - "@remix-run/router": "1.23.3", - "react-router": "6.30.4" + "react-router": "7.18.3" }, "engines": { - "node": ">=14.0.0" + "node": ">=20.0.0" }, "peerDependencies": { - "react": ">=16.8", - "react-dom": ">=16.8" + "react": ">=18", + "react-dom": ">=18" } }, "node_modules/read-cache": { @@ -3432,6 +4685,30 @@ "node": ">=8.10.0" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve": { "version": "1.22.12", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", @@ -3464,13 +4741,13 @@ } }, "node_modules/rolldown": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", - "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.5.tgz", + "integrity": "sha512-VD2IE5PUG4Oj8zz2VGykiYd5wbnjdIiSsNQb8Qu5B+noEp+A78mu2iVvpp27g8es14Tk9rofNs5Tku9iQCS4fA==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.139.0", + "@oxc-project/types": "=0.146.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -3480,21 +4757,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.1.5", - "@rolldown/binding-darwin-arm64": "1.1.5", - "@rolldown/binding-darwin-x64": "1.1.5", - "@rolldown/binding-freebsd-x64": "1.1.5", - "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", - "@rolldown/binding-linux-arm64-gnu": "1.1.5", - "@rolldown/binding-linux-arm64-musl": "1.1.5", - "@rolldown/binding-linux-ppc64-gnu": "1.1.5", - "@rolldown/binding-linux-s390x-gnu": "1.1.5", - "@rolldown/binding-linux-x64-gnu": "1.1.5", - "@rolldown/binding-linux-x64-musl": "1.1.5", - "@rolldown/binding-openharmony-arm64": "1.1.5", - "@rolldown/binding-wasm32-wasi": "1.1.5", - "@rolldown/binding-win32-arm64-msvc": "1.1.5", - "@rolldown/binding-win32-x64-msvc": "1.1.5" + "@rolldown/binding-android-arm-eabi": "1.2.5", + "@rolldown/binding-android-arm64": "1.2.5", + "@rolldown/binding-darwin-arm64": "1.2.5", + "@rolldown/binding-darwin-x64": "1.2.5", + "@rolldown/binding-freebsd-x64": "1.2.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.5", + "@rolldown/binding-linux-arm64-gnu": "1.2.5", + "@rolldown/binding-linux-arm64-musl": "1.2.5", + "@rolldown/binding-linux-ppc64-gnu": "1.2.5", + "@rolldown/binding-linux-s390x-gnu": "1.2.5", + "@rolldown/binding-linux-x64-gnu": "1.2.5", + "@rolldown/binding-linux-x64-musl": "1.2.5", + "@rolldown/binding-openharmony-arm64": "1.2.5", + "@rolldown/binding-win32-arm64-msvc": "1.2.5", + "@rolldown/binding-win32-x64-msvc": "1.2.5" } }, "node_modules/run-parallel": { @@ -3520,6 +4797,19 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -3539,6 +4829,12 @@ "semver": "bin/semver.js" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -3562,14 +4858,27 @@ "node": ">=8" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/sonner": { - "version": "1.7.4", - "resolved": "https://registry.npmjs.org/sonner/-/sonner-1.7.4.tgz", - "integrity": "sha512-DIS8z4PfJRbIyfVFDVnK9rO3eYDtse4Omcm6bt0oEr5/jtLgysmjuBl1frJ9E/EQZrFmKx2A8m/s5s9CRXIzhw==", + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.8.tgz", + "integrity": "sha512-UM/ByIoFra8yzV75n1o0Puu0bw5U/9UNnDacrJNspekBewIfsQ3D6ez1nvlWpt7aTsO6rujQtifBpycwIivqlg==", "license": "MIT", "peerDependencies": { + "@types/react": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, "node_modules/source-map-js": { @@ -3581,12 +4890,39 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/state-local": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz", "integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==", "license": "MIT" }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/sucrase": { "version": "3.35.1", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", @@ -3609,6 +4945,19 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "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", @@ -3621,6 +4970,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tailwind-merge": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.1.tgz", @@ -3698,6 +5054,23 @@ "node": ">=0.8" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -3743,6 +5116,36 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.8", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.8.tgz", + "integrity": "sha512-htwgN/8KRB3z3vnC0BOETVh2m499g5GmyTK9Wq5JBLX3FNz6tSBveAd+fQhzy9hkjif8vy2jwDMR1sGhLtZl2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.8" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.8", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.8.tgz", + "integrity": "sha512-c1P7u0EhACHj7lPy4MJm8iTFEU8+nB0LCtddH0fhP7noaVoXAqafMtOOeX+ulpuPBqnrRgRhw494RICT3mbhnw==", + "dev": true, + "license": "MIT" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -3755,6 +5158,32 @@ "node": ">=8.0" } }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -3793,6 +5222,19 @@ "node": ">= 0.8.0" } }, + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -3808,16 +5250,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.63.0.tgz", - "integrity": "sha512-xgwXyzG4sK9ALkBxbyGkTMMOS+imnW65iPhxCQMK83KhxyoDNW7l+IDqEf9vMdoUidHpOoS967RCq4eMiTexwQ==", + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.69.0.tgz", + "integrity": "sha512-B3MltX0VqjUBNEe3b3sSuiRbfa6XrfHFtBiPamjT5AsW/dfq+y+bc0wyuS9DxAS1LyzCxRp2+rxzpLUvqM2BvA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.63.0", - "@typescript-eslint/parser": "8.63.0", - "@typescript-eslint/typescript-estree": "8.63.0", - "@typescript-eslint/utils": "8.63.0" + "@typescript-eslint/eslint-plugin": "8.69.0", + "@typescript-eslint/parser": "8.69.0", + "@typescript-eslint/typescript-estree": "8.69.0", + "@typescript-eslint/utils": "8.69.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3831,6 +5273,16 @@ "typescript": ">=4.8.4 <6.1.0" } }, + "node_modules/undici": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -3872,6 +5324,13 @@ "punycode": "^2.1.0" } }, + "node_modules/uri-js-replace": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/uri-js-replace/-/uri-js-replace-1.0.1.tgz", + "integrity": "sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==", + "dev": true, + "license": "MIT" + }, "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", @@ -3888,16 +5347,16 @@ "license": "MIT" }, "node_modules/vite": { - "version": "8.1.3", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz", - "integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==", + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz", + "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==", "dev": true, "license": "MIT", "dependencies": { - "lightningcss": "^1.32.0", - "picomatch": "^4.0.4", - "postcss": "^8.5.16", - "rolldown": "~1.1.3", + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.26", + "rolldown": "~1.2.4", "tinyglobby": "^0.2.17" }, "bin": { @@ -3914,7 +5373,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.3.0", + "@vitejs/devtools": "^0.4.0 || ^0.5.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", @@ -3978,6 +5437,157 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/vitest": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", + "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.11", + "@vitest/mocker": "4.1.11", + "@vitest/pretty-format": "4.1.11", + "@vitest/runner": "4.1.11", + "@vitest/snapshot": "4.1.11", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.11", + "@vitest/browser-preview": "4.1.11", + "@vitest/browser-webdriverio": "4.1.11", + "@vitest/coverage-istanbul": "4.1.11", + "@vitest/coverage-v8": "4.1.11", + "@vitest/ui": "4.1.11", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/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/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -3994,6 +5604,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -4004,6 +5631,23 @@ "node": ">=0.10.0" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -4011,6 +5655,23 @@ "dev": true, "license": "ISC" }, + "node_modules/yaml-ast-parser": { + "version": "0.0.43", + "resolved": "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz", + "integrity": "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -4048,20 +5709,18 @@ } }, "node_modules/zustand": { - "version": "4.5.7", - "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", - "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "version": "5.0.15", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.15.tgz", + "integrity": "sha512-MpSEjRiBkA9crSYeOUH32rJC7SVqAbm0Fqcqge/bUi2PPoLcBWKOsG+C8mevmpr8TwXHBVkChbbJiyvkE+i/3A==", "license": "MIT", - "dependencies": { - "use-sync-external-store": "^1.2.2" - }, "engines": { - "node": ">=12.7.0" + "node": ">=12.20.0" }, "peerDependencies": { - "@types/react": ">=16.8", + "@types/react": ">=18.0.0", "immer": ">=9.0.6", - "react": ">=16.8" + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" }, "peerDependenciesMeta": { "@types/react": { @@ -4072,6 +5731,9 @@ }, "react": { "optional": true + }, + "use-sync-external-store": { + "optional": true } } } diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 6021d4e2..01b3bf20 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -6,43 +6,60 @@ "scripts": { "dev": "vite", "build": "tsc -b && vite build", + "generate:api-contract": "node ../../scripts/generate-api-contract.mjs", "preview": "vite preview", - "lint": "eslint ." + "lint": "eslint .", + "test": "vitest run --coverage", + "test:watch": "vitest", + "test:visual": "playwright test" }, "dependencies": { - "@dagrejs/dagre": "^3.0.0", + "@dagrejs/dagre": "^3.1.1", "@monaco-editor/react": "^4.7.0", - "@xyflow/react": "^12.11.2", + "@xyflow/react": "^12.11.5", "clsx": "^2.1.1", - "framer-motion": "^11.5.0", + "framer-motion": "^13.1.1", "html-to-image": "^1.11.13", - "lucide-react": "^0.446.0", + "lucide-react": "^0.577.0", "react": "^18.3.1", "react-dom": "^18.3.1", - "react-dropzone": "^14.2.9", - "react-router-dom": "^6.26.0", - "sonner": "^1.5.0", + "react-dropzone": "^20.1.1", + "react-router-dom": "^7.18.3", + "sonner": "^2.0.8", "tailwind-merge": "^2.5.2", "tailwindcss-animate": "^1.0.7", - "zustand": "^4.5.5" + "zustand": "^5.0.15" }, "devDependencies": { "@eslint/js": "^10.0.1", + "@playwright/test": "^1.62.1", + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "^16.3.3", "@types/react": "^18.3.5", "@types/react-dom": "^18.3.0", - "@vitejs/plugin-react": "^6.0.3", - "autoprefixer": "^10.4.20", - "eslint": "^10.6.0", + "@vitejs/plugin-react": "^6.1.1", + "@vitest/coverage-v8": "^4.1.11", + "autoprefixer": "^10.5.4", + "axe-core": "^4.13.0", + "eslint": "^10.9.1", "eslint-plugin-react-hooks": "^7.1.1", - "eslint-plugin-react-refresh": "^0.5.3", - "globals": "^17.7.0", - "postcss": "^8.4.45", + "eslint-plugin-react-refresh": "^0.5.5", + "globals": "^17.11.0", + "jsdom": "^30.0.1", + "openapi-typescript": "7.13.0", + "postcss": "^8.5.26", "tailwindcss": "^3.4.10", "typescript": "^5.5.4", - "typescript-eslint": "^8.63.0", - "vite": "^8.1.3" + "typescript-eslint": "^8.69.0", + "vite": "^8.2.2", + "vitest": "^4.1.10" }, "overrides": { - "dompurify": "3.4.11" + "dompurify": "3.4.13", + "brace-expansion": "^5.0.9", + "js-yaml": "4.3.2", + "undici": "7.29.0", + "nanoid": "3.3.18", + "browserslist": "4.28.7" } } diff --git a/apps/frontend/playwright.config.ts b/apps/frontend/playwright.config.ts new file mode 100644 index 00000000..e764d40a --- /dev/null +++ b/apps/frontend/playwright.config.ts @@ -0,0 +1,29 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * Browser-driven visual acceptance (#112, #154). + * + * Deliberately not part of the default `npm test`: it needs a running backend + * with seeded fixtures and a downloaded browser, so it is an explicit, + * reproducible gate rather than something that silently blocks unit runs. + * Run through the repository-level `npm run test:e2e` script. + */ +export default defineConfig({ + testDir: './e2e', + timeout: 90_000, + expect: { timeout: 15_000 }, + fullyParallel: false, + workers: 1, + reporter: [['list'], ['html', { open: 'never', outputFolder: 'e2e-report' }]], + use: { + baseURL: process.env.PARTHA_E2E_BASE_URL ?? 'http://localhost:5173', + screenshot: 'only-on-failure', + trace: 'retain-on-failure', + viewport: { width: 1440, height: 900 }, + }, + // Desktop Chrome's default 1280x720 is overridden so the captured evidence + // reflects a realistic desktop review viewport. + projects: [ + { name: 'chromium', use: { ...devices['Desktop Chrome'], viewport: { width: 1440, height: 900 } } }, + ], +}); diff --git a/apps/frontend/src/app/App.tsx b/apps/frontend/src/app/App.tsx index 87b10996..9f75efb2 100644 --- a/apps/frontend/src/app/App.tsx +++ b/apps/frontend/src/app/App.tsx @@ -1,11 +1,18 @@ +import { useEffect } from 'react'; import { RouterProvider } from 'react-router-dom'; import { Toaster } from 'sonner'; import { router } from '@/app/routes/router'; -import { RepositoryProvider } from '@/features/repositories/context/RepositoryProvider'; +import { useAuthStore } from '@/app/store/useAuthStore'; export function App() { + const bootstrap = useAuthStore((state) => state.bootstrap); + + useEffect(() => { + void bootstrap(); + }, [bootstrap]); + return ( - + <> - + ); } diff --git a/apps/frontend/src/app/pages/AIWorkspacePage.test.tsx b/apps/frontend/src/app/pages/AIWorkspacePage.test.tsx new file mode 100644 index 00000000..efe26b81 --- /dev/null +++ b/apps/frontend/src/app/pages/AIWorkspacePage.test.tsx @@ -0,0 +1,24 @@ +import { render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { describe, expect, it, vi } from 'vitest'; +import { AIWorkspacePage } from './AIWorkspacePage'; + +vi.mock('@/features/ai/hooks/useAIWorkspace', () => ({ + useAIWorkspace: () => ({ + activeRepository: { id: 'repo-1', name: 'partha', source: 'upload' }, + source: 'upload', emptyReason: null, query: '', setQuery: vi.fn(), + messages: [], suggestions: [], loading: false, error: null, + historyError: false, providerConfigured: false, ask: vi.fn(), retryLoad: vi.fn(), + }), +})); + +describe('AIWorkspacePage composer', () => { + it('keeps the real provider setup state and question composer available', () => { + render(); + + expect(screen.getByRole('heading', { name: 'AI Workspace' })).toBeVisible(); + expect(screen.getByText(/No AI provider is configured/)).toBeVisible(); + expect(screen.getByLabelText('Ask about the codebase')).toBeVisible(); + expect(screen.getByRole('button', { name: /Settings/i })).toBeVisible(); + }); +}); diff --git a/apps/frontend/src/app/pages/AIWorkspacePage.tsx b/apps/frontend/src/app/pages/AIWorkspacePage.tsx index af52d60f..6a07951c 100644 --- a/apps/frontend/src/app/pages/AIWorkspacePage.tsx +++ b/apps/frontend/src/app/pages/AIWorkspacePage.tsx @@ -1,11 +1,27 @@ import { useNavigate } from 'react-router-dom'; -import { Bot, Send, AlertCircle } from 'lucide-react'; +import { Bot, Send, AlertCircle, Settings } from 'lucide-react'; import { PageHeader } from '@/shared/components/ui/PageHeader'; +import { PreviewBanner } from '@/shared/components/ui/PreviewBanner'; import { EmptyState } from '@/shared/components/ui/EmptyState'; import { DataSourceBadge } from '@/shared/components/ui/DataSourceBadge'; import { useAIWorkspace } from '@/features/ai/hooks/useAIWorkspace'; +import { getErrorDetail } from '@/shared/services/api'; import { cn } from '@/shared/utils/cn'; +// Kept identical to the `limitation` recorded for this surface in the +// product-surface registry, which is what classifies it as Preview. +const AI_WORKSPACE_LIMITATION = + 'Free-form questions require a configured AI provider. They receive sealed-snapshot structural facts, heuristic roles, and observed paths, but no source-file contents; provider answers therefore have no automatic citations.'; + +// States what the surface actually does rather than generic "chat" framing: +// answers are grounded in the already-computed sealed snapshot, and no +// source-file contents leave the instance. It must not claim the thread is +// forgotten -- conversation turns are persisted per owner and repository and +// restored on return (#231), and recent turns are replayed as context, so +// promising otherwise would be a false privacy assurance. +const AI_WORKSPACE_SUBTITLE = + 'Answers are grounded in the structural facts your last analysis already computed — no source-file contents are sent. Your thread is saved for this repository and restored when you return.'; + export function AIWorkspacePage() { const navigate = useNavigate(); const aiWorkspace = useAIWorkspace(); @@ -15,11 +31,12 @@ export function AIWorkspacePage() { if (aiWorkspace.emptyReason === 'no-completed-repositories') { return (
- + + navigate('/upload') }} />
@@ -29,40 +46,42 @@ export function AIWorkspacePage() { if (aiWorkspace.emptyReason === 'no-active-repository' || !activeRepository) { return (
- + +
); } return ( -
- +
+ + -
-
+
+
{aiWorkspace.messages.length === 0 ? (
-
+
-

Ask about {activeRepository.name}

+

Structural facts for {activeRepository.name}

- Questions are sent to your configured AI provider with repository context and file citations when available. + Each answer is generated from the sealed structural facts your last analysis already computed for this repository. No source-file contents are sent to the provider. Your conversation is saved for this repository and restored when you come back, and recent turns are sent along as context.

) : ( aiWorkspace.messages.map((message, index) => (
-
+

{message.content}

{message.citations && message.citations.length > 0 && (
@@ -76,23 +95,60 @@ export function AIWorkspacePage() { )) )} {aiWorkspace.loading &&

AI provider is thinking...

} - {aiWorkspace.error && ( -
- -

{aiWorkspace.error}

-
- )} + {aiWorkspace.error && (() => { + const detail = getErrorDetail(aiWorkspace.error); + return ( +
+ +
+

{detail.message}

+ {detail.details.length > 0 && ( +
    + {detail.details.map((d, i) => ( +
  • {d}
  • + ))} +
+ )} + {aiWorkspace.historyError && ( + + )} +
+
+ ); + })()} {aiWorkspace.suggestions.length > 0 && (
{aiWorkspace.suggestions.map((suggestion) => ( - ))}
)}
-
+
+ {aiWorkspace.providerConfigured === false && ( +
+ + + No AI provider is configured. Free-form questions cannot run until you save a provider in{' '} + + . + +
+ )}
{ @@ -100,17 +156,19 @@ export function AIWorkspacePage() { void aiWorkspace.ask(); }} > + aiWorkspace.setQuery(e.target.value)} - className="flex-1 rounded-md border border-border bg-background px-4 py-2.5 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring" + className="partha-input min-w-0 flex-1 px-3 py-2.5 text-sm sm:px-4" />
} /> + + , + ); +} + +describe('AnalysisPipelinePage integration with the real polling hook', () => { + beforeEach(() => { + useAppStore.setState({ repositories: [repository], analysisRunning: true }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + + it('navigates to the analysed repository as soon as the backend reports completed, without a reload', async () => { + vi.useFakeTimers(); + vi.spyOn(backendService, 'startAnalysis').mockResolvedValue({ + repositoryId: repository.id, + status: 'queued', + jobId: 'job-1', + }); + vi.spyOn(backendService, 'fetchAnalysisStatus').mockResolvedValue({ + repositoryId: repository.id, + status: 'completed', + jobId: 'job-1', + stage: 'completed', + progress: 100, + startedAt: '2026-07-22T08:00:01Z', + completedAt: '2026-07-22T08:00:02Z', + error: null, + }); + + // MemoryRouter navigation is a client-side state transition, not a + // browser reload -- this test proves the SPA route swap happens on its + // own, with no manual reload step anywhere in the flow. + renderPage(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + + expect(screen.getByText('Repo Detail Page')).toBeInTheDocument(); + }); + + it('renders the Analysis Failed terminal state when the job fails server-side', async () => { + vi.useFakeTimers(); + vi.spyOn(backendService, 'fetchAnalysisStatus').mockResolvedValue({ + repositoryId: repository.id, + status: 'failed', + jobId: 'job-1', + stage: 'extracting-modules', + progress: 35, + startedAt: '2026-07-22T08:00:01Z', + completedAt: '2026-07-22T08:00:02Z', + error: 'Extraction crashed.', + }); + + renderPage(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + + expect(screen.getByText('Analysis Failed')).toBeInTheDocument(); + expect(screen.getByText('Extraction crashed.')).toBeInTheDocument(); + }); + + it('recovers from a transient poll error into the completed terminal state, without ever showing Analysis Failed', async () => { + vi.useFakeTimers(); + const fetchStatus = vi + .spyOn(backendService, 'fetchAnalysisStatus') + .mockRejectedValueOnce(new NetworkError('/analysis/repo-1/status')) + .mockResolvedValueOnce({ + repositoryId: repository.id, + status: 'running', + jobId: 'job-1', + stage: 'extracting-modules', + progress: 35, + startedAt: '2026-07-22T08:00:01Z', + completedAt: null, + error: null, + }) + .mockResolvedValue({ + repositoryId: repository.id, + status: 'completed', + jobId: 'job-1', + stage: 'completed', + progress: 100, + startedAt: '2026-07-22T08:00:01Z', + completedAt: '2026-07-22T08:00:02Z', + error: null, + }); + + renderPage(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(screen.getByText('Connection lost — retrying…')).toBeInTheDocument(); + expect(screen.queryByText('Analysis Failed')).not.toBeInTheDocument(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(1000); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(1500); + }); + + expect(screen.getByText('Repo Detail Page')).toBeInTheDocument(); + expect(fetchStatus.mock.calls.length).toBeGreaterThanOrEqual(3); + }); +}); diff --git a/apps/frontend/src/app/pages/AnalysisPipelinePage.test.tsx b/apps/frontend/src/app/pages/AnalysisPipelinePage.test.tsx new file mode 100644 index 00000000..cda93435 --- /dev/null +++ b/apps/frontend/src/app/pages/AnalysisPipelinePage.test.tsx @@ -0,0 +1,177 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useAnalysisPipeline } from '@/features/analysis/hooks/useAnalysisPipeline'; +import { ANALYSIS_STAGES } from '@/shared/types'; +import { AnalysisPipelinePage } from './AnalysisPipelinePage'; + +vi.mock('@/features/analysis/hooks/useAnalysisPipeline', () => ({ + useAnalysisPipeline: vi.fn(), +})); + +const baseAnalysis = { + repository: { + id: 'repo-1', + name: 'sample', + source: 'upload' as const, + size: 10, + fileCount: 1, + status: 'analysing' as const, + analysisStage: 'reading-structure' as const, + analysisProgress: 25, + uploadedAt: '2026-07-22T08:00:00Z', + meta: null, + fileTree: [], + }, + stages: ANALYSIS_STAGES, + currentStageIndex: 2, + status: 'loading' as const, + jobStatus: 'running' as const, + loading: true, + error: null, + connectionStatus: 'connected' as const, + retryingConnection: false, + connectionLost: false, + rateLimited: false, + rateLimitSecondsRemaining: null, + empty: false, + success: false, + source: 'upload' as const, + retry: vi.fn(), + refresh: vi.fn(), + cancel: vi.fn().mockResolvedValue(undefined), + restart: vi.fn().mockResolvedValue(undefined), + cancelling: false, + canCancel: true, + cancelled: false, + completedRepositoryPath: null, +}; + +function renderPage() { + return render( + + + } /> + + , + ); +} + +describe('AnalysisPipelinePage', () => { + beforeEach(() => { + vi.mocked(useAnalysisPipeline).mockReturnValue(baseAnalysis); + }); + + it('shows a cancellation action only while work is active', () => { + renderPage(); + expect(screen.getByRole('button', { name: 'Cancel analysis' })).toBeInTheDocument(); + }); + + it('shows the cancelled terminal state without another cancel action', () => { + vi.mocked(useAnalysisPipeline).mockReturnValue({ + ...baseAnalysis, + status: 'idle', + jobStatus: 'cancelled', + loading: false, + canCancel: false, + cancelled: true, + }); + renderPage(); + expect(screen.getByText('Analysis cancelled')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Cancel analysis' })).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Restart analysis' })); + expect(baseAnalysis.restart).toHaveBeenCalled(); + }); + + it('shows a non-terminal retrying notice for a transient poll failure, not Analysis Failed', () => { + vi.mocked(useAnalysisPipeline).mockReturnValue({ + ...baseAnalysis, + connectionStatus: 'retrying', + retryingConnection: true, + }); + renderPage(); + expect(screen.getByText('Connection lost — retrying…')).toBeInTheDocument(); + expect(screen.queryByText('Analysis Failed')).not.toBeInTheDocument(); + // Progress is preserved, not reset or advanced, while retrying. + expect(screen.getByText('25%')).toBeInTheDocument(); + }); + + it('shows a distinct connectivity error with a manual retry action once retries are exhausted', () => { + vi.mocked(useAnalysisPipeline).mockReturnValue({ + ...baseAnalysis, + connectionStatus: 'lost', + connectionLost: true, + }); + renderPage(); + expect(screen.getByText('Connection lost')).toBeInTheDocument(); + expect(screen.queryByText('Analysis Failed')).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Retry connection' })); + expect(baseAnalysis.retry).toHaveBeenCalled(); + }); + + it('still renders the terminal Analysis Failed state for a real job failure', () => { + vi.mocked(useAnalysisPipeline).mockReturnValue({ + ...baseAnalysis, + status: 'error', + jobStatus: 'failed', + loading: false, + error: 'Analysis failed.', + repository: { ...baseAnalysis.repository, status: 'error', errorMessage: 'Analysis failed.' }, + }); + renderPage(); + expect(screen.getByText('Analysis Failed')).toBeInTheDocument(); + expect(screen.queryByText('Connection lost')).not.toBeInTheDocument(); + }); + + it('shows an accessible countdown for a 429 instead of Analysis Failed (#169)', () => { + vi.mocked(useAnalysisPipeline).mockReturnValue({ + ...baseAnalysis, + connectionStatus: 'rate-limited', + rateLimited: true, + rateLimitSecondsRemaining: 12, + }); + renderPage(); + + const status = screen.getByText(/retrying automatically in 12s/i); + expect(status).toBeInTheDocument(); + expect(status.closest('[role="status"]')).toBeInTheDocument(); + expect(screen.queryByText('Analysis Failed')).not.toBeInTheDocument(); + }); + + it('offers a manual retry once the automatic rate-limit budget is exhausted, without ever reading Analysis Failed', () => { + vi.mocked(useAnalysisPipeline).mockReturnValue({ + ...baseAnalysis, + connectionStatus: 'rate-limited', + rateLimited: true, + rateLimitSecondsRemaining: null, + }); + renderPage(); + + expect(screen.getByText('Still rate limited')).toBeInTheDocument(); + expect(screen.queryByText('Analysis Failed')).not.toBeInTheDocument(); + const callsBeforeClick = vi.mocked(baseAnalysis.retry).mock.calls.length; + fireEvent.click(screen.getByRole('button', { name: 'Retry now' })); + expect(vi.mocked(baseAnalysis.retry).mock.calls.length).toBe(callsBeforeClick + 1); + }); + + it('disables the manual restart action while a rate-limit cooldown is active, to prevent retry-storming', () => { + vi.mocked(useAnalysisPipeline).mockReturnValue({ + ...baseAnalysis, + status: 'idle', + jobStatus: 'cancelled', + loading: false, + canCancel: false, + cancelled: true, + rateLimited: true, + rateLimitSecondsRemaining: 7, + }); + renderPage(); + + const restartButton = screen.getByRole('button', { name: /restart analysis/i }); + expect(restartButton).toBeDisabled(); + expect(restartButton).toHaveTextContent('wait 7s'); + const callsBeforeClick = vi.mocked(baseAnalysis.restart).mock.calls.length; + fireEvent.click(restartButton); + expect(vi.mocked(baseAnalysis.restart).mock.calls.length).toBe(callsBeforeClick); + }); +}); diff --git a/apps/frontend/src/app/pages/AnalysisPipelinePage.tsx b/apps/frontend/src/app/pages/AnalysisPipelinePage.tsx index d224502c..86e84c96 100644 --- a/apps/frontend/src/app/pages/AnalysisPipelinePage.tsx +++ b/apps/frontend/src/app/pages/AnalysisPipelinePage.tsx @@ -1,6 +1,6 @@ import { Navigate, useParams, useNavigate } from 'react-router-dom'; import { motion } from 'framer-motion'; -import { Check, Loader2, Circle, XCircle, ArrowLeft } from 'lucide-react'; +import { Check, Loader2, Circle, XCircle, ArrowLeft, Ban, WifiOff, Clock } from 'lucide-react'; import { PageHeader } from '@/shared/components/ui/PageHeader'; import { DataSourceBadge } from '@/shared/components/ui/DataSourceBadge'; import { useAnalysisPipeline } from '@/features/analysis/hooks/useAnalysisPipeline'; @@ -12,11 +12,6 @@ export function AnalysisPipelinePage() { const analysis = useAnalysisPipeline(id); const repo = analysis.repository; - const handleCancel = () => { - analysis.cancel(); - navigate('/repositories'); - }; - if (!repo) { return (
@@ -44,16 +39,16 @@ export function AnalysisPipelinePage() { -
+
- + Progress {repo.analysisProgress}%
-
+
-
+
{analysis.stages.map((stage, index) => { const isCompleted = index < analysis.currentStageIndex; @@ -79,16 +74,16 @@ export function AnalysisPipelinePage() { ) : isCurrent ? ( -
+
) : ( -
+
)} @@ -107,7 +102,7 @@ export function AnalysisPipelinePage() { {!isLast && (
@@ -118,6 +113,84 @@ export function AnalysisPipelinePage() {
+ {analysis.retryingConnection && ( + + +
+

Connection lost — retrying…

+

+ Analysis is still running. Reconnecting to check its progress. +

+
+
+ )} + + {analysis.connectionLost && ( + + +
+

Connection lost

+

+ Cannot reach the PARTHA backend. The analysis job itself keeps running on the server — this + only affects checking its progress here. +

+ +
+
+ )} + + {/* Suppressed once cancelled: the disabled, countdown-labelled Restart + button below already communicates the same cooldown, and showing + both at once would be a redundant, slightly confusing double banner. */} + {analysis.rateLimited && !analysis.cancelled && ( + + +
+

+ {analysis.rateLimitSecondsRemaining !== null + ? `Too many requests — retrying automatically in ${analysis.rateLimitSecondsRemaining}s` + : 'Still rate limited'} +

+

+ Analysis is still running server-side. This is a temporary rate limit, not a failure — + {analysis.rateLimitSecondsRemaining !== null + ? ' checking progress will resume automatically.' + : ' automatic retries were exhausted. Try again once the limit clears.'} +

+ {analysis.rateLimitSecondsRemaining === null && ( + + )} +
+
+ )} + {(repo.status === 'error' || analysis.error) && ( )} -
+ {analysis.cancelled && ( + + +
+

Analysis cancelled

+

+ No further analysis work will run for this job. +

+ +
+
+ )} + +
+ {analysis.canCancel && ( + + )}
); diff --git a/apps/frontend/src/app/pages/ArchitecturePage.tsx b/apps/frontend/src/app/pages/ArchitecturePage.tsx index a83729e7..536fe6fc 100644 --- a/apps/frontend/src/app/pages/ArchitecturePage.tsx +++ b/apps/frontend/src/app/pages/ArchitecturePage.tsx @@ -1,17 +1,23 @@ -import { useNavigate } from 'react-router-dom'; -import { Network } from 'lucide-react'; +import { useState } from 'react'; +import { Link, useNavigate } from 'react-router-dom'; +import { ExternalLink, Network, ShieldCheck } from 'lucide-react'; import { EmptyState } from '@/shared/components/ui/EmptyState'; +import { PageHeader } from '@/shared/components/ui/PageHeader'; import { ExportMenu } from '@/shared/components/ui/ExportMenu'; import { ArchWorkspace } from '@/features/architecture/components/ArchWorkspace'; +import { AuthenticationExplanationPanel } from '@/features/architecture/components/AuthenticationExplanationPanel'; +import { RevisionManifestPanel } from '@/features/architecture/components/RevisionManifestPanel'; import { useArchitecture } from '@/features/architecture/hooks/useArchitecture'; export function ArchitecturePage() { const navigate = useNavigate(); const architecture = useArchitecture(); + const [authPanelOpen, setAuthPanelOpen] = useState(false); if (architecture.emptyReason === 'no-completed-repositories') { return (
+ +
- {architecture.loading && ( - <> -
- Loading architecture model... - - )} - {architecture.error && ( -
-

{architecture.error}

- -
- )} +
+ Loading architecture model... +
+
+ ); + } + + // A repository can be analysed yet still have no sealed ri.v1 snapshot + // (#217, same 404 contract as Dependencies/Review/Insights). That must read + // as "run analysis again", never as a silent, indistinguishable empty graph. + if (architecture.noSnapshot) { + return ( +
+ + navigate('/upload') }} + /> +
+ ); + } + + if (architecture.error) { + return ( +
+
+

{architecture.error}

+
); } + if (!architecture.model) { + return null; + } + return ( -
-
-

Architecture - {architecture.model.repositoryName}

- +
+
+

System view

Architecture · {architecture.model.repositoryName}

+
+ + Engineering Review + + + + +
+
+ {/* The manifest names the exact revision every citation below belongs + to, so it sits with the evidence rather than in a settings page. */} +
+
+ setAuthPanelOpen(false)} />
); } diff --git a/apps/frontend/src/app/pages/AuthLinkContrast.test.tsx b/apps/frontend/src/app/pages/AuthLinkContrast.test.tsx new file mode 100644 index 00000000..6f1fb2ef --- /dev/null +++ b/apps/frontend/src/app/pages/AuthLinkContrast.test.tsx @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { createMemoryRouter, RouterProvider } from 'react-router-dom'; +import { LoginPage } from './LoginPage'; +import { RegisterPage } from './RegisterPage'; + +// #240: the register/login inline links must not rely on color alone to +// signal that they are links -- they need a persistent, non-hover +// distinction (an underline) at rest. +describe('auth inline link non-color affordance (#240)', () => { + it('underlines the login page register link at rest, not only on hover', () => { + const router = createMemoryRouter([{ path: '/login', element: }], { + initialEntries: ['/login'], + }); + render(); + + const link = screen.getByRole('link', { name: 'Create one' }); + expect(link.className.split(' ')).toEqual(expect.arrayContaining(['underline'])); + expect(link.className).not.toContain('hover:underline'); + }); + + it('underlines the register page sign-in link at rest, not only on hover', () => { + const router = createMemoryRouter([{ path: '/register', element: }], { + initialEntries: ['/register'], + }); + render(); + + const link = screen.getByRole('link', { name: 'Sign in' }); + expect(link.className.split(' ')).toEqual(expect.arrayContaining(['underline'])); + expect(link.className).not.toContain('hover:underline'); + }); +}); diff --git a/apps/frontend/src/app/pages/DashboardPage.test.tsx b/apps/frontend/src/app/pages/DashboardPage.test.tsx new file mode 100644 index 00000000..ce6b385e --- /dev/null +++ b/apps/frontend/src/app/pages/DashboardPage.test.tsx @@ -0,0 +1,134 @@ +import { render, screen, within } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { describe, expect, it, vi } from 'vitest'; +import type { Repository } from '@/shared/types'; +import { DashboardPage } from './DashboardPage'; + +const mockDashboard = vi.hoisted(() => vi.fn()); +vi.mock('@/features/repositories/hooks/useRepositoryDashboard', () => ({ + useRepositoryDashboard: () => mockDashboard(), +})); + +function makeRepository(overrides: Partial): Repository { + return { + id: 'repo-1', + name: 'partha', + source: 'github', + size: 1024, + fileCount: 42, + status: 'completed', + analysisStage: null, + analysisProgress: 100, + uploadedAt: '2026-08-01T00:00:00Z', + meta: null, + fileTree: [], + ...overrides, + }; +} + +function renderDashboard() { + return render( + + + , + ); +} + +describe('DashboardPage', () => { + it('surfaces the most recently completed analysis with its real revision identity', () => { + const older = makeRepository({ + id: 'repo-older', + name: 'older-repo', + analysedAt: '2026-08-01T10:00:00Z', + revision: { kind: 'git', ref: 'refs/heads/main', value: '1234567890abcdef1234567890abcdef12345678' }, + }); + const newer = makeRepository({ + id: 'repo-newer', + name: 'newest-repo', + analysedAt: '2026-08-09T15:30:00Z', + revision: { kind: 'git', ref: 'refs/heads/main', value: 'abcdef1234567890abcdef1234567890abcdef12' }, + }); + const middle = makeRepository({ + id: 'repo-middle', + name: 'middle-repo', + analysedAt: '2026-08-05T09:00:00Z', + revision: { kind: 'git', ref: 'refs/heads/main', value: 'fedcba0987654321fedcba0987654321fedcba09' }, + }); + // Deliberately unsorted, with the winner neither first nor last: picking + // either end of the list must not accidentally pass this test. + mockDashboard.mockReturnValue({ + repositories: [older, newer, middle], + metrics: { totalRepositories: 3, completedRepositories: 3, totalFiles: 126, totalSize: 3072 }, + selectRepository: vi.fn(), + }); + + renderDashboard(); + + const summary = screen.getByTestId('latest-analysis-summary'); + expect(within(summary).getByText('newest-repo')).toBeInTheDocument(); + expect(within(summary).getByText('git abcdef1')).toBeInTheDocument(); + expect(within(summary).queryByText('older-repo')).not.toBeInTheDocument(); + expect(within(summary).queryByText('middle-repo')).not.toBeInTheDocument(); + }); + + it('labels a git revision with its kind and keeps the full immutable value available', () => { + const repo = makeRepository({ + analysedAt: '2026-08-09T15:30:00Z', + revision: { kind: 'git', ref: 'refs/heads/main', value: 'abcdef1234567890abcdef1234567890abcdef12' }, + }); + mockDashboard.mockReturnValue({ + repositories: [repo], + metrics: { totalRepositories: 1, completedRepositories: 1, totalFiles: 42, totalSize: 1024 }, + selectRepository: vi.fn(), + }); + + renderDashboard(); + + // The abbreviation is display-only -- the exact revision identity stays + // recoverable, never replaced by its 7-character prefix. + const revision = within(screen.getByTestId('latest-analysis-summary')).getByText('git abcdef1'); + expect(revision).toHaveAttribute('title', 'abcdef1234567890abcdef1234567890abcdef12'); + }); + + it('renders an upload revision as a short content hash, not the raw sha256 value', () => { + const repo = makeRepository({ + analysedAt: '2026-08-09T15:30:00Z', + revision: { kind: 'upload', value: 'sha256:deadbeefcafefeed1234567890abcdef1234567890abcdef1234567890abcd' }, + }); + mockDashboard.mockReturnValue({ + repositories: [repo], + metrics: { totalRepositories: 1, completedRepositories: 1, totalFiles: 42, totalSize: 1024 }, + selectRepository: vi.fn(), + }); + + renderDashboard(); + + expect(within(screen.getByTestId('latest-analysis-summary')).getByText('upload deadbee')).toBeInTheDocument(); + }); + + it('omits the summary line when no repository has a completed analysis yet', () => { + const repo = makeRepository({ status: 'analysing', analysedAt: undefined, revision: null }); + mockDashboard.mockReturnValue({ + repositories: [repo], + metrics: { totalRepositories: 1, completedRepositories: 0, totalFiles: 42, totalSize: 1024 }, + selectRepository: vi.fn(), + }); + + renderDashboard(); + + expect(screen.queryByText(/Most recently analysed:/)).not.toBeInTheDocument(); + }); + + it('still shows the empty state, without the summary line, when there are no repositories at all', () => { + mockDashboard.mockReturnValue({ + repositories: [], + metrics: { totalRepositories: 0, completedRepositories: 0, totalFiles: 0, totalSize: 0 }, + selectRepository: vi.fn(), + }); + + renderDashboard(); + + expect(screen.getByText('Welcome to PARTHA')).toBeInTheDocument(); + expect(screen.queryByText(/Most recently analysed:/)).not.toBeInTheDocument(); + }); +}); diff --git a/apps/frontend/src/app/pages/DashboardPage.tsx b/apps/frontend/src/app/pages/DashboardPage.tsx index 3b844776..0697aa57 100644 --- a/apps/frontend/src/app/pages/DashboardPage.tsx +++ b/apps/frontend/src/app/pages/DashboardPage.tsx @@ -1,3 +1,4 @@ +import { useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; import { motion } from 'framer-motion'; import { LayoutDashboard, FolderGit2, Upload, Activity, Clock, Github } from 'lucide-react'; @@ -9,11 +10,38 @@ import { DataSourceBadge } from '@/shared/components/ui/DataSourceBadge'; import { useRepositoryDashboard } from '@/features/repositories/hooks/useRepositoryDashboard'; import { repositoryStatusVariant } from '@/features/repositories/status'; import { formatFileSize } from '@/shared/utils/cn'; +import type { Repository, RepositoryRevision } from '@/shared/types'; + +/** + * Abbreviates the real revision identity already on the repository record + * (#87, RFC-0001 §3) -- never invented. Both kinds render as `kind value`, the + * same shape Insights and Engineering Review use, so a bare hex string is + * never shown without saying what it is. The abbreviation is display-only: the + * full immutable value is kept in the `title` at the call site, since a + * 7-character prefix is not itself an identity. + */ +function shortRevisionLabel(revision: RepositoryRevision): string { + if (revision.kind === 'git') return `git ${revision.value.slice(0, 7)}`; + return `upload ${revision.value.replace(/^sha256:/, '').slice(0, 7)}`; +} export function DashboardPage() { const navigate = useNavigate(); const { repositories, metrics, selectRepository } = useRepositoryDashboard(); + // Most useful single fact: which repository's analysis is most current, as + // of what revision -- derived only from fields the repository list (this + // page's existing data) already carries, never a separate fetch. + const mostRecentlyAnalysed = useMemo<(Repository & { analysedAt: string }) | null>(() => { + const analysed = repositories.filter( + (repo): repo is Repository & { analysedAt: string } => repo.status === 'completed' && Boolean(repo.analysedAt), + ); + if (analysed.length === 0) return null; + return analysed.reduce((latest, repo) => + new Date(repo.analysedAt).getTime() > new Date(latest.analysedAt).getTime() ? repo : latest, + ); + }, [repositories]); + if (repositories.length === 0) { return (
@@ -33,13 +61,30 @@ export function DashboardPage() { + {mostRecentlyAnalysed && ( +

+ Most recently analysed:{' '} + {mostRecentlyAnalysed.name} + {' — '} + {new Date(mostRecentlyAnalysed.analysedAt).toLocaleString()} + {mostRecentlyAnalysed.revision && ( + <> + {' at revision '} + + {shortRevisionLabel(mostRecentlyAnalysed.revision)} + + + )} +

+ )} +
@@ -47,9 +92,10 @@ export function DashboardPage() {
-
-
-

Repositories

+
+
+

System view

+

Repositories

{repositories.map((repo, index) => ( @@ -60,22 +106,22 @@ export function DashboardPage() { transition={{ delay: index * 0.05 }} onClick={() => { selectRepository(repo); - if (repo.status === 'analysing') navigate(`/analysis/${repo.id}`); + if (repo.status === 'analysing' || repo.status === 'cancelled') navigate(`/analysis/${repo.id}`); else navigate(`/repositories/${repo.id}`); }} - className="flex items-center justify-between px-5 py-3.5 hover:bg-accent/30 cursor-pointer transition-colors" + className="flex min-w-0 flex-col items-start justify-between gap-3 px-5 py-4 hover:bg-accent cursor-pointer transition-colors sm:flex-row sm:items-center sm:px-6" > -
-
+
+
{repo.source === 'github' ? ( ) : ( )}
-
-

{repo.name}

-
+
+

{repo.name}

+
{repo.meta?.language && ( {repo.meta.language} )} @@ -91,8 +137,8 @@ export function DashboardPage() {
-
- +
+ {repo.meta && ( {repo.meta.totalFiles} files diff --git a/apps/frontend/src/app/pages/DeferredSurfacePage.tsx b/apps/frontend/src/app/pages/DeferredSurfacePage.tsx new file mode 100644 index 00000000..8360e4f5 --- /dev/null +++ b/apps/frontend/src/app/pages/DeferredSurfacePage.tsx @@ -0,0 +1,21 @@ +import { useNavigate } from 'react-router-dom'; +import { CircleDashed } from 'lucide-react'; +import { EmptyState } from '@/shared/components/ui/EmptyState'; +import { PageHeader } from '@/shared/components/ui/PageHeader'; +import type { DeferredProductSurface } from '@/app/routes/productSurfaces'; + +export function DeferredSurfacePage({ surface }: { surface: DeferredProductSurface }) { + const navigate = useNavigate(); + + return ( +
+ + navigate('/dashboard') }} + /> +
+ ); +} diff --git a/apps/frontend/src/app/pages/DependenciesPage.test.tsx b/apps/frontend/src/app/pages/DependenciesPage.test.tsx new file mode 100644 index 00000000..7f4284b1 --- /dev/null +++ b/apps/frontend/src/app/pages/DependenciesPage.test.tsx @@ -0,0 +1,323 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useDependencies } from '@/features/dependencies/hooks/useDependencies'; +import { architectureService } from '@/shared/services/api/architecture'; +import type { DependencyDiagnostic, DependencyGraphResponse, RevisionManifestResponse } from '@/shared/services/api/types'; +import { DependenciesPage } from './DependenciesPage'; + +vi.mock('@/features/dependencies/hooks/useDependencies', () => ({ + useDependencies: vi.fn(), +})); + +vi.mock('@/shared/services/api/architecture', () => ({ + architectureService: { getRevisionManifest: vi.fn() }, +})); + +const revisionManifest: RevisionManifestResponse = { + manifest: { + schemaVersion: 'revision-manifest.v1', + repositoryId: 'repo-1', + revisionKind: 'upload', + revisionValue: `sha256:${'0'.repeat(64)}`, + revisionRef: null, + snapshotId: 'snap_example', + snapshotSchemaVersion: 'ri.v1', + extractors: [{ name: 'dependency-manifest', version: '1.2.0' }], + producerSetHash: `sha256:${'1'.repeat(64)}`, + configHash: `sha256:${'2'.repeat(64)}`, + canonicalGraphHash: `sha256:${'3'.repeat(64)}`, + createdAt: '2026-07-25T00:00:00Z', + sealedAt: '2026-07-25T00:00:05Z', + }, + manifestDigest: `sha256:${'4'.repeat(64)}`, + verificationMethod: 'sha256-canonical-json', + verificationState: 'verified', + verificationNote: 'This digest is a SHA-256 over the canonical JSON encoding of the manifest fields above.', +}; + +const graph: DependencyGraphResponse = { + schemaVersion: 'dependency-graph.v2', + repositoryId: 'repo-1', + repositoryName: 'sample', + revisionKind: 'upload', + revisionValue: `sha256:${'0'.repeat(64)}`, + snapshotId: 'snap_example', + snapshotSchemaVersion: 'ri.v1', + canonicalGraphHash: `sha256:${'1'.repeat(64)}`, + manifestDigest: `sha256:${'2'.repeat(64)}`, + provenance: { + source: 'ri.v1', + snapshotId: 'snap_example', + snapshotSchemaVersion: 'ri.v1', + canonicalGraphHash: `sha256:${'1'.repeat(64)}`, + }, + generatedAt: '2026-07-25T00:00:00Z', + nodes: [ + { + id: 'dep:npm:lodash', + name: 'lodash', + version: '4.17.15', + type: 'production', + ecosystem: 'npm', + declarations: [ + { + name: 'lodash', + manifestPath: 'package.json', + workspacePath: '.', + startLine: 3, + endLine: 3, + extractor: 'dependency-manifest', + extractorVersion: '1.2.0', + ecosystem: 'npm', + version: '4.17.15', + type: 'production', + }, + ], + }, + ], + edges: [{ id: 'edge_1', source: 'repo:root', target: 'dep:npm:lodash', type: 'depends-on' }], + totalDependencies: 1, + manifestCount: 1, + diagnostics: [], + vulnerabilityAssessment: { status: 'not_computed' }, + outdatedAssessment: { status: 'not_computed' }, +}; + +const dependencies: ReturnType = { + activeRepository: { + id: 'repo-1', + name: 'sample', + source: 'upload', + size: 100, + fileCount: 2, + status: 'completed', + analysisStage: 'completed', + analysisProgress: 100, + uploadedAt: '2026-07-15T00:00:00Z', + meta: { + language: 'TypeScript', + framework: 'React', + totalFiles: 2, + totalFolders: 1, + entryPoint: 'src/main.tsx', + configFiles: ['package.json'], + packageManager: 'npm', + hasReadme: false, + hasLicense: false, + licenseName: null, + }, + fileTree: [], + }, + completedRepositories: [], + status: 'success' as const, + loading: false, + error: null, + noSnapshot: false, + empty: false, + success: true, + source: 'upload' as const, + emptyReason: null, + graph, + retry: vi.fn(), + refresh: vi.fn(), + packageManager: 'npm', +}; + +function renderPage() { + return render( + + + , + ); +} + +function mockDependencies(overrides: Partial = {}) { + vi.mocked(useDependencies).mockReturnValue({ ...dependencies, ...overrides }); +} + +function diagnostic(severity: DependencyDiagnostic['severity']): DependencyDiagnostic { + return { + code: 'RI-SRC-MALFORMED', + category: 'malformed source', + severity, + message: 'dependency manifest could not be parsed or has an unsupported structure', + path: 'apps/broken/package.json', + producer: 'dependency-manifest@1.2.0', + details: null, + }; +} + +describe('DependenciesPage', () => { + beforeEach(() => { + mockDependencies(); + vi.mocked(architectureService.getRevisionManifest).mockResolvedValue(revisionManifest); + }); + + it('shows uncomputed assessments without clean badges or numeric fallbacks', () => { + renderPage(); + + expect(screen.getAllByText('Not computed')).toHaveLength(2); + expect(screen.getByText('Vulnerability and outdated-version assessments have not been run.')).toBeInTheDocument(); + expect(screen.getByText('lodash')).toBeInTheDocument(); + expect(screen.queryByText('vulnerable')).not.toBeInTheDocument(); + expect(screen.queryByText('outdated')).not.toBeInTheDocument(); + }); + + it('keeps the sealed snapshot identity one click away instead of on the landing strip (#176)', async () => { + renderPage(); + + await waitFor(() => expect(screen.getByTestId('revision-manifest')).toBeInTheDocument()); + // Collapsed by default: the snapshot id stays visible, but the hashes do not. + expect(screen.getByText('snap_example')).toBeInTheDocument(); + expect(screen.queryByText(revisionManifest.manifestDigest)).not.toBeInTheDocument(); + expect(screen.queryByText(revisionManifest.manifest.canonicalGraphHash!)).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: /details/i })); + + expect(screen.getByText('Manifest digest')).toBeInTheDocument(); + expect(screen.getByText(revisionManifest.manifestDigest)).toBeInTheDocument(); + expect(screen.getByText('Canonical graph hash')).toBeInTheDocument(); + expect(screen.getByText(revisionManifest.manifest.canonicalGraphHash!)).toBeInTheDocument(); + }); + + it('shows an empty inventory instead of a search-miss message', () => { + mockDependencies({ + graph: { + ...graph, + nodes: [], + edges: [], + totalDependencies: 0, + manifestCount: 0, + }, + }); + + renderPage(); + + expect(screen.getByText('No dependencies were discovered.')).toBeInTheDocument(); + expect(screen.getByText('Detected package manager: npm.')).toBeInTheDocument(); + expect(screen.queryByText('No dependencies match your search.')).not.toBeInTheDocument(); + + fireEvent.change(screen.getByPlaceholderText('Search dependencies...'), { target: { value: 'not-present' } }); + + expect(screen.getByText('No dependencies were discovered.')).toBeInTheDocument(); + expect(screen.queryByText('No dependencies match your search.')).not.toBeInTheDocument(); + }); + + it.each(['error', 'fatal'] as const)('does not present a %s extraction diagnostic as an empty inventory', (severity) => { + mockDependencies({ + graph: { + ...graph, + nodes: [], + edges: [], + totalDependencies: 0, + diagnostics: [diagnostic(severity)], + }, + }); + + renderPage(); + + expect(screen.getByText('Dependency inventory may be incomplete.')).toBeInTheDocument(); + expect(screen.getByText('1 blocking extraction issue was reported. Review the affected source files and analyse the repository again.')).toBeInTheDocument(); + expect(screen.queryByText('No dependencies were discovered.')).not.toBeInTheDocument(); + expect(screen.queryByText('No dependencies match your search.')).not.toBeInTheDocument(); + }); + + it.each(['info', 'warning'] as const)('treats a %s diagnostic as a complete empty inventory', (severity) => { + mockDependencies({ + graph: { + ...graph, + nodes: [], + edges: [], + totalDependencies: 0, + manifestCount: 0, + diagnostics: [diagnostic(severity)], + }, + packageManager: null, + }); + + renderPage(); + + expect(screen.getByText('No dependencies were discovered.')).toBeInTheDocument(); + expect(screen.queryByText('Dependency inventory may be incomplete.')).not.toBeInTheDocument(); + expect(screen.getByText(/Package-manager information is unavailable for this repository\./)).toBeInTheDocument(); + expect(screen.queryByText(/Detected package manager:/)).not.toBeInTheDocument(); + }); + + it('warns when a populated inventory has blocking extraction diagnostics', () => { + mockDependencies({ + graph: { + ...graph, + diagnostics: [diagnostic('error')], + }, + }); + + renderPage(); + + expect(screen.getByText('Dependency inventory may be incomplete.')).toBeInTheDocument(); + expect(screen.getByText('1 blocking extraction issue was reported. Review the affected source files and analyse the repository again.')).toBeInTheDocument(); + expect(screen.getByText('lodash')).toBeInTheDocument(); + }); + + it('shows a search-miss message only when a populated inventory has no matches', () => { + renderPage(); + + fireEvent.change(screen.getByPlaceholderText('Search dependencies...'), { target: { value: 'not-present' } }); + + expect(screen.getByText('No dependencies match your search.')).toBeInTheDocument(); + expect(screen.queryByText('No dependencies were discovered.')).not.toBeInTheDocument(); + }); + + it('keeps an unavailable inventory distinct from an empty inventory', () => { + mockDependencies({ graph: null }); + + renderPage(); + + expect(screen.getByText('Dependency inventory unavailable')).toBeInTheDocument(); + expect(screen.getByText('No dependency inventory is available for this repository yet. Analyse the repository again to generate dependency data.')).toBeInTheDocument(); + expect(screen.queryByText('No dependencies were discovered.')).not.toBeInTheDocument(); + expect(screen.queryByText('No dependencies match your search.')).not.toBeInTheDocument(); + }); + + it('keeps loading and error states distinct from an unavailable inventory', () => { + mockDependencies({ graph: null, loading: true }); + const { unmount } = renderPage(); + + expect(screen.getByText('Loading dependency graph...')).toBeInTheDocument(); + expect(screen.queryByText('Dependency inventory unavailable')).not.toBeInTheDocument(); + + unmount(); + mockDependencies({ graph: null, error: 'Dependency service is unavailable.' }); + renderPage(); + + expect(screen.getByText('Dependency service is unavailable.')).toBeInTheDocument(); + expect(screen.queryByText('Dependency inventory unavailable')).not.toBeInTheDocument(); + }); + + it('distinguishes "no sealed snapshot yet" from a genuine zero-dependency snapshot', () => { + mockDependencies({ graph: null, noSnapshot: true }); + + renderPage(); + + expect(screen.getByText('No sealed snapshot yet')).toBeInTheDocument(); + expect( + screen.getByText( + 'This repository has no sealed Repository Intelligence snapshot for its current revision. Analyse it again to generate one.', + ), + ).toBeInTheDocument(); + expect(screen.queryByText('No dependencies were discovered.')).not.toBeInTheDocument(); + expect(screen.queryByText('Dependency inventory unavailable')).not.toBeInTheDocument(); + }); + + it('does not show the no-snapshot state for a genuinely empty sealed snapshot', () => { + mockDependencies({ + graph: { ...graph, nodes: [], edges: [], totalDependencies: 0, manifestCount: 0 }, + noSnapshot: false, + }); + + renderPage(); + + expect(screen.getByText('No dependencies were discovered.')).toBeInTheDocument(); + expect(screen.queryByText('No sealed snapshot yet')).not.toBeInTheDocument(); + }); +}); diff --git a/apps/frontend/src/app/pages/DependenciesPage.tsx b/apps/frontend/src/app/pages/DependenciesPage.tsx index 4aad471e..189a60ae 100644 --- a/apps/frontend/src/app/pages/DependenciesPage.tsx +++ b/apps/frontend/src/app/pages/DependenciesPage.tsx @@ -6,19 +6,28 @@ import { EmptyState } from '@/shared/components/ui/EmptyState'; import { DataSourceBadge } from '@/shared/components/ui/DataSourceBadge'; import { ExportMenu } from '@/shared/components/ui/ExportMenu'; import { useDependencies } from '@/features/dependencies/hooks/useDependencies'; +import { RevisionManifestPanel } from '@/features/architecture/components/RevisionManifestPanel'; +import type { DependencyAssessment } from '@/shared/services/api/types'; export function DependenciesPage() { const navigate = useNavigate(); const dependencies = useDependencies(); const activeRepository = dependencies.activeRepository; const [query, setQuery] = useState(''); + const graph = dependencies.graph; + const hasSearchQuery = query.trim().length > 0; + const hasDependencies = (graph?.nodes.length ?? 0) > 0; + const blockingDiagnosticCount = graph?.diagnostics.filter( + (diagnostic) => diagnostic.severity === 'error' || diagnostic.severity === 'fatal', + ).length ?? 0; + const hasBlockingExtractionDiagnostics = blockingDiagnosticCount > 0; const filteredNodes = useMemo(() => { const q = query.trim().toLowerCase(); - const nodes = dependencies.graph?.nodes || []; + const nodes = graph?.nodes || []; if (!q) return nodes; return nodes.filter((node) => node.name.toLowerCase().includes(q) || node.type.toLowerCase().includes(q)); - }, [dependencies.graph?.nodes, query]); + }, [graph?.nodes, query]); if (dependencies.emptyReason === 'no-completed-repositories') { @@ -54,7 +63,26 @@ export function DependenciesPage() { -
Loading dependency graph...
+
Loading dependency graph...
+
+ ); + } + + // A repository can be analysed yet still have no sealed ri.v1 snapshot (#158, + // same 404 contract as Architecture/Review/Insights). That must read as "run + // analysis again", never as a silent, indistinguishable zero-dependency result. + if (dependencies.noSnapshot) { + return ( +
+ + + + navigate('/upload') }} + />
); } @@ -73,42 +101,93 @@ export function DependenciesPage() { ); } + if (!graph) { + return ( +
+ + + + +
+ ); + } + return (
+ +
+ +
+
- - - - + + + +
+

+ Vulnerability and outdated-version assessments have not been run. +

-
-
+
+
setQuery(event.target.value)} placeholder="Search dependencies..." - className="w-full rounded-md border border-border bg-background pl-8 pr-3 py-1.5 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-ring" + className="w-full rounded-xl border border-primary/25 bg-background py-2 pl-8 pr-3 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-ring" />
- +
- {filteredNodes.length === 0 ? ( + {hasBlockingExtractionDiagnostics && ( +
+ +

Dependency inventory may be incomplete.

+

+ {blockingDiagnosticCount} blocking extraction {blockingDiagnosticCount === 1 ? 'issue was' : 'issues were'} reported. Review the affected source files and analyse the repository again. +

+ {!hasDependencies && dependencies.packageManager && ( +

Detected package manager: {dependencies.packageManager}.

+ )} +
+ )} + {!hasDependencies ? ( + hasBlockingExtractionDiagnostics ? null : ( +
+ +

No dependencies were discovered.

+ {dependencies.packageManager && ( +

Detected package manager: {dependencies.packageManager}.

+ )} +
+ ) + ) : hasSearchQuery && filteredNodes.length === 0 ? (
-

No dependencies matched your search.

+

No dependencies match your search.

) : ( -
+
{filteredNodes.map((node) => ( -
+
-
+
@@ -117,26 +196,31 @@ export function DependenciesPage() {
- {node.type} - {node.hasVulnerabilities && vulnerable} - {node.isOutdated && outdated} + {node.type}
))}
)} -
- {dependencies.packageManager} detected. Dependency relationships are generated from backend package manifests. +
+ {dependencies.packageManager + ? `Detected package manager: ${dependencies.packageManager}. ` + : 'Package-manager information is unavailable for this repository. '} + Dependency data, when available, comes from the sealed repository-intelligence snapshot.
); } -function Stat({ label, value }: { label: string; value: number }) { +function assessmentLabel(assessment: DependencyAssessment | undefined): string { + return assessment?.status === 'not_computed' ? 'Not computed' : 'Unavailable'; +} + +function Stat({ label, value }: { label: string; value: number | string }) { return ( -
-

{label}

+
+

{label}

{value}

); diff --git a/apps/frontend/src/app/pages/DocumentationPage.test.tsx b/apps/frontend/src/app/pages/DocumentationPage.test.tsx new file mode 100644 index 00000000..2d9c028e --- /dev/null +++ b/apps/frontend/src/app/pages/DocumentationPage.test.tsx @@ -0,0 +1,99 @@ +import { render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useDocumentation } from '@/features/documentation/hooks/useDocumentation'; +import { DocumentationPage } from './DocumentationPage'; + +vi.mock('@/features/documentation/hooks/useDocumentation', () => ({ + useDocumentation: vi.fn(), +})); + +const activeRepository = { + id: 'repo-1', + name: 'sample', + source: 'upload' as const, + size: 100, + fileCount: 2, + status: 'completed' as const, + analysisStage: 'completed' as const, + analysisProgress: 100, + uploadedAt: '2026-07-15T00:00:00Z', + meta: null, + fileTree: [], +}; + +const base: ReturnType = { + activeRepository, + completedRepositories: [activeRepository], + status: 'success', + loading: false, + error: null, + noSnapshot: false, + empty: false, + success: true, + source: 'upload', + emptyReason: null, + document: { + content: '# sample\n', + format: 'markdown', + generatedAt: '2026-07-25T00:00:00Z', + source: 'ri.v1', + snapshotId: 'snap_example', + snapshotSchemaVersion: 'ri.v1', + revisionKind: 'upload', + revisionValue: `sha256:${'0'.repeat(64)}`, + }, + sections: ['overview'], + format: 'markdown', + setFormat: vi.fn(), + toggleSection: vi.fn(), + retry: vi.fn(), + refresh: vi.fn(), +}; + +function renderPage() { + return render( + + + , + ); +} + +function mockDocumentation(overrides: Partial = {}) { + vi.mocked(useDocumentation).mockReturnValue({ ...base, ...overrides }); +} + +describe('DocumentationPage', () => { + beforeEach(() => { + mockDocumentation(); + }); + + it('renders the generated document for a successful load', () => { + renderPage(); + + expect(screen.getByText('snap_example', { exact: false })).toBeInTheDocument(); + }); + + it('guides the user to run analysis again instead of showing a generic error when no sealed snapshot exists (#178)', () => { + mockDocumentation({ document: null, success: false, noSnapshot: true }); + + renderPage(); + + expect(screen.getByText('No sealed snapshot yet')).toBeInTheDocument(); + expect( + screen.getByText( + 'This repository has no sealed Repository Intelligence snapshot for its current revision. Analyse it again to generate one.', + ), + ).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /run analysis/i })).toBeInTheDocument(); + }); + + it('keeps a genuine error distinct from the no-snapshot state', () => { + mockDocumentation({ document: null, success: false, error: 'Documentation service is unavailable.' }); + + renderPage(); + + expect(screen.getByText('Documentation service is unavailable.')).toBeInTheDocument(); + expect(screen.queryByText('No sealed snapshot yet')).not.toBeInTheDocument(); + }); +}); diff --git a/apps/frontend/src/app/pages/DocumentationPage.tsx b/apps/frontend/src/app/pages/DocumentationPage.tsx index 4c74049b..1faef228 100644 --- a/apps/frontend/src/app/pages/DocumentationPage.tsx +++ b/apps/frontend/src/app/pages/DocumentationPage.tsx @@ -48,30 +48,54 @@ export function DocumentationPage() { ); } + // A repository can be analysed yet still have no sealed ri.v1 snapshot + // (#178, same 404 contract as Dependencies/Architecture). That must read as + // "run analysis again", never as a generic, unguided error message. + if (documentation.noSnapshot) { + return ( +
+ + navigate('/upload') }} + /> +
+ ); + } + return (
-
-
+ {documentation.document && ( +

+ Sealed {documentation.document.snapshotSchemaVersion} snapshot{' '} + {documentation.document.snapshotId} · revision{' '} + {documentation.document.revisionValue} +

+ )} +
+
- @@ -82,7 +106,7 @@ export function DocumentationPage() { diff --git a/apps/frontend/src/app/pages/EngineeringReviewPage.test.tsx b/apps/frontend/src/app/pages/EngineeringReviewPage.test.tsx new file mode 100644 index 00000000..e3304715 --- /dev/null +++ b/apps/frontend/src/app/pages/EngineeringReviewPage.test.tsx @@ -0,0 +1,237 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useReview } from '@/features/review/hooks/useReview'; +import { useReviewStore } from '@/features/review/store'; +import { architectureService } from '@/shared/services/api/architecture'; +import type { EngineeringReview } from '@/shared/types/review'; +import type { RevisionManifestResponse } from '@/shared/services/api/types'; +import { EngineeringReviewPage } from './EngineeringReviewPage'; + +vi.mock('@/features/review/hooks/useReview', () => ({ + useReview: vi.fn(), +})); + +vi.mock('@/shared/services/api/architecture', () => ({ + architectureService: { getRevisionManifest: vi.fn() }, +})); + +const review: EngineeringReview = { + schemaVersion: 'engineering-review.v2', + repositoryId: 'repo-1', + repositoryName: 'sample', + revisionKind: 'upload', + revisionValue: `sha256:${'0'.repeat(64)}`, + snapshotId: 'snap_example', + snapshotSchemaVersion: 'ri.v1', + canonicalGraphHash: `sha256:${'1'.repeat(64)}`, + manifestDigest: `sha256:${'2'.repeat(64)}`, + provenance: { + source: 'ri.v1', + snapshotId: 'snap_example', + snapshotSchemaVersion: 'ri.v1', + canonicalGraphHash: `sha256:${'1'.repeat(64)}`, + }, + generatedAt: '2026-07-25T00:00:00Z', + assessmentStatus: 'assessed', + categories: [], + findings: [], + pagination: { offset: 0, limit: 50, total: 0 }, + summary: { + message: 'No findings were surfaced for this snapshot.', + findingsBySeverity: { info: 0, low: 0, medium: 0, high: 0, critical: 0 }, + assessedCategories: 8, + partiallyAssessedCategories: 0, + notAssessedCategories: 0, + insufficientEvidenceCategories: 0, + evidenceBackedFindingCount: 0, + fileScopedFindingCount: 0, + omittedUnsupportedDiagnosticCount: 0, + vulnerabilityScanning: 'not_assessed', + }, +}; + +const revisionManifest: RevisionManifestResponse = { + manifest: { + schemaVersion: 'revision-manifest.v1', + repositoryId: 'repo-1', + revisionKind: 'upload', + revisionValue: `sha256:${'0'.repeat(64)}`, + revisionRef: null, + snapshotId: 'snap_example', + snapshotSchemaVersion: 'ri.v1', + extractors: [{ name: 'typescript-extractor', version: '1.0.0' }], + producerSetHash: `sha256:${'1'.repeat(64)}`, + configHash: `sha256:${'2'.repeat(64)}`, + canonicalGraphHash: `sha256:${'3'.repeat(64)}`, + createdAt: '2026-07-25T00:00:00Z', + sealedAt: '2026-07-25T00:00:05Z', + }, + manifestDigest: `sha256:${'4'.repeat(64)}`, + verificationMethod: 'sha256-canonical-json', + verificationState: 'verified', + verificationNote: 'This digest is a SHA-256 over the canonical JSON encoding of the manifest fields above.', +}; + +function renderPage() { + return render( + + + , + ); +} + +describe('EngineeringReviewPage', () => { + beforeEach(() => { + vi.mocked(useReview).mockReturnValue({ + review, + data: review, + source: 'upload', + status: 'success', + loading: false, + error: null, + noSnapshot: false, + empty: false, + success: true, + retry: vi.fn(), + refresh: vi.fn(), + loadMore: vi.fn(), + loadingMore: false, + activeRepository: null, + completedRepositories: [], + emptyReason: null, + }); + useReviewStore.setState({ + review, + selectedFindingId: null, + filterCategory: 'all', + filterSeverity: 'all', + filterDiagnosticCode: null, + }); + vi.mocked(architectureService.getRevisionManifest).mockResolvedValue(revisionManifest); + }); + + it('keeps the sealed snapshot identity one click away instead of on the landing strip (#176)', async () => { + renderPage(); + + await waitFor(() => expect(screen.getByTestId('revision-manifest')).toBeInTheDocument()); + expect(screen.getByText('snap_example')).toBeInTheDocument(); + expect(screen.queryByText(revisionManifest.manifestDigest)).not.toBeInTheDocument(); + expect(screen.queryByText(revisionManifest.manifest.canonicalGraphHash!)).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: /details/i })); + + expect(screen.getByText('Manifest digest')).toBeInTheDocument(); + expect(screen.getByText(revisionManifest.manifestDigest)).toBeInTheDocument(); + expect(screen.getByText(revisionManifest.manifest.canonicalGraphHash!)).toBeInTheDocument(); + }); + + it('still renders the evidence-backed summary and assessment matrix', () => { + renderPage(); + + expect(screen.getByText('Evidence-backed summary')).toBeInTheDocument(); + expect(screen.getByText('No findings were surfaced for this snapshot.')).toBeInTheDocument(); + expect( + screen.getByText('No overall score, grade, health percentage, or category score is produced.'), + ).toBeInTheDocument(); + }); + + it('requests the next server page when "Show more" is clicked, rather than slicing an in-memory array', () => { + const provenance = review.provenance; + const finding = { + id: 'finding-0', + category: 'relationship_resolution' as const, + severity: 'medium' as const, + title: 'Unresolved relationship', + explanation: 'explanation', + path: 'src/file.ts', + startLine: 1, + endLine: 1, + snapshotId: 'snap_example', + factId: 'fact-0', + evidenceId: 'evidence-0', + extractorName: 'typescript-extractor', + extractorVersion: '1.0.0', + diagnosticCode: 'RI-RES-UNRESOLVED', + ruleId: 'engineering-review.v2/RI-RES-UNRESOLVED', + remediationGuidance: 'remediation', + supportStatus: 'supported' as const, + provenance, + evidence: { + evidenceId: 'evidence-0', + snapshotId: 'snap_example', + factId: 'fact-0', + path: 'src/file.ts', + startLine: 1, + endLine: 1, + extractorName: 'typescript-extractor', + extractorVersion: '1.0.0', + }, + }; + const partialReview: EngineeringReview = { + ...review, + findings: [finding], + pagination: { offset: 0, limit: 1, total: 5 }, + summary: { ...review.summary, evidenceBackedFindingCount: 5 }, + }; + const loadMore = vi.fn(); + vi.mocked(useReview).mockReturnValue({ + review: partialReview, + data: partialReview, + source: 'upload', + status: 'success', + loading: false, + error: null, + noSnapshot: false, + empty: false, + success: true, + retry: vi.fn(), + refresh: vi.fn(), + loadMore, + loadingMore: false, + activeRepository: null, + completedRepositories: [], + emptyReason: null, + }); + useReviewStore.setState({ + review: partialReview, + selectedFindingId: null, + filterCategory: 'all', + filterSeverity: 'all', + filterDiagnosticCode: null, + }); + + renderPage(); + + expect(screen.getByText('5 matching supported findings · showing 1')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: /show 4 more/i })); + expect(loadMore).toHaveBeenCalledTimes(1); + }); + + it('guides the user to run analysis again instead of showing a generic error when no sealed snapshot exists (#178)', () => { + vi.mocked(useReview).mockReturnValue({ + review: null, + data: null, + source: null, + status: 'error', + loading: false, + error: null, + noSnapshot: true, + empty: false, + success: false, + retry: vi.fn(), + refresh: vi.fn(), + loadMore: vi.fn(), + loadingMore: false, + activeRepository: null, + completedRepositories: [], + emptyReason: null, + }); + + renderPage(); + + expect(screen.getByText('No sealed snapshot yet')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /run analysis/i })).toBeInTheDocument(); + expect(screen.queryByText('Evidence-backed summary')).not.toBeInTheDocument(); + }); +}); diff --git a/apps/frontend/src/app/pages/EngineeringReviewPage.tsx b/apps/frontend/src/app/pages/EngineeringReviewPage.tsx index 99c3d471..66682174 100644 --- a/apps/frontend/src/app/pages/EngineeringReviewPage.tsx +++ b/apps/frontend/src/app/pages/EngineeringReviewPage.tsx @@ -1,209 +1,234 @@ -import { useMemo } from 'react'; -import { useNavigate } from 'react-router-dom'; -import { motion, AnimatePresence } from 'framer-motion'; -import { ShieldCheck, AlertTriangle, AlertCircle, Info, CheckCircle2 } from 'lucide-react'; +import { useEffect, useMemo } from 'react'; +import { Link, useNavigate, useSearchParams } from 'react-router-dom'; +import { AnimatePresence } from 'framer-motion'; +import { AlertTriangle, CheckCircle2, ExternalLink, Info, ShieldCheck } from 'lucide-react'; import { EmptyState } from '@/shared/components/ui/EmptyState'; import { PageHeader } from '@/shared/components/ui/PageHeader'; -import { DataSourceBadge } from '@/shared/components/ui/DataSourceBadge'; import { useReviewStore } from '@/features/review/store'; import { useReview } from '@/features/review/hooks/useReview'; -import { ScoreCard, OverallScoreRing } from '@/features/review/components/ScoreCard'; import { FindingCard } from '@/features/review/components/FindingCard'; import { FindingDetail } from '@/features/review/components/FindingDetail'; import { ReviewFilters } from '@/features/review/components/ReviewFilters'; -import { Roadmap } from '@/features/review/components/Roadmap'; import { ExportMenu } from '@/shared/components/ui/ExportMenu'; -import { cn } from '@/shared/utils/cn'; +import { RevisionManifestPanel } from '@/features/architecture/components/RevisionManifestPanel'; +import type { ReviewAssessmentState, ReviewCategory, ReviewSeverity } from '@/shared/types/review'; + +const STATE_LABEL: Record = { + assessed: 'Assessed', + partially_assessed: 'Partially assessed', + not_assessed: 'Not assessed', + insufficient_evidence: 'Insufficient evidence', +}; + +const SEVERITY_ORDER: ReviewSeverity[] = ['critical', 'high', 'medium', 'low', 'info']; export function EngineeringReviewPage() { const navigate = useNavigate(); + const [searchParams] = useSearchParams(); const reviewState = useReview(); - const { - review, - selectedFindingId, - setSelectedFindingId, - setFilterCategory, - setFilterSeverity, - filteredFindings, - } = useReviewStore(); - const activeReview = reviewState.review || review; - - const findings = filteredFindings(); + const { review, selectedFindingId, setSelectedFindingId, setFilterDiagnosticCode, setFilterCategory } = + useReviewStore(); + + useEffect(() => { + setFilterDiagnosticCode(searchParams.get('diagnosticCode')); + }, [searchParams, setFilterDiagnosticCode]); + + useEffect(() => { + const category = searchParams.get('category'); + setFilterCategory((category as ReviewCategory | null) ?? 'all'); + }, [searchParams, setFilterCategory]); + + const totalFilteredCount = review?.pagination.total ?? 0; + const findings = review?.findings ?? []; + const hasMoreFindings = findings.length < totalFilteredCount; const selectedFinding = useMemo( - () => activeReview?.findings.find((f) => f.id === selectedFindingId) || null, - [activeReview, selectedFindingId] + () => review?.findings.find((finding) => finding.id === selectedFindingId) ?? null, + [review, selectedFindingId], ); if (reviewState.emptyReason === 'no-completed-repositories') { return ( -
+
+ navigate('/upload') }} />
); } - if (reviewState.emptyReason === 'no-active-repository') { return ( -
+
+
); } - - if (reviewState.error) { - return ( -
-
-

{reviewState.error}

- -
-
- ); + if (reviewState.loading) { + return ; } - - if (reviewState.loading || !activeReview) { + // A repository can be analysed yet still have no sealed ri.v1 snapshot + // (#178, same 404 contract as Dependencies/Architecture). That must read as + // "run analysis again", never as a generic, unguided error message. + if (reviewState.noSnapshot) { return ( -
-
-
- Loading engineering review... -
+
+ + navigate('/upload') }} + />
); } + if (reviewState.error) { + return ; + } + if (!review) return null; return ( -
-
-
-
- - - - +
+ + + Architecture + + + Insights + + + + +
+ PARTHA reports only findings supported by the selected sealed snapshot. Vulnerability scanning and + categories without sufficient evidence are marked Not assessed. +
+ +
+ +
+ +
+
+
+

Engineering review

Evidence-backed summary

+

{review.summary.message}

+

+ No overall score, grade, health percentage, or category score is produced. +

+
+ {SEVERITY_ORDER.map((severity) => ( +
+

+ {review.summary.findingsBySeverity[severity]} +

+

{severity}

+
+ ))} +
+
+
- {/* Executive Summary */} -
-
-
- -
- - - - -
+
+

Assessment matrix

+
+ {review.categories.map((category) => ( +
+
+

{category.label}

+ + {STATE_LABEL[category.state]} +
-
-
- - {/* Health Scores */} -
-

Health Scores

-
- {activeReview.scores.map((score) => ( - { - setFilterCategory(score.category); - setFilterSeverity('all'); - }} - /> - ))} -
-
- - {/* Findings */} -
-
-

Findings

- {findings.length} results -
- -
- {findings.length === 0 ? ( -
- -

No findings match the current filters

-
- ) : ( - findings.map((finding) => ( - - setSelectedFindingId( - selectedFindingId === finding.id ? null : finding.id - )} - /> - - )) - )} -
-
- - {/* Roadmap */} -
- -
+

{category.explanation}

+

{category.findingCount} supported findings

+ + ))}
-
+
+ +
+
+
+

Findings

+

+ {totalFilteredCount} matching supported findings + {hasMoreFindings ? ` · showing ${findings.length}` : ''} +

+
+ +
+ {review.summary.evidenceBackedFindingCount === 0 ? ( +
+ +

No evidence-backed findings

+

+ This does not mean every engineering category was assessed; consult the matrix above. +

+
+ ) : findings.length === 0 ? ( +
+ +

No findings match the selected filters.

+
+ ) : ( +
+ {findings.map((finding) => ( + setSelectedFindingId(finding.id)} + /> + ))} + {hasMoreFindings && ( + + )} +
+ )} +
+ + {review.summary.omittedUnsupportedDiagnosticCount > 0 && ( +
+ +

+ {review.summary.omittedUnsupportedDiagnosticCount} diagnostic record(s) were not promoted to findings + because an exact supporting evidence span was unavailable. +

+
+ )} - {/* Detail Panel */} {selectedFinding && ( setSelectedFindingId(null)} /> @@ -213,24 +238,20 @@ export function EngineeringReviewPage() { ); } -function SummaryPill({ - icon: Icon, - label, - count, - color, - bg, -}: { - icon: typeof AlertTriangle; - label: string; - count: number; - color: string; - bg: string; -}) { +function StatusPanel({ text }: { text: string }) { + return ( +
+
+ {text} +
+ ); +} + +function ErrorPanel({ message, retry }: { message: string; retry: () => void }) { return ( -
0 && bg)}> - 0 ? color : 'text-muted-foreground/50')} /> -

0 ? color : 'text-muted-foreground/50')}>{count}

-

{label}

+
+

{message}

+
); } diff --git a/apps/frontend/src/app/pages/InsightsPage.test.tsx b/apps/frontend/src/app/pages/InsightsPage.test.tsx new file mode 100644 index 00000000..51a5b046 --- /dev/null +++ b/apps/frontend/src/app/pages/InsightsPage.test.tsx @@ -0,0 +1,102 @@ +import { render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useInsights } from '@/features/insights/hooks/useInsights'; +import type { RepositoryInsights } from '@/shared/types/insights'; +import { InsightsPage } from './InsightsPage'; + +vi.mock('@/features/insights/hooks/useInsights', () => ({ + useInsights: vi.fn(), +})); + +const insightsData: RepositoryInsights = { + schemaVersion: 'repository-insights.v1', + repositoryId: 'repo-1', + repositoryName: 'sample', + revisionKind: 'upload', + revisionValue: `sha256:${'0'.repeat(64)}`, + snapshotId: 'snap_example', + snapshotSchemaVersion: 'ri.v1', + canonicalGraphHash: `sha256:${'1'.repeat(64)}`, + manifestDigest: `sha256:${'2'.repeat(64)}`, + provenance: { + source: 'ri.v1', + snapshotId: 'snap_example', + snapshotSchemaVersion: 'ri.v1', + canonicalGraphHash: `sha256:${'1'.repeat(64)}`, + }, + computedAt: '2026-07-25T00:00:00Z', + snapshotCreatedAt: '2026-07-25T00:00:00Z', + snapshotSealedAt: '2026-07-25T00:00:05Z', + extractorSet: [], + metrics: [], + relationshipsByPredicate: [], + diagnosticsBySeverity: [], + diagnosticsByCode: [], + languages: [], + changeOverTime: { assessmentState: 'not_assessed', message: 'Change-over-time insights are not available yet.' }, +}; + +const base: ReturnType = { + data: insightsData, + activeRepository: null, + completedRepositories: [], + status: 'success', + loading: false, + error: null, + noSnapshot: false, + empty: false, + success: true, + emptyReason: null, + retry: vi.fn(), + refresh: vi.fn(), +}; + +function renderPage() { + return render( + + + , + ); +} + +function mockInsights(overrides: Partial = {}) { + vi.mocked(useInsights).mockReturnValue({ ...base, ...overrides }); +} + +describe('InsightsPage', () => { + beforeEach(() => { + mockInsights(); + }); + + it('renders the sealed snapshot identity for a successful load', () => { + renderPage(); + + expect(screen.getByText('snap_example')).toBeInTheDocument(); + expect(screen.getByText('Extractor inventory')).toBeInTheDocument(); + }); + + it('guides the user to run analysis again instead of showing a generic error when no sealed snapshot exists (#178)', () => { + mockInsights({ data: null, status: 'error', success: false, noSnapshot: true }); + + renderPage(); + + expect(screen.getByText('No sealed snapshot yet')).toBeInTheDocument(); + expect( + screen.getByText( + 'This repository has no sealed Repository Intelligence snapshot for its current revision. Analyse it again to generate one.', + ), + ).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /run analysis/i })).toBeInTheDocument(); + expect(screen.queryByText('Extractor inventory')).not.toBeInTheDocument(); + }); + + it('keeps a genuine error distinct from the no-snapshot state', () => { + mockInsights({ data: null, status: 'error', success: false, error: 'Insights service is unavailable.' }); + + renderPage(); + + expect(screen.getByText('Insights service is unavailable.')).toBeInTheDocument(); + expect(screen.queryByText('No sealed snapshot yet')).not.toBeInTheDocument(); + }); +}); diff --git a/apps/frontend/src/app/pages/InsightsPage.tsx b/apps/frontend/src/app/pages/InsightsPage.tsx index e374307b..d35b5173 100644 --- a/apps/frontend/src/app/pages/InsightsPage.tsx +++ b/apps/frontend/src/app/pages/InsightsPage.tsx @@ -1,57 +1,227 @@ -import { useNavigate } from 'react-router-dom'; -import { Lightbulb, Lock } from 'lucide-react'; +import { Link, useNavigate } from 'react-router-dom'; +import { BarChart3, ExternalLink, Info, ShieldCheck } from 'lucide-react'; import { PageHeader } from '@/shared/components/ui/PageHeader'; import { EmptyState } from '@/shared/components/ui/EmptyState'; -import { DataSourceBadge } from '@/shared/components/ui/DataSourceBadge'; import { useInsights } from '@/features/insights/hooks/useInsights'; +import type { InsightBreakdown, InsightMetric } from '@/shared/types/insights'; export function InsightsPage() { const navigate = useNavigate(); const insights = useInsights(); - const activeRepository = insights.activeRepository; if (insights.emptyReason === 'no-completed-repositories') { return (
- + navigate('/upload') }} />
); } - - if (insights.emptyReason === 'no-active-repository' || !activeRepository) { + if (insights.emptyReason === 'no-active-repository') { return (
- +
); } + if (insights.loading) { + return ( +
+
+ Loading snapshot-backed repository insights... +
+ ); + } + // A repository can be analysed yet still have no sealed ri.v1 snapshot + // (#178, same 404 contract as Dependencies/Architecture). That must read as + // "run analysis again", never as a generic, unguided error message. + if (insights.noSnapshot) { + return ( +
+ + navigate('/upload') }} + /> +
+ ); + } + if (insights.error) { + return ( +
+

{insights.error}

+ +
+ ); + } + if (!insights.data) return null; + const data = insights.data; return ( -
- - +
+ + + Architecture + + + Engineering Review + -
-
- -
-

Code Insights Coming Soon

-

- Repository {activeRepository.name} is ready for insight generation. - This workflow is intentionally disabled until the insights endpoint is implemented. + +

+ +

+ Every value below is defined and counted from the selected sealed snapshot. Missing assessments remain + visibly unavailable; no legacy metadata is used.

+ +
+ + + + +
+ +
+

Defined metrics

+
+ {data.metrics.map((metric) => )} +
+
+ +
+ + + +
+

Diagnostics by code

+ {data.diagnosticsByCode.length === 0 ? ( +

No diagnostics were stored in this snapshot.

+ ) : ( +
    + {data.diagnosticsByCode.map((item) => ( +
  • + + {item.key} + + {item.value} +
  • + ))} +
+ )} +
+
+ +
+

Extractor inventory

+
+ + + + + + + + + + {data.extractorSet.map((extractor) => ( + + + + + + ))} + +
ExtractorVersionEvidence records
{extractor.name}{extractor.version || 'Not available'}{extractor.evidenceRecordCount}
+
+
+ +
+ +
+

Change over time

+

{data.changeOverTime.message}

+
+
+
+ ); +} + +function MetricCard({ metric }: { metric: InsightMetric }) { + const value = + metric.assessmentState === 'not_assessed' + ? 'Not assessed' + : metric.assessmentState === 'insufficient_evidence' + ? 'Insufficient evidence' + : metric.numerator !== null && metric.denominator !== null + ? `${metric.numerator} / ${metric.denominator}` + : String(metric.value ?? 'Not available'); + + return ( +
+
+

{metric.label}

+ {metric.unit} +
+

{value}

+

{metric.definition}

+

{metric.id}

+
+ ); +} + +function BreakdownCard({ title, items }: { title: string; items: InsightBreakdown[] }) { + return ( +
+

{title}

+ {items.length === 0 ? ( +

No records are available for this breakdown.

+ ) : ( +
    + {items.map((item) => ( +
  • + {item.label} + {item.value} +
  • + ))} +
+ )} +
+ ); +} + +function Identity({ label, value }: { label: string; value: string }) { + return ( +
+

{label}

+

{value}

); } diff --git a/apps/frontend/src/app/pages/LandingPage.test.tsx b/apps/frontend/src/app/pages/LandingPage.test.tsx new file mode 100644 index 00000000..0e2ab5be --- /dev/null +++ b/apps/frontend/src/app/pages/LandingPage.test.tsx @@ -0,0 +1,94 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { LandingPage } from './LandingPage'; +import { useLandingThemeStore } from '@/features/landing/hooks/useLandingTheme'; + +function renderLanding() { + return render( + + + + ); +} + +describe('LandingPage theme', () => { + beforeEach(() => { + window.localStorage.clear(); + useLandingThemeStore.setState({ preference: 'light', resolved: 'light' }); + }); + + afterEach(() => { + window.localStorage.clear(); + }); + + it('renders in light mode by default, with no landing-dark class anywhere', () => { + renderLanding(); + + const main = screen.getByRole('img').closest('main'); + expect(main).not.toHaveClass('landing-dark'); + expect(screen.getByRole('img').getAttribute('src')).toMatch(/landing-reference\.svg/); + expect(screen.getByRole('img').getAttribute('src')).not.toMatch(/landing-reference-dark\.svg/); + }); + + it('switching to Dark applies the scoped class and swaps the canvas image', () => { + renderLanding(); + + fireEvent.click(screen.getByRole('radio', { name: 'Dark' })); + + const main = screen.getByRole('img').closest('main'); + expect(main).toHaveClass('landing-dark'); + expect(screen.getByRole('img').getAttribute('src')).toMatch(/landing-reference-dark\.svg/); + }); + + it('switching back to Light removes the scoped class and restores the light canvas', () => { + renderLanding(); + + fireEvent.click(screen.getByRole('radio', { name: 'Dark' })); + fireEvent.click(screen.getByRole('radio', { name: 'Light' })); + + const main = screen.getByRole('img').closest('main'); + expect(main).not.toHaveClass('landing-dark'); + expect(screen.getByRole('img').getAttribute('src')).toMatch(/landing-reference\.svg/); + expect(screen.getByRole('img').getAttribute('src')).not.toMatch(/landing-reference-dark\.svg/); + }); + + it('persists the preference under its own landing-scoped storage key', () => { + renderLanding(); + + fireEvent.click(screen.getByRole('radio', { name: 'Dark' })); + + expect(window.localStorage.getItem('partha-landing-theme')).toBe('dark'); + }); + + it('never applies any dark class to document.documentElement, in any state', () => { + renderLanding(); + + fireEvent.click(screen.getByRole('radio', { name: 'Dark' })); + expect(document.documentElement.classList.contains('dark')).toBe(false); + expect(document.documentElement.classList.contains('landing-dark')).toBe(false); + + fireEvent.click(screen.getByRole('radio', { name: 'Light' })); + expect(document.documentElement.classList.contains('dark')).toBe(false); + expect(document.documentElement.classList.contains('landing-dark')).toBe(false); + }); + + it('clears the pre-hydration boot marker on mount so it can never persist past first paint', () => { + document.documentElement.setAttribute('data-landing-theme-boot', 'dark'); + + renderLanding(); + + expect(document.documentElement.hasAttribute('data-landing-theme-boot')).toBe(false); + }); + + it('unmounting the landing page leaves no trace of the scoped class on the document', () => { + const { unmount } = renderLanding(); + fireEvent.click(screen.getByRole('radio', { name: 'Dark' })); + + unmount(); + + expect(document.documentElement.classList.contains('landing-dark')).toBe(false); + expect(document.documentElement.classList.contains('dark')).toBe(false); + expect(document.querySelector('.landing-dark')).toBeNull(); + }); +}); diff --git a/apps/frontend/src/app/pages/LandingPage.tsx b/apps/frontend/src/app/pages/LandingPage.tsx new file mode 100644 index 00000000..c01e9aaa --- /dev/null +++ b/apps/frontend/src/app/pages/LandingPage.tsx @@ -0,0 +1,196 @@ +import { useEffect, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { useAuthStore } from '@/app/store/useAuthStore'; +import { ThemeSwitcher } from '@/features/landing/components/ThemeSwitcher'; +import { useLandingTheme } from '@/features/landing/hooks/useLandingTheme'; +import landingReference from '@/assets/landing/landing-reference.svg'; +import landingReferenceDark from '@/assets/landing/landing-reference-dark.svg'; + +/** + * The marketing page is supplied as a complete, authored 1728-wide design + * canvas (light: 5608px tall, dark: 5643px tall -- independently exported, + * not the same asset recolored). Rendering the source canvas directly + * avoids scale, font, and layout drift from the signed-off composition. + * Transparent controls preserve the primary journeys without altering its + * artwork. + * + * The .landing-dark class is applied to this component's own root element + * only, never to (see useLandingTheme.ts and globals.css) -- it + * cannot leak into /login, /register, or any authenticated route, and + * cleans itself up automatically on unmount since it's plain conditional + * JSX, not imperative DOM mutation. + */ +export function LandingPage() { + const authenticated = useAuthStore((state) => state.status === 'authenticated'); + const [faqIndex, setFaqIndex] = useState(null); + const [footerNotice, setFooterNotice] = useState(null); + const workspaceHref = authenticated ? '/dashboard' : '/login'; + const theme = useLandingTheme(); + const dark = theme.resolved === 'dark'; + + useEffect(() => { + // Hand off from the pre-hydration boot flash-guard (index.html) to this + // component's own scoped class now that React has mounted. + document.documentElement.removeAttribute('data-landing-theme-boot'); + }, []); + + // Self-hosted is the only deployment model for the foreseeable future + // (#382): an unauthenticated visitor's "analyze a repository" intent goes + // straight to account creation on this instance, same as it would for + // anyone standing up their own copy of PARTHA. Registration itself still + // enforces the admin-managed email allowlist (#374/#375) or the + // development bypass (#384) exactly as before -- this only changes what + // the landing page's CTA points at, not what registering requires. + const analysisCta = (className: string) => + authenticated ? ( + Analyze a repository + ) : ( + Create an account + ); + + return ( +
+

Reveal the system behind the code.

+
+ PARTHA repository intelligence system overview, capabilities, frequently asked questions, and call to action. + + {/* right-[9.75%] top-[98.42%] is not a guess: it's the exact + position of the footer switcher pill in the raw Figma source + (rect x="1429.5" y="5519.5"/"y within the dark footer at 394.5", + width=130, on a 1728-wide canvas) converted to a percentage, so + it lands in the same spot -- below the footer divider, next to + the copyright line -- in both light and dark, matching the + reference exactly rather than an estimated position. */} + + + + +
+
+
+
+ + See how PARTHA works + {analysisCta('absolute left-[49.4%] top-[13.5%] z-20 h-[1.35%] w-[22.4%]')} + + {faqQuestions.map((question, index) => ( + +
+

{faqAnswers[faqIndex]}

+
+
+ )} + + {footerNotice && ( +
+ {footerNotice} + +
+ )} +
+
+ ); +} + +const faqQuestions = [ + 'Is PARTHA an AI product?', + 'How do you handle dynamic dispatch, reflection, and generated code?', + 'Which languages are supported?', + 'Where does PARTHA run?', + 'How does PARTHA integrate with our CI?', + 'What is the ri.v1 format?', +] as const; + +const faqAnswers = [ + 'PARTHA creates deterministic, evidence-backed repository models. AI assistance is optional and is limited to structural facts that have already been computed from a sealed snapshot.', + 'PARTHA makes supported evidence and limits visible. Findings that cannot be verified from the selected revision are not presented as facts.', + 'Language support is determined by the extractors available for the selected repository. The sealed snapshot records exactly what was assessed.', + 'PARTHA analyses a repository at a specific revision and retains a reproducible model for the workspace.', + 'Connect a repository, select a revision, then use the generated evidence and exports in the engineering workflow that suits your team.', + 'ri.v1 is PARTHA’s sealed repository-intelligence snapshot format. It records the exact revision, extracted facts, and available evidence.', +] as const; + +type FooterControl = { + label: string; + left: string; + top: string; + href?: string; + external?: boolean; + message?: string; +}; + +const footerControls: FooterControl[] = [ + { label: 'How it works', left: '39.1%', top: '93.65%', href: '#how-it-works' }, + { label: 'Capabilities', left: '39.1%', top: '94.45%', href: '#capabilities' }, + { label: 'FAQ', left: '39.1%', top: '95.25%', href: '#faq' }, + { label: 'Privacy', left: '39.1%', top: '96.05%', message: 'Privacy details will be published with the public release.' }, + { label: 'Docs', left: '55%', top: '93.65%', href: '/documentation' }, + { label: 'ri.v1 spec', left: '55%', top: '94.45%', message: 'The ri.v1 specification will be available shortly.' }, + { label: 'Language matrix', left: '55%', top: '95.25%', message: 'The language matrix will be available shortly.' }, + { label: 'Changelog', left: '55%', top: '96.05%', message: 'The changelog will be available shortly.' }, + { label: 'About', left: '70.6%', top: '93.65%', message: 'About PARTHA will be available shortly.' }, + { label: 'Security', left: '70.6%', top: '94.45%', message: 'Security details will be available shortly.' }, + { label: 'Contact', left: '70.6%', top: '95.25%', href: 'https://discord.gg/qvk9DcxDA', external: true }, + { label: 'Legal', left: '70.6%', top: '96.05%', message: 'Legal information will be available shortly.' }, + { label: 'LinkedIn', left: '86.3%', top: '93.65%', href: 'https://www.linkedin.com', external: true }, + { label: 'X', left: '86.3%', top: '94.45%', href: 'https://x.com', external: true }, + { label: 'GitHub', left: '86.3%', top: '95.25%', href: 'https://github.com', external: true }, +]; + +function FooterControl({ item, onUnavailable }: { item: FooterControl; onUnavailable: (message: string) => void }) { + const className = 'absolute z-20 h-[0.9%] w-[10.5%] border-0 bg-transparent p-0'; + const style = { left: item.left, top: item.top }; + if (item.message) { + return + + + + ); +} diff --git a/apps/frontend/src/app/pages/NotFoundPage.tsx b/apps/frontend/src/app/pages/NotFoundPage.tsx new file mode 100644 index 00000000..73203773 --- /dev/null +++ b/apps/frontend/src/app/pages/NotFoundPage.tsx @@ -0,0 +1,20 @@ +import { useNavigate } from 'react-router-dom'; +import { Compass } from 'lucide-react'; +import { EmptyState } from '@/shared/components/ui/EmptyState'; +import { PageHeader } from '@/shared/components/ui/PageHeader'; + +export function NotFoundPage() { + const navigate = useNavigate(); + + return ( +
+ + navigate('/dashboard') }} + /> +
+ ); +} diff --git a/apps/frontend/src/app/pages/OAuthCompletePage.test.tsx b/apps/frontend/src/app/pages/OAuthCompletePage.test.tsx new file mode 100644 index 00000000..dda97656 --- /dev/null +++ b/apps/frontend/src/app/pages/OAuthCompletePage.test.tsx @@ -0,0 +1,118 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { createMemoryRouter, RouterProvider } from 'react-router-dom'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { OAuthCompletePage } from './OAuthCompletePage'; +import { useAuthStore } from '@/app/store/useAuthStore'; +import { authService } from '@/shared/services/api'; + +vi.mock('@/shared/services/api', () => ({ + authService: { confirmOAuthLink: vi.fn() }, + getErrorMessage: vi.fn((error: unknown) => String(error)), + configureApiClient: vi.fn(), + requestSharedRefresh: vi.fn(), +})); + +function DashboardStub() { + return

Dashboard landed

; +} +function SettingsStub() { + return

Settings landed

; +} +function LoginStub() { + return

Login landed

; +} + +function renderAt(path: string) { + const router = createMemoryRouter( + [ + { path: '/oauth/complete', element: }, + { path: '/dashboard', element: }, + { path: '/settings', element: }, + { path: '/login', element: }, + ], + { initialEntries: [path] }, + ); + return render(); +} + +beforeEach(() => { + vi.mocked(authService.confirmOAuthLink).mockReset(); + useAuthStore.setState({ + status: 'unauthenticated', + accessToken: null, + user: null, + bootstrap: vi.fn().mockResolvedValue(undefined), + }); +}); + +describe('OAuthCompletePage', () => { + it('status=success bootstraps the session and lands on /dashboard', async () => { + const bootstrap = vi.fn().mockResolvedValue(undefined); + useAuthStore.setState({ bootstrap }); + + renderAt('/oauth/complete?status=success'); + + await screen.findByText('Dashboard landed'); + expect(bootstrap).toHaveBeenCalled(); + }); + + it('status=linked bootstraps the session and lands on Settings', async () => { + const bootstrap = vi.fn().mockResolvedValue(undefined); + useAuthStore.setState({ bootstrap }); + + renderAt('/oauth/complete?status=linked'); + + await screen.findByText('Settings landed'); + expect(bootstrap).toHaveBeenCalled(); + }); + + it('status=pending-link shows a password form and confirms the link on submit', async () => { + const setSession = vi.fn(); + useAuthStore.setState({ setSession }); + vi.mocked(authService.confirmOAuthLink).mockResolvedValue({ + accessToken: 'tok', + tokenType: 'bearer', + user: { id: 'u1', email: 'a@example.com', createdAt: new Date().toISOString() }, + }); + + renderAt('/oauth/complete?status=pending-link&pendingLinkId=p1&provider=google'); + + const passwordInput = await screen.findByLabelText('Password'); + fireEvent.change(passwordInput, { target: { value: 'correct-horse-battery-staple' } }); + fireEvent.click(screen.getByRole('button', { name: /Confirm and link account/ })); + + await waitFor(() => + expect(authService.confirmOAuthLink).toHaveBeenCalledWith({ + pendingLinkId: 'p1', + password: 'correct-horse-battery-staple', + }), + ); + await screen.findByText('Dashboard landed'); + expect(setSession).toHaveBeenCalled(); + }); + + it('status=pending-link shows an error and does not navigate on a wrong password', async () => { + vi.mocked(authService.confirmOAuthLink).mockRejectedValue(new Error('Invalid email or password.')); + + renderAt('/oauth/complete?status=pending-link&pendingLinkId=p1&provider=google'); + + const passwordInput = await screen.findByLabelText('Password'); + fireEvent.change(passwordInput, { target: { value: 'wrong-password' } }); + fireEvent.click(screen.getByRole('button', { name: /Confirm and link account/ })); + + expect(await screen.findByRole('alert')).toHaveTextContent('Invalid email or password.'); + expect(screen.queryByText('Dashboard landed')).not.toBeInTheDocument(); + }); + + it('status=error with a known reason shows the mapped, friendly message', async () => { + renderAt('/oauth/complete?status=error&reason=email_not_approved'); + + expect(await screen.findByText(/invite-only during the beta/)).toBeInTheDocument(); + }); + + it('status=error with an unrecognized or missing reason shows a generic message', async () => { + renderAt('/oauth/complete?status=error'); + + expect(await screen.findByText('Something went wrong completing sign-in. Please try again.')).toBeInTheDocument(); + }); +}); diff --git a/apps/frontend/src/app/pages/OAuthCompletePage.tsx b/apps/frontend/src/app/pages/OAuthCompletePage.tsx new file mode 100644 index 00000000..fb1fab14 --- /dev/null +++ b/apps/frontend/src/app/pages/OAuthCompletePage.tsx @@ -0,0 +1,154 @@ +import { useEffect, useState } from 'react'; +import { useNavigate, useSearchParams } from 'react-router-dom'; +import { Loader2 } from 'lucide-react'; +import { useAuthStore } from '@/app/store/useAuthStore'; +import { authService, getErrorMessage } from '@/shared/services/api'; +import { AuthShell } from '@/shared/components/layout/AuthShell'; + +const ERROR_MESSAGES: Record = { + access_denied: 'You cancelled the sign-in request.', + missing_code: 'The sign-in provider did not return the expected response. Please try again.', + exchange_failed: "We couldn't verify that sign-in with the provider. Please try again.", + provider_unavailable: 'This sign-in method is not available right now.', + account_unavailable: 'This account is not able to sign in right now.', + already_linked: 'That account is already linked to a different sign-in method.', + email_not_approved: + "This email hasn't been approved for access yet. PARTHA is invite-only during the beta — join the waitlist and we'll be in touch.", + email_already_registered: 'An account with this email already exists. Sign in with your password instead.', +}; + +function messageFor(reason: string | null): string { + if (reason && ERROR_MESSAGES[reason]) return ERROR_MESSAGES[reason]; + return 'Something went wrong completing sign-in. Please try again.'; +} + +/** Landing point for every /auth/oauth/{provider}/callback redirect (#288). + * + * The backend redirect never carries a body the frontend can read directly + * -- everything it needs is in this URL's query string, and for a real + * session it comes back as the httpOnly refresh cookie the callback just + * set. bootstrap() is exactly the primitive built for that: it already + * turns "a refresh cookie exists" into a live access token + user, which is + * the same thing a page reload after any other login does. */ +export function OAuthCompletePage() { + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + const setSession = useAuthStore((state) => state.setSession); + const bootstrap = useAuthStore((state) => state.bootstrap); + + const status = searchParams.get('status'); + const [pendingPassword, setPendingPassword] = useState(''); + const [pendingSubmitting, setPendingSubmitting] = useState(false); + const [pendingError, setPendingError] = useState(null); + const [linkedRedirecting, setLinkedRedirecting] = useState(false); + + useEffect(() => { + if (status === 'success') { + bootstrap().then(() => navigate('/dashboard', { replace: true })); + return; + } + if (status === 'linked') { + setLinkedRedirecting(true); + bootstrap().then(() => navigate('/settings?tab=General', { replace: true })); + } + }, [status, bootstrap, navigate]); + + if (status === 'success' || linkedRedirecting) { + return ; + } + + if (status === 'pending-link') { + const pendingLinkId = searchParams.get('pendingLinkId'); + const provider = searchParams.get('provider'); + + const confirm = async () => { + if (!pendingLinkId || pendingSubmitting) return; + setPendingSubmitting(true); + setPendingError(null); + try { + const auth = await authService.confirmOAuthLink({ pendingLinkId, password: pendingPassword }); + setSession(auth); + navigate('/dashboard', { replace: true }); + } catch (caught) { + setPendingError(getErrorMessage(caught)); + setPendingSubmitting(false); + } + }; + + return ( + navigate('/login')} className="font-medium text-primary underline underline-offset-2"> + Cancel and go to sign in + + } + > +
{ + event.preventDefault(); + confirm(); + }} + className="space-y-4" + > +
+ + setPendingPassword(event.target.value)} + className="partha-input w-full px-3 py-2.5 text-sm" + /> +
+ {pendingError && ( +

+ {pendingError} +

+ )} + +
+
+ ); + } + + // status === 'error' (or anything unrecognized -- same treatment). + const reason = searchParams.get('reason'); + return ( + navigate('/login')} className="font-medium text-primary underline underline-offset-2"> + Back to sign in + + } + > + <> + + ); +} + +function FullPageSpinner({ label }: { label: string }) { + return ( +
+ +

{label}

+
+ ); +} diff --git a/apps/frontend/src/app/pages/RegisterPage.test.tsx b/apps/frontend/src/app/pages/RegisterPage.test.tsx new file mode 100644 index 00000000..70855911 --- /dev/null +++ b/apps/frontend/src/app/pages/RegisterPage.test.tsx @@ -0,0 +1,61 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { createMemoryRouter, RouterProvider } from 'react-router-dom'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { RegisterPage } from './RegisterPage'; +import { authService } from '@/shared/services/api'; +import { ApiError } from '@/shared/services/api/errors'; + +// #374: registration no longer takes an invite code -- these lock in the +// field's removal and the allowlist-rejection error surfacing correctly. +describe('RegisterPage (#374 approved-email allowlist)', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + function renderPage() { + const router = createMemoryRouter([{ path: '/register', element: }], { + initialEntries: ['/register'], + }); + return render(); + } + + it('has no invite code field', () => { + renderPage(); + + expect(screen.queryByLabelText(/invite code/i)).not.toBeInTheDocument(); + expect(screen.getByLabelText('Email')).toBeInTheDocument(); + expect(screen.getByLabelText('Password')).toBeInTheDocument(); + }); + + it('still points an unapproved visitor at a way to get access', () => { + renderPage(); + + expect(screen.getByText(/not approved yet/i)).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Get in touch' })).toHaveAttribute( + 'href', + 'https://discord.gg/qvk9DcxDA', + ); + }); + + it('submits only email and password, and surfaces the allowlist rejection message', async () => { + vi.spyOn(authService, 'register').mockRejectedValue( + new ApiError( + 422, + 'Unprocessable Content', + { code: 'validation_error', message: "This email hasn't been approved for access yet. Join the waitlist and we'll be in touch." }, + '/auth/register', + null, + ), + ); + + renderPage(); + fireEvent.change(screen.getByLabelText('Email'), { target: { value: 'nobody@example.com' } }); + fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'longenoughpassword' } }); + fireEvent.click(screen.getByRole('button', { name: 'Create account' })); + + await waitFor(() => + expect(authService.register).toHaveBeenCalledWith({ email: 'nobody@example.com', password: 'longenoughpassword' }), + ); + expect(await screen.findByRole('alert')).toHaveTextContent("hasn't been approved"); + }); +}); diff --git a/apps/frontend/src/app/pages/RegisterPage.tsx b/apps/frontend/src/app/pages/RegisterPage.tsx new file mode 100644 index 00000000..5d0fc028 --- /dev/null +++ b/apps/frontend/src/app/pages/RegisterPage.tsx @@ -0,0 +1,82 @@ +import { Link } from 'react-router-dom'; +import { Loader2 } from 'lucide-react'; +import { PASSWORD_MIN_LENGTH, useRegisterForm } from '@/features/auth/hooks/useRegisterForm'; +import { AuthShell } from '@/shared/components/layout/AuthShell'; + +export function RegisterPage() { + const { email, setEmail, password, setPassword, submitting, error, submit, redirectState } = useRegisterForm(); + + return ( + Already have an account?{' '}Sign in} + > +
+ +
+ + setPassword(event.target.value)} + className="partha-input w-full px-3 py-2.5 text-sm" + /> +

At least {PASSWORD_MIN_LENGTH} characters.

+
+ + {error && ( +

+ {error} +

+ )} + + +
+ + + ); +} diff --git a/apps/frontend/src/app/pages/RepositoriesPage.test.tsx b/apps/frontend/src/app/pages/RepositoriesPage.test.tsx new file mode 100644 index 00000000..c7f60e84 --- /dev/null +++ b/apps/frontend/src/app/pages/RepositoriesPage.test.tsx @@ -0,0 +1,162 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Repository } from '@/shared/types'; +import { useRepository } from '@/features/repositories/hooks/useRepository'; +import { RepositoriesPage } from './RepositoriesPage'; + +const navigateMock = vi.fn(); +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { ...actual, useNavigate: () => navigateMock }; +}); + +vi.mock('@/features/repositories/hooks/useRepository', () => ({ + useRepository: vi.fn(), +})); + +const repository: Repository = { + id: 'repo-1', + name: 'kalyx', + source: 'github', + size: 10, + fileCount: 5, + status: 'completed', + analysisStage: 'completed', + analysisProgress: 100, + uploadedAt: '2026-08-02T08:00:00Z', + meta: { + language: 'TypeScript', + framework: 'React', + totalFiles: 5, + totalFolders: 2, + entryPoint: 'src/main.tsx', + configFiles: [], + packageManager: 'npm', + hasReadme: true, + hasLicense: true, + licenseName: 'MIT', + }, + fileTree: [], +}; + +function mockUseRepository(overrides: Partial>) { + vi.mocked(useRepository).mockReturnValue({ + repositories: [], + removeRepository: vi.fn(), + selectRepository: vi.fn(), + loading: false, + error: null, + retry: vi.fn(), + ...overrides, + } as ReturnType); +} + +function renderPage() { + return render( + + + , + ); +} + +describe('RepositoriesPage', () => { + beforeEach(() => { + navigateMock.mockClear(); + }); + + it('shows a loading state while repositories are being fetched', () => { + mockUseRepository({ loading: true }); + + renderPage(); + + expect(screen.getByText('Loading repositories...')).toBeInTheDocument(); + expect(screen.queryByRole('table')).not.toBeInTheDocument(); + }); + + it('shows an honest empty state for an authenticated user with no repositories, not a fabricated table', () => { + mockUseRepository({ repositories: [] }); + + renderPage(); + + expect(screen.getByText('No repositories yet')).toBeInTheDocument(); + expect(screen.queryByRole('table')).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Upload Repository' })); + expect(navigateMock).toHaveBeenCalledWith('/upload'); + }); + + it('shows a truthful, actionable error message with a working retry, not the table', async () => { + const retry = vi.fn(); + mockUseRepository({ error: 'The repository service is unavailable.', retry }); + + renderPage(); + + expect(screen.getByText('Unable to load repositories')).toBeInTheDocument(); + expect(screen.getByText('The repository service is unavailable.')).toBeInTheDocument(); + expect(screen.queryByRole('table')).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Try again' })); + await waitFor(() => expect(retry).toHaveBeenCalledTimes(1)); + }); + + it('renders a populated repository list with its real data, not placeholder values', () => { + mockUseRepository({ repositories: [repository] }); + + renderPage(); + + expect(screen.getByRole('table')).toBeInTheDocument(); + expect(screen.getByText('kalyx')).toBeInTheDocument(); + expect(screen.getByText('GitHub')).toBeInTheDocument(); + expect(screen.getByText('TypeScript')).toBeInTheDocument(); + expect(screen.getByText('5')).toBeInTheDocument(); + expect(screen.getByText('completed')).toBeInTheDocument(); + }); + + it('names row actions with repository context and hides their decorative icons', () => { + mockUseRepository({ repositories: [repository] }); + + renderPage(); + + const open = screen.getByRole('button', { name: 'Open kalyx' }); + const remove = screen.getByRole('button', { name: 'Delete kalyx' }); + + expect(open.querySelector('svg')).toHaveAttribute('aria-hidden', 'true'); + expect(remove.querySelector('svg')).toHaveAttribute('aria-hidden', 'true'); + }); + + it('opening a completed repository selects it and navigates to its detail page', () => { + const selectRepository = vi.fn(); + mockUseRepository({ repositories: [repository], selectRepository }); + + renderPage(); + + fireEvent.click(screen.getByRole('button', { name: 'Open kalyx' })); + + expect(selectRepository).toHaveBeenCalledWith(repository); + expect(navigateMock).toHaveBeenCalledWith('/repositories/repo-1'); + }); + + it('opening an in-progress repository navigates to its analysis progress page instead', () => { + const analysing: Repository = { ...repository, status: 'analysing' }; + mockUseRepository({ repositories: [analysing] }); + + renderPage(); + + fireEvent.click(screen.getByRole('button', { name: 'Open kalyx' })); + + expect(navigateMock).toHaveBeenCalledWith('/analysis/repo-1'); + }); + + it('deleting a repository calls removeRepository and surfaces a truthful error on failure', async () => { + const removeRepository = vi.fn().mockRejectedValue(new Error('Repository is still analysing.')); + mockUseRepository({ repositories: [repository], removeRepository }); + + renderPage(); + + fireEvent.click(screen.getByRole('button', { name: 'Delete kalyx' })); + + expect(removeRepository).toHaveBeenCalledWith('repo-1'); + expect(await screen.findByText('Repository is still analysing.')).toBeInTheDocument(); + }); +}); diff --git a/apps/frontend/src/app/pages/RepositoriesPage.tsx b/apps/frontend/src/app/pages/RepositoriesPage.tsx index b7e1e796..7c0f69e2 100644 --- a/apps/frontend/src/app/pages/RepositoriesPage.tsx +++ b/apps/frontend/src/app/pages/RepositoriesPage.tsx @@ -34,7 +34,7 @@ export function RepositoriesPage() {

{error}

@@ -62,7 +62,7 @@ export function RepositoriesPage() { @@ -74,16 +74,16 @@ export function RepositoriesPage() {
)} -
- +
+
- - - - - - - + + + + + + + @@ -93,13 +93,13 @@ export function RepositoriesPage() { initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ delay: index * 0.03 }} - className="hover:bg-accent/30 transition-colors" + className="hover:bg-accent transition-colors" > - - - - - - diff --git a/apps/frontend/src/app/pages/RepositoryDetailPage.test.tsx b/apps/frontend/src/app/pages/RepositoryDetailPage.test.tsx new file mode 100644 index 00000000..506ad9af --- /dev/null +++ b/apps/frontend/src/app/pages/RepositoryDetailPage.test.tsx @@ -0,0 +1,157 @@ +import { render, screen } from '@testing-library/react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { describe, expect, it, vi } from 'vitest'; +import type { Repository } from '@/shared/types'; +import { RepositoryDetailPage } from './RepositoryDetailPage'; + +const repositoryState = vi.hoisted(() => ({ repositories: [] as Repository[] })); +const explorerProps = vi.hoisted(() => ({ initialPath: null as string | null })); + +vi.mock('@/features/repositories/hooks/useRepository', () => ({ + useRepository: () => ({ repositories: repositoryState.repositories }), +})); + +vi.mock('@/features/repositories/components/RepositoryOutcomeSummary', () => ({ + RepositoryOutcomeSummary: ({ repository }: { repository: Repository }) => ( +
Outcome summary for {repository.name}
+ ), +})); + +vi.mock('@/features/explorer/components/RepositoryExplorer', () => ({ + RepositoryExplorer: ({ initialPath }: { initialPath?: string | null }) => { + explorerProps.initialPath = initialPath ?? null; + return
Explorer
; + }, +})); + +vi.mock('@/features/repositories/components/RepositoryLineageHistory', () => ({ + RepositoryLineageHistory: ({ repositoryId }: { repositoryId: string }) => ( +
History for {repositoryId}
+ ), +})); + +const completedRepository: Repository = { + id: 'repo-1', + name: 'sample', + source: 'upload', + size: 10, + fileCount: 5, + status: 'completed', + analysisStage: 'completed', + analysisProgress: 100, + uploadedAt: '2026-07-22T08:00:00Z', + meta: { + language: 'TypeScript', + framework: 'React', + totalFiles: 5, + totalFolders: 2, + entryPoint: 'src/main.tsx', + configFiles: [], + packageManager: 'npm', + hasReadme: true, + hasLicense: true, + licenseName: 'MIT', + }, + fileTree: [], +}; + +function renderPage(repositoryId = 'repo-1', search = '') { + return render( + + + } /> + + , + ); +} + +describe('RepositoryDetailPage', () => { + it('leads the completed repository landing view with the outcome summary, before the tabs (#176)', () => { + repositoryState.repositories = [completedRepository]; + + renderPage(); + + const summary = screen.getByTestId('outcome-summary-stub'); + expect(summary).toHaveTextContent('Outcome summary for sample'); + + const overviewTab = screen.getByRole('button', { name: 'Overview' }); + expect(summary.compareDocumentPosition(overviewTab) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + }); + + it('does not show the outcome summary for a repository that failed analysis', () => { + repositoryState.repositories = [ + { ...completedRepository, status: 'error', errorMessage: 'Extraction crashed.', meta: null }, + ]; + + renderPage(); + + expect(screen.queryByTestId('outcome-summary-stub')).not.toBeInTheDocument(); + expect(screen.getByText('Extraction crashed.')).toBeInTheDocument(); + }); + + it('opens the Explorer and forwards a global-search file path from the route', () => { + repositoryState.repositories = [completedRepository]; + + renderPage('repo-1', '?tab=Explorer&path=src%2Fmain.tsx'); + + expect(screen.getByTestId('repository-explorer-stub')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Explorer' })).toHaveClass('text-foreground'); + expect(explorerProps.initialPath).toBe('src/main.tsx'); + }); + + it('opens the History tab for this repository (#400)', () => { + repositoryState.repositories = [completedRepository]; + + renderPage('repo-1', '?tab=History'); + + expect(screen.getByRole('button', { name: 'History' })).toHaveClass('text-foreground'); + expect(screen.getByTestId('repository-lineage-history-stub')).toHaveTextContent('History for repo-1'); + }); +}); + +describe('RepositoryDetailPage revision identity', () => { + it('shows the full commit and its branch for a git import', () => { + repositoryState.repositories = [ + { + ...completedRepository, + source: 'github', + revision: { kind: 'git', ref: 'refs/heads/main', value: 'abcdef1234567890abcdef1234567890abcdef12' }, + }, + ]; + + renderPage(); + + // Shown in full rather than abbreviated: this is the page a user opens to + // answer "which code is this?", and a 7-character prefix is not an + // identity (RFC-0001 §3). + expect(screen.getByText('Commit')).toBeInTheDocument(); + expect(screen.getByText('abcdef1234567890abcdef1234567890abcdef12')).toBeInTheDocument(); + expect(screen.getByText('Branch')).toBeInTheDocument(); + expect(screen.getByText('main')).toBeInTheDocument(); + }); + + it('labels an upload as a content hash and shows no branch', () => { + repositoryState.repositories = [ + { + ...completedRepository, + revision: { kind: 'upload', ref: null, value: `sha256:${'a'.repeat(64)}` }, + }, + ]; + + renderPage(); + + expect(screen.getByText('Content hash')).toBeInTheDocument(); + expect(screen.getByText(`sha256:${'a'.repeat(64)}`)).toBeInTheDocument(); + expect(screen.queryByText('Branch')).not.toBeInTheDocument(); + }); + + it('omits both rows when the repository carries no revision', () => { + repositoryState.repositories = [{ ...completedRepository, revision: null }]; + + renderPage(); + + expect(screen.queryByText('Commit')).not.toBeInTheDocument(); + expect(screen.queryByText('Content hash')).not.toBeInTheDocument(); + expect(screen.queryByText('Branch')).not.toBeInTheDocument(); + }); +}); diff --git a/apps/frontend/src/app/pages/RepositoryDetailPage.tsx b/apps/frontend/src/app/pages/RepositoryDetailPage.tsx index 60aecb01..d386fead 100644 --- a/apps/frontend/src/app/pages/RepositoryDetailPage.tsx +++ b/apps/frontend/src/app/pages/RepositoryDetailPage.tsx @@ -1,5 +1,7 @@ -import { Navigate, useParams, useNavigate } from 'react-router-dom'; +import { useMemo } from 'react'; +import { Navigate, useParams, useNavigate, useSearchParams } from 'react-router-dom'; import { motion } from 'framer-motion'; +import { parseEvidenceCitation } from '@/features/explorer/fileUtils'; import { ArrowLeft, FolderGit2, @@ -13,6 +15,9 @@ import { Scale, Settings2, FolderTree, + GitBranch, + GitCommitHorizontal, + Fingerprint, Layers, } from 'lucide-react'; import { PageHeader } from '@/shared/components/ui/PageHeader'; @@ -20,6 +25,8 @@ import { EmptyState } from '@/shared/components/ui/EmptyState'; import { Badge } from '@/shared/components/ui/Badge'; import { DataSourceBadge } from '@/shared/components/ui/DataSourceBadge'; import { RepositoryExplorer } from '@/features/explorer/components/RepositoryExplorer'; +import { RepositoryLineageHistory } from '@/features/repositories/components/RepositoryLineageHistory'; +import { RepositoryOutcomeSummary } from '@/features/repositories/components/RepositoryOutcomeSummary'; import { useRepositoryDetail } from '@/features/repositories/hooks/useRepositoryDetail'; import { useRepositoryTree } from '@/features/repositories/hooks/useRepositoryTree'; import { repositoryStatusVariant } from '@/features/repositories/status'; @@ -29,9 +36,13 @@ import { cn } from '@/shared/utils/cn'; export function RepositoryDetailPage() { const { id } = useParams(); const navigate = useNavigate(); + const [searchParams] = useSearchParams(); const { repository: repo, tabs, activeTab, setActiveTab, redirectToAnalysis } = useRepositoryDetail(id); const repositoryTree = useRepositoryTree(repo); + const citation = useMemo(() => parseEvidenceCitation(searchParams), [searchParams]); + const initialFilePath = searchParams.get('path'); + if (!repo) { return (
@@ -80,13 +91,15 @@ export function RepositoryDetailPage() {
) : repo.status === 'completed' && repo.meta ? ( <> -
+ + +
{tabs.map((tab) => (
-
-

Repository Information

+
+

Repository

Repository Information

)} + {/* The exact source this repository was analysed at (#87). Shown in + full, not abbreviated: this is the page you open to answer + "which code is this?", and a shortened prefix is not an identity. */} + {repo.revision && ( + + )} + {repo.revision?.ref && ( + + )} {repo.size > 0 && ( )}
-
-

Detected Configuration

+
+

Evidence

Detected Configuration

{repo.meta.entryPoint && ( @@ -158,7 +190,7 @@ export function RepositoryDetailPage() { />
{repo.meta.configFiles.length > 0 && ( -
+

Configuration Files @@ -167,7 +199,7 @@ export function RepositoryDetailPage() { {repo.meta.configFiles.map((file) => ( {file} @@ -182,7 +214,18 @@ export function RepositoryDetailPage() { {activeTab === 'Explorer' && ( - + + + )} + + {activeTab === 'History' && ( + + )} @@ -193,10 +236,10 @@ export function RepositoryDetailPage() { function InfoCard({ icon: Icon, label, value }: { icon: typeof Code2; label: string; value: string }) { return ( -

+
- {label} + {label}

{value}

diff --git a/apps/frontend/src/app/pages/SettingsPage.oauth.test.tsx b/apps/frontend/src/app/pages/SettingsPage.oauth.test.tsx new file mode 100644 index 00000000..b607e119 --- /dev/null +++ b/apps/frontend/src/app/pages/SettingsPage.oauth.test.tsx @@ -0,0 +1,100 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { SettingsPage } from './SettingsPage'; +import { settingsTabs, useSettings } from '@/features/settings/hooks/useSettings'; +import { useOAuthAccounts } from '@/features/settings/hooks/useOAuthAccounts'; +import { useAuthStore } from '@/app/store/useAuthStore'; + +// #288: dedicated to the "Connected accounts" card, kept out of +// SettingsPage.test.tsx to avoid entangling with that file's AI-provider +// mocking setup -- useOAuthAccounts is mocked directly here instead of +// exercising the real API layer. +vi.mock('@/features/settings/hooks/useSettings', async () => { + const actual = await vi.importActual( + '@/features/settings/hooks/useSettings', + ); + return { ...actual, useSettings: vi.fn() }; +}); + +vi.mock('@/features/settings/hooks/useOAuthAccounts', () => ({ + useOAuthAccounts: vi.fn(), +})); + +// Only the fields the "General" tab actually reads -- cast rather than +// filling in every field of useSettings()'s much larger AI-provider-setup +// shape, which this file never touches. +function baseSettings(): ReturnType { + return { + tabs: settingsTabs, + activeTab: 'General', + setActiveTab: vi.fn(), + } as unknown as ReturnType; +} + +function baseOAuthAccounts() { + return { + identities: [] as { provider: string; email: string | null; createdAt: string }[], + loadError: null, + linkableProviders: [] as ('google' | 'github')[], + pendingAction: null, + actionError: null, + link: vi.fn(), + unlink: vi.fn(), + }; +} + +beforeEach(() => { + vi.mocked(useSettings).mockReturnValue(baseSettings()); + useAuthStore.setState({ + user: { id: 'u1', email: 'a@example.com', createdAt: new Date().toISOString() }, + }); +}); + +describe('SettingsPage connected accounts (#288)', () => { + it('renders nothing extra when no provider is configured and nothing is linked', () => { + vi.mocked(useOAuthAccounts).mockReturnValue(baseOAuthAccounts()); + + render(); + + expect(screen.queryByText('Connected accounts')).not.toBeInTheDocument(); + }); + + it('offers to link a configured, not-yet-linked provider', async () => { + const link = vi.fn(); + vi.mocked(useOAuthAccounts).mockReturnValue({ ...baseOAuthAccounts(), linkableProviders: ['google'], link }); + + render(); + + expect(screen.getByText('Connected accounts')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Link' })); + expect(link).toHaveBeenCalledWith('google'); + }); + + it('lists a linked identity with its email and an Unlink action', async () => { + const unlink = vi.fn(); + vi.mocked(useOAuthAccounts).mockReturnValue({ + ...baseOAuthAccounts(), + identities: [{ provider: 'github', email: 'dev@example.com', createdAt: new Date().toISOString() }], + unlink, + }); + + render(); + + expect(screen.getByText('GitHub')).toBeInTheDocument(); + expect(screen.getByText('dev@example.com')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Unlink' })); + await waitFor(() => expect(unlink).toHaveBeenCalledWith('github')); + }); + + it('shows an action error (e.g. refusing to remove the only sign-in method)', () => { + vi.mocked(useOAuthAccounts).mockReturnValue({ + ...baseOAuthAccounts(), + identities: [{ provider: 'google', email: 'a@example.com', createdAt: new Date().toISOString() }], + actionError: 'This is your only way to sign in to this account. Link another provider before removing it.', + }); + + render(); + + expect(screen.getByRole('alert')).toHaveTextContent('This is your only way to sign in'); + }); +}); diff --git a/apps/frontend/src/app/pages/SettingsPage.test.tsx b/apps/frontend/src/app/pages/SettingsPage.test.tsx new file mode 100644 index 00000000..e1ed4aab --- /dev/null +++ b/apps/frontend/src/app/pages/SettingsPage.test.tsx @@ -0,0 +1,381 @@ +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { SettingsPage } from './SettingsPage'; +import { useSettings, settingsTabs } from '@/features/settings/hooks/useSettings'; +import { useAuthStore } from '@/app/store/useAuthStore'; +import { authService } from '@/shared/services/api'; +import { ApiError } from '@/shared/services/api/errors'; +import type { AiProvider, AiProviderCapability } from '@/shared/services/api/types'; + +vi.mock('@/features/settings/hooks/useSettings', async () => { + const actual = await vi.importActual( + '@/features/settings/hooks/useSettings', + ); + return { ...actual, useSettings: vi.fn() }; +}); + +const CAPABILITIES: AiProviderCapability[] = [ + { + provider: 'openai', + displayName: 'OpenAI', + requiresApiKey: true, + requiresBaseUrl: false, + defaultModel: 'gpt-4o-mini', + setupUrl: 'https://platform.openai.com/api-keys', + setupSteps: [ + 'Create an OpenAI account and generate an API key.', + 'Paste in the API key.', + 'Confirm the model ID (default: gpt-4o-mini).', + 'Test the connection, then save.', + ], + supportState: 'supported', + }, + { + provider: 'anthropic', + displayName: 'Anthropic', + requiresApiKey: true, + requiresBaseUrl: false, + defaultModel: 'claude-3-5-haiku-latest', + setupUrl: 'https://console.anthropic.com/settings/keys', + setupSteps: [ + 'Create an Anthropic account and generate an API key.', + 'Paste in the API key.', + 'Confirm the model ID (default: claude-3-5-haiku-latest).', + 'Test the connection, then save.', + ], + supportState: 'supported', + }, + { + provider: 'gemini', + displayName: 'Google Gemini', + requiresApiKey: true, + requiresBaseUrl: false, + defaultModel: 'gemini-1.5-flash', + setupUrl: 'https://aistudio.google.com/apikey', + setupSteps: [ + 'Create a Google AI Studio API key.', + 'Paste in the API key.', + 'Confirm the model ID (default: gemini-1.5-flash).', + 'Test the connection, then save.', + ], + supportState: 'supported', + }, + { + provider: 'openrouter', + displayName: 'OpenRouter', + requiresApiKey: true, + requiresBaseUrl: false, + defaultModel: 'openai/gpt-4o-mini', + setupUrl: 'https://openrouter.ai/keys', + setupSteps: [ + 'Create an OpenRouter account and generate an API key.', + 'Paste in the API key.', + 'Confirm the model ID (default: openai/gpt-4o-mini).', + 'Test the connection, then save.', + ], + supportState: 'supported', + }, + { + provider: 'ollama', + displayName: 'Ollama', + requiresApiKey: false, + requiresBaseUrl: true, + defaultModel: 'llama3.2', + setupUrl: 'https://ollama.com/download', + setupSteps: [ + 'Install and start Ollama, either locally or on a server you control.', + "Enter the base URL where it's running.", + 'Confirm the model ID (default: llama3.2).', + 'Test the connection, then save.', + ], + supportState: 'supported', + }, +]; + +function capabilityFor(provider: AiProvider): AiProviderCapability { + const capability = CAPABILITIES.find((entry) => entry.provider === provider); + if (!capability) throw new Error(`no test fixture capability for provider ${provider}`); + return capability; +} + +function baseSettings(overrides: Partial> = {}): ReturnType { + const provider = overrides.provider ?? 'openai'; + return { + tabs: settingsTabs, + activeTab: 'General', + setActiveTab: vi.fn(), + capabilities: CAPABILITIES, + capabilitiesError: null, + activeCapability: capabilityFor(provider), + aiConfig: null, + provider, + setProvider: vi.fn(), + apiKey: '', + setApiKey: vi.fn(), + model: 'gpt-4o-mini', + setModel: vi.fn(), + baseUrl: '', + setBaseUrl: vi.fn(), + saveAiConfig: vi.fn(), + testAiConfig: vi.fn(), + testing: false, + statusMessage: null, + loading: false, + error: null, + empty: false, + success: true, + retry: vi.fn(), + refresh: vi.fn(), + ...overrides, + }; +} + +describe('SettingsPage renders every section without crashing', () => { + it.each(settingsTabs)('renders the %s tab', (tab) => { + vi.mocked(useSettings).mockReturnValue(baseSettings({ activeTab: tab })); + + render(); + + expect(screen.getByRole('tab', { name: tab, selected: true })).toBeInTheDocument(); + }); +}); + +describe('SettingsPage "in development" sections are honest and non-interactive', () => { + it('API Keys: states there are none configured and disables the only action', () => { + vi.mocked(useSettings).mockReturnValue(baseSettings({ activeTab: 'API Keys' })); + + render(); + + expect(screen.getByText('No API keys configured.')).toBeVisible(); + expect(screen.getByRole('button', { name: 'Coming Soon' })).toBeDisabled(); + }); + + it('General: profile fields are read-only and editing is disabled, not silently ignored', () => { + vi.mocked(useSettings).mockReturnValue(baseSettings({ activeTab: 'General' })); + useAuthStore.setState({ + status: 'authenticated', + accessToken: 'a-token', + user: { id: 'user-1', email: 'alice@example.com', createdAt: '2026-01-01T00:00:00Z' }, + }); + + render(); + + expect(screen.getByLabelText('Email')).toBeDisabled(); + expect(screen.getByLabelText('Member Since')).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Editing Coming Soon' })).toBeDisabled(); + + useAuthStore.setState({ status: 'initialising', accessToken: null, user: null }); + }); + + it('Notifications: discloses the upcoming state and gives each disabled switch a unique accessible name', () => { + vi.mocked(useSettings).mockReturnValue(baseSettings({ activeTab: 'Notifications' })); + + render(); + + expect(screen.getByText('Coming Soon')).toBeVisible(); + expect( + screen.getByText('Notification preferences are in development and cannot be configured yet.'), + ).toBeVisible(); + + for (const preference of ['Analysis complete', 'Error alerts', 'New insights available']) { + const control = screen.getByRole('switch', { + name: `${preference} notifications (coming soon)`, + }); + + expect(control).toBeDisabled(); + expect(control).toHaveAttribute('aria-checked', 'false'); + const thumb = within(control).getByRole('generic', { hidden: true }); + expect(thumb).toHaveAttribute('aria-hidden', 'true'); + expect(thumb).toHaveClass('left-0.5'); + expect(thumb).not.toHaveClass('right-0.5'); + } + }); +}); + +describe('SettingsPage AI provider configuration', () => { + it('shows "Not configured" and an honest empty-key placeholder when no provider is saved', () => { + vi.mocked(useSettings).mockReturnValue(baseSettings({ activeTab: 'AI Providers', aiConfig: null })); + + render(); + + expect(screen.getByText('Not configured')).toBeVisible(); + expect(screen.getByLabelText('API Key')).toHaveAttribute('placeholder', 'Enter provider API key'); + expect(document.body.textContent).not.toMatch(/sk-[A-Za-z0-9]/); + }); + + it('shows the saved provider and only the masked last 4 characters of its key, never full key material', () => { + vi.mocked(useSettings).mockReturnValue( + baseSettings({ + activeTab: 'AI Providers', + provider: 'openai', + aiConfig: { provider: 'openai', model: 'gpt-4o-mini', hasApiKey: true, apiKeyLast4: 'wxyz', baseUrl: null }, + }), + ); + + render(); + + expect(screen.getByText('Saved: openai')).toBeVisible(); + expect(screen.getByLabelText('API Key')).toHaveAttribute('placeholder', 'Saved key •••• wxyz'); + // The full key is never part of this hook's state (AiProviderPublicConfig + // only ever carries a masked last-4), so there is nothing beyond the + // masked placeholder that could leak -- confirm no bare "sk-..." pattern + // renders anywhere in the page. + expect(document.body.textContent).not.toMatch(/sk-[A-Za-z0-9]{10,}/); + }); + + it('switches provider and its model default, and reveals the Ollama base-URL field only for Ollama', () => { + const setProvider = vi.fn(); + vi.mocked(useSettings).mockReturnValue(baseSettings({ activeTab: 'AI Providers', setProvider })); + + render(); + + expect(screen.queryByLabelText('Ollama Base URL')).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Ollama' })); + expect(setProvider).toHaveBeenCalledWith('ollama'); + }); + + it('shows the Ollama base-URL field, not the API key field, once Ollama is selected', () => { + vi.mocked(useSettings).mockReturnValue(baseSettings({ activeTab: 'AI Providers', provider: 'ollama' })); + + render(); + + expect(screen.getByLabelText('Ollama Base URL')).toBeInTheDocument(); + expect(screen.queryByLabelText('API Key')).not.toBeInTheDocument(); + }); + + it('surfaces a save/test error honestly', () => { + vi.mocked(useSettings).mockReturnValue( + baseSettings({ activeTab: 'AI Providers', error: 'The provided API key was rejected.' }), + ); + + render(); + + expect(screen.getByText('The provided API key was rejected.')).toBeVisible(); + }); + + it('disables Test Connection and Save while a request is in flight', () => { + vi.mocked(useSettings).mockReturnValue(baseSettings({ activeTab: 'AI Providers', loading: true })); + + render(); + + expect(screen.getByRole('button', { name: 'Saving...' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Test Connection' })).toBeDisabled(); + }); + + it('shows at most 4 setup steps and an official setup link for the selected provider', () => { + vi.mocked(useSettings).mockReturnValue(baseSettings({ activeTab: 'AI Providers', provider: 'anthropic' })); + + render(); + + expect(screen.getByText('Getting started with Anthropic')).toBeVisible(); + const capability = capabilityFor('anthropic'); + for (const step of capability.setupSteps) { + expect(screen.getByText(step)).toBeVisible(); + } + expect(capability.setupSteps.length).toBeLessThanOrEqual(4); + + const link = screen.getByRole('link', { name: 'Open Anthropic setup page' }); + expect(link).toHaveAttribute('href', capability.setupUrl); + expect(link).toHaveAttribute('target', '_blank'); + expect(link).toHaveAttribute('rel', expect.stringContaining('noreferrer')); + }); + + it('surfaces a capability-fetch failure honestly, without hiding the rest of the tab', () => { + vi.mocked(useSettings).mockReturnValue( + baseSettings({ + activeTab: 'AI Providers', + capabilities: [], + activeCapability: null, + capabilitiesError: 'Could not load AI provider setup information.', + }), + ); + + render(); + + expect(screen.getByText('Could not load AI provider setup information.')).toBeVisible(); + expect(screen.queryByText(/Getting started with/)).not.toBeInTheDocument(); + }); +}); + +describe('SettingsPage account deletion', () => { + beforeEach(() => { + vi.mocked(useSettings).mockReturnValue(baseSettings({ activeTab: 'General' })); + useAuthStore.setState({ + status: 'authenticated', + accessToken: 'a-token', + user: { id: 'user-1', email: 'alice@example.com', createdAt: '2026-01-01T00:00:00Z' }, + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + useAuthStore.setState({ status: 'initialising', accessToken: null, user: null }); + }); + + it('keeps the confirm button disabled until the account email is typed back exactly', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Delete Account' })); + + const confirmButton = screen.getByRole('button', { name: 'Permanently Delete Account' }); + expect(confirmButton).toBeDisabled(); + + fireEvent.change(screen.getByLabelText(/Type alice@example.com to confirm/i), { + target: { value: 'wrong@example.com' }, + }); + fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'correct-horse-battery' } }); + expect(confirmButton).toBeDisabled(); + }); + + it('deletes the account and clears local session state on success', async () => { + const deleteAccount = vi.spyOn(authService, 'deleteAccount').mockResolvedValue(undefined); + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Delete Account' })); + fireEvent.change(screen.getByLabelText(/Type alice@example.com to confirm/i), { + target: { value: 'alice@example.com' }, + }); + fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'correct-horse-battery' } }); + + const confirmButton = screen.getByRole('button', { name: 'Permanently Delete Account' }); + expect(confirmButton).toBeEnabled(); + fireEvent.click(confirmButton); + + await waitFor(() => { + expect(deleteAccount).toHaveBeenCalledWith({ + password: 'correct-horse-battery', + confirmEmail: 'alice@example.com', + }); + }); + await waitFor(() => expect(useAuthStore.getState().status).toBe('unauthenticated')); + expect(useAuthStore.getState().user).toBeNull(); + }); + + it('surfaces a wrong-password rejection without clearing the session', async () => { + vi.spyOn(authService, 'deleteAccount').mockRejectedValue( + new ApiError(401, 'Unauthorized', { code: 'unauthorized', message: 'Invalid password.' }, '/auth/me'), + ); + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Delete Account' })); + fireEvent.change(screen.getByLabelText(/Type alice@example.com to confirm/i), { + target: { value: 'alice@example.com' }, + }); + fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'wrong-password' } }); + fireEvent.click(screen.getByRole('button', { name: 'Permanently Delete Account' })); + + expect(await screen.findByRole('alert')).toHaveTextContent('Invalid password.'); + expect(useAuthStore.getState().status).toBe('authenticated'); + }); + + it('cancel collapses the panel and clears the entered fields', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Delete Account' })); + fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'some-password' } }); + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + + expect(screen.getByRole('button', { name: 'Delete Account' })).toBeVisible(); + expect(screen.queryByLabelText('Password')).not.toBeInTheDocument(); + }); +}); diff --git a/apps/frontend/src/app/pages/SettingsPage.tsx b/apps/frontend/src/app/pages/SettingsPage.tsx index 9a108018..350b0be8 100644 --- a/apps/frontend/src/app/pages/SettingsPage.tsx +++ b/apps/frontend/src/app/pages/SettingsPage.tsx @@ -1,29 +1,34 @@ +import { Loader2 } from 'lucide-react'; import { PageHeader } from '@/shared/components/ui/PageHeader'; import { useSettings } from '@/features/settings/hooks/useSettings'; +import { useAccountDeletion } from '@/features/settings/hooks/useAccountDeletion'; +import { useOAuthAccounts } from '@/features/settings/hooks/useOAuthAccounts'; +import { useAuthStore } from '@/app/store/useAuthStore'; import { cn } from '@/shared/utils/cn'; +const OAUTH_PROVIDER_LABEL: Record = { google: 'Google', github: 'GitHub' }; + export function SettingsPage() { const settings = useSettings(); const { tabs, activeTab, setActiveTab } = settings; - const providers = [ - ['openai', 'OpenAI'], - ['anthropic', 'Anthropic'], - ['gemini', 'Google Gemini'], - ['openrouter', 'OpenRouter'], - ['ollama', 'Ollama'], - ] as const; + const user = useAuthStore((state) => state.user); + const deletion = useAccountDeletion(); + const oauthAccounts = useOAuthAccounts(); return ( -
+
-
+
{tabs.map((tab) => (
-
-

Danger Zone

-

Permanently delete your account and all data.

- + {(oauthAccounts.identities === null ? [] : oauthAccounts.identities).length > 0 || + oauthAccounts.linkableProviders.length > 0 ? ( +
+

Sign-in

+

Connected accounts

+
+ {(oauthAccounts.identities ?? []).map((identity) => ( +
+
+

+ {OAUTH_PROVIDER_LABEL[identity.provider] ?? identity.provider} +

+ {identity.email &&

{identity.email}

} +
+ +
+ ))} + {oauthAccounts.linkableProviders.map((provider) => ( +
+

+ {OAUTH_PROVIDER_LABEL[provider] ?? provider} +

+ +
+ ))} +
+ {oauthAccounts.actionError && ( +

+ {oauthAccounts.actionError} +

+ )} +
+ ) : null} +
+

Danger Zone

+

+ Permanently delete your account, repositories, AI provider configuration, and conversation history. + This cannot be undone. +

+ {!deletion.expanded && ( + + )} + {deletion.expanded && ( +
+
+ + deletion.setConfirmEmail(event.target.value)} + autoComplete="off" + className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-destructive" + /> +
+
+ + deletion.setPassword(event.target.value)} + autoComplete="current-password" + className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-destructive" + /> +
+ {deletion.error &&

{deletion.error}

} +
+ + +
+
+ )}
)} {activeTab === 'AI Providers' && ( -
-

AI Provider

+
+
+

AI Provider

+ + {settings.aiConfig?.provider ? `Saved: ${settings.aiConfig.provider}` : 'Not configured'} + +

- Keys are stored by the local backend and are never shown again after saving. + Keys are stored by the local backend, encrypted at rest, and are never shown again after saving.

-
- {providers.map(([id, label]) => ( + {settings.capabilitiesError && ( +

{settings.capabilitiesError}

+ )} +
+ {settings.capabilities.map((capability) => ( ))}
+ {settings.activeCapability && ( +
+

+ Getting started with {settings.activeCapability.displayName} +

+
    + {settings.activeCapability.setupSteps.map((step) => ( +
  1. {step}
  2. + ))} +
+ + Open {settings.activeCapability.displayName} setup page + +
+ )}
- + settings.setModel(event.target.value)} className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-ring" />
- {settings.provider === 'ollama' && ( + {settings.activeCapability?.requiresBaseUrl && (
- + settings.setBaseUrl(event.target.value)} placeholder="http://localhost:11434" @@ -115,14 +282,21 @@ export function SettingsPage() { />
)} - {settings.provider !== 'ollama' && ( + {settings.activeCapability?.requiresApiKey && (
- + settings.setApiKey(event.target.value)} - placeholder={settings.aiConfig?.provider === settings.provider && settings.aiConfig.hasApiKey ? 'Saved key configured' : 'Enter provider API key'} + placeholder={ + settings.aiConfig?.provider === settings.provider && settings.aiConfig.hasApiKey + ? settings.aiConfig.apiKeyLast4 + ? `Saved key •••• ${settings.aiConfig.apiKeyLast4}` + : 'Saved key configured' + : 'Enter provider API key' + } className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-ring" />
@@ -130,40 +304,45 @@ export function SettingsPage() {
{settings.error &&

{settings.error}

} {settings.statusMessage &&

{settings.statusMessage}

} -
- -
)} - {activeTab === 'Appearance' && ( -
-

Theme

-
- - -
-
- )} {activeTab === 'Notifications' && ( -
-

Notification Preferences

+
+
+
+

Notification Preferences

+ + Coming Soon + +
+

+ Notification preferences are in development and cannot be configured yet. +

+
{['Analysis complete', 'Error alerts', 'New insights available'].map((item) => (
{item} -
))} @@ -171,8 +350,8 @@ export function SettingsPage() {
)} {activeTab === 'API Keys' && ( -
-

API Keys

+
+

API Keys

Manage API keys for programmatic access.

No API keys configured.

diff --git a/apps/frontend/src/app/pages/UploadPage.test.tsx b/apps/frontend/src/app/pages/UploadPage.test.tsx new file mode 100644 index 00000000..f3eed2eb --- /dev/null +++ b/apps/frontend/src/app/pages/UploadPage.test.tsx @@ -0,0 +1,296 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Repository } from '@/shared/types'; +import { useUpload } from '@/features/upload/hooks/useUpload'; +import { useGitHubImport } from '@/features/upload/hooks/useGitHubImport'; +import { UploadPage } from './UploadPage'; + +const navigateMock = vi.fn(); +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { ...actual, useNavigate: () => navigateMock }; +}); + +vi.mock('@/features/upload/hooks/useUpload', async () => { + const actual = await vi.importActual( + '@/features/upload/hooks/useUpload', + ); + return { ...actual, useUpload: vi.fn() }; +}); + +vi.mock('@/features/upload/hooks/useGitHubImport', () => ({ + useGitHubImport: vi.fn(), +})); + +// react-dropzone reads File.prototype internals jsdom doesn't fully implement; +// the file-selection/drag-drop path itself is already covered by useUpload's +// own hook tests (selectFile/rejectFile), so UploadPage's tests exercise the +// page's own wiring (button gating, error/preview rendering, post-analysis +// navigation) against whatever state the hook reports, not react-dropzone +// internals. + +function baseUpload(overrides: Partial> = {}): ReturnType { + return { + uploadFile: null, + loading: false, + error: null, + empty: true, + success: false, + source: 'upload', + selectFile: vi.fn(), + rejectFile: vi.fn(), + removeFile: vi.fn(), + analyseFile: vi.fn(), + retry: vi.fn(), + refresh: vi.fn(), + ...overrides, + }; +} + +function baseGithubImport( + overrides: Partial> = {}, +): ReturnType { + return { + githubUrl: '', + setGithubUrl: vi.fn(), + loading: false, + error: null, + empty: true, + success: false, + source: 'github', + previewName: null, + analyseGithub: vi.fn(), + retry: vi.fn(), + refresh: vi.fn(), + ...overrides, + }; +} + +function renderPage() { + return render( + + + , + ); +} + +const repository: Repository = { + id: 'repo-1', + name: 'sample', + source: 'upload', + size: 10, + fileCount: 5, + status: 'completed', + analysisStage: 'completed', + analysisProgress: 100, + uploadedAt: '2026-08-02T08:00:00Z', + meta: null, + fileTree: [], +}; + +describe('UploadPage', () => { + beforeEach(() => { + navigateMock.mockClear(); + vi.mocked(useUpload).mockReturnValue(baseUpload()); + vi.mocked(useGitHubImport).mockReturnValue(baseGithubImport()); + }); + + describe('source tabs', () => { + it('keeps the selected tab and visible panel in sync', async () => { + renderPage(); + + const fileTab = screen.getByRole('tab', { name: 'Upload File' }); + const githubTab = screen.getByRole('tab', { name: 'GitHub URL' }); + expect(fileTab).toHaveAttribute('aria-selected', 'true'); + expect(screen.getByRole('tabpanel')).toHaveAttribute('id', 'upload-file-panel'); + + fireEvent.click(githubTab); + + expect(githubTab).toHaveAttribute('aria-selected', 'true'); + expect(fileTab).toHaveAttribute('aria-selected', 'false'); + await waitFor(() => { + expect(screen.getByRole('tabpanel')).toHaveAttribute('id', 'upload-github-panel'); + }); + expect(screen.getByTestId('github-import-title')).toHaveTextContent('Import from GitHub'); + }); + }); + + describe('archive upload', () => { + it('has no submit button until a file is selected', () => { + renderPage(); + + expect(screen.queryByRole('button', { name: /Analyse Repository/i })).not.toBeInTheDocument(); + }); + + it('shows the selected file with its real name and size, and a working remove control', () => { + const removeFile = vi.fn(); + vi.mocked(useUpload).mockReturnValue( + baseUpload({ + uploadFile: { file: new File(['x'], 'my-repo.zip'), name: 'my-repo.zip', size: 2048, type: 'application/zip', lastModified: 0 }, + empty: false, + success: true, + removeFile, + }), + ); + + renderPage(); + + expect(screen.getByText('my-repo.zip')).toBeInTheDocument(); + expect(screen.getByText('2 KB')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Analyse Repository/i })).toBeInTheDocument(); + + const removeButton = screen.getByText('my-repo.zip').closest('div.rounded-2xl')?.querySelector('button'); + expect(removeButton).not.toBeNull(); + fireEvent.click(removeButton!); + expect(removeFile).toHaveBeenCalled(); + }); + + it('surfaces a rejected-file error honestly, not a fabricated success state', () => { + vi.mocked(useUpload).mockReturnValue( + baseUpload({ error: 'Invalid file type. Please upload a ZIP, TAR, TAR.GZ, or TGZ file.' }), + ); + + renderPage(); + + expect( + screen.getByText('Invalid file type. Please upload a ZIP, TAR, TAR.GZ, or TGZ file.'), + ).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /Analyse Repository/i })).not.toBeInTheDocument(); + }); + + it('navigates to the repository detail page once analysis completes synchronously', async () => { + const analyseFile = vi.fn().mockResolvedValue(repository); + vi.mocked(useUpload).mockReturnValue( + baseUpload({ + uploadFile: { file: new File(['x'], 'a.zip'), name: 'a.zip', size: 10, type: 'application/zip', lastModified: 0 }, + empty: false, + success: true, + analyseFile, + }), + ); + + renderPage(); + fireEvent.click(screen.getByRole('button', { name: /Analyse Repository/i })); + + await waitFor(() => expect(navigateMock).toHaveBeenCalledWith('/repositories/repo-1')); + }); + + it('navigates to the analysis progress page when the repository is still analysing', async () => { + const analysing: Repository = { ...repository, status: 'analysing' }; + const analyseFile = vi.fn().mockResolvedValue(analysing); + vi.mocked(useUpload).mockReturnValue( + baseUpload({ + uploadFile: { file: new File(['x'], 'a.zip'), name: 'a.zip', size: 10, type: 'application/zip', lastModified: 0 }, + empty: false, + success: true, + analyseFile, + }), + ); + + renderPage(); + fireEvent.click(screen.getByRole('button', { name: /Analyse Repository/i })); + + await waitFor(() => expect(navigateMock).toHaveBeenCalledWith('/analysis/repo-1')); + }); + + it('does not navigate when analysis fails to return a repository', async () => { + const analyseFile = vi.fn().mockResolvedValue(null); + vi.mocked(useUpload).mockReturnValue( + baseUpload({ + uploadFile: { file: new File(['x'], 'a.zip'), name: 'a.zip', size: 10, type: 'application/zip', lastModified: 0 }, + empty: false, + success: true, + analyseFile, + }), + ); + + renderPage(); + fireEvent.click(screen.getByRole('button', { name: /Analyse Repository/i })); + + await waitFor(() => expect(analyseFile).toHaveBeenCalled()); + expect(navigateMock).not.toHaveBeenCalled(); + }); + }); + + describe('GitHub import', () => { + async function githubPanel() { + fireEvent.click(screen.getByRole('tab', { name: 'GitHub URL' })); + // AnimatePresence swaps the panel asynchronously (exit animation before + // the new panel mounts); the tab's aria-selected flips immediately, but + // the panel content does not. + await waitFor(() => expect(screen.getByRole('tabpanel')).toHaveAttribute('id', 'upload-github-panel')); + } + + it('disables submit until a URL is entered', async () => { + renderPage(); + await githubPanel(); + + expect(screen.getByRole('button', { name: /Analyse Repository/i })).toBeDisabled(); + }); + + it('enables submit once a URL is entered', async () => { + vi.mocked(useGitHubImport).mockReturnValue( + baseGithubImport({ githubUrl: 'https://github.com/example/repo', empty: false, success: true }), + ); + + renderPage(); + await githubPanel(); + + expect(screen.getByRole('button', { name: /Analyse Repository/i })).toBeEnabled(); + }); + + it('renders a live preview of the resolved repository name for a valid URL', async () => { + vi.mocked(useGitHubImport).mockReturnValue( + baseGithubImport({ githubUrl: 'https://github.com/example/repo', success: true, previewName: 'repo' }), + ); + + renderPage(); + await githubPanel(); + + expect(screen.getByText('repo', { selector: 'span' })).toBeInTheDocument(); + }); + + it('surfaces an invalid-URL error honestly rather than a fabricated preview', async () => { + vi.mocked(useGitHubImport).mockReturnValue( + baseGithubImport({ + githubUrl: 'not-a-url', + error: 'Invalid GitHub URL. Format: https://github.com/owner/repository', + }), + ); + + renderPage(); + await githubPanel(); + + expect( + screen.getByText('Invalid GitHub URL. Format: https://github.com/owner/repository'), + ).toBeInTheDocument(); + }); + + it('typing into the URL field calls setGithubUrl with the raw input', async () => { + const setGithubUrl = vi.fn(); + vi.mocked(useGitHubImport).mockReturnValue(baseGithubImport({ setGithubUrl })); + + renderPage(); + await githubPanel(); + fireEvent.change(screen.getByTestId('github-import-url'), { + target: { value: 'https://github.com/example/repo' }, + }); + + expect(setGithubUrl).toHaveBeenCalledWith('https://github.com/example/repo'); + }); + + it('navigates to the repository detail page once a completed import resolves', async () => { + const analyseGithub = vi.fn().mockResolvedValue(repository); + vi.mocked(useGitHubImport).mockReturnValue( + baseGithubImport({ githubUrl: 'https://github.com/example/repo', success: true, analyseGithub }), + ); + + renderPage(); + await githubPanel(); + fireEvent.click(screen.getByRole('button', { name: /Analyse Repository/i })); + + await waitFor(() => expect(navigateMock).toHaveBeenCalledWith('/repositories/repo-1')); + }); + }); +}); diff --git a/apps/frontend/src/app/pages/UploadPage.tsx b/apps/frontend/src/app/pages/UploadPage.tsx index e16a8299..f4935f31 100644 --- a/apps/frontend/src/app/pages/UploadPage.tsx +++ b/apps/frontend/src/app/pages/UploadPage.tsx @@ -49,7 +49,7 @@ export function UploadPage() { }; return ( -
+
-
+
@@ -156,11 +169,11 @@ export function UploadPage() { initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -8 }} - className="mt-4 rounded-xl border border-border bg-card p-4" + className="mt-4 rounded-2xl border border-primary/20 bg-card p-5 shadow-[0_12px_26px_hsl(var(--foreground)/0.04)]" >
-
+
@@ -199,7 +212,7 @@ export function UploadPage() {
NameSourceLanguageFilesStatusActions
NameSourceLanguageFilesStatusActions
+ + {repo.source === 'github' ? ( <> GitHub @@ -116,40 +116,44 @@ export function RepositoriesPage() { )} + {repo.meta?.language || '-'} + {repo.meta?.totalFiles || '-'} +
{repo.status} - +
+
+ + + + + + + + + + {sortedNodes.map((node) => ( + + + + + + + ))} + +
NameTypeLayerRelationship state
+ + {node.type.replace(/-/g, ' ')} + {(layerNameById.get(node.layer) ?? node.layer).replace(/-/g, ' ')} + + {node.relationshipState.replace(/-/g, ' ')} +
+
+ )} + + +
+

+ Relationships ({sortedEdges.length}) +

+ {sortedEdges.length === 0 ? ( +

No relationships in this snapshot.

+ ) : ( +
+ + + + + + + + + + + + {sortedEdges.map((edge) => ( + + + + + + + + ))} + +
SourceTargetTypePredicateTruth state
+ + + + {edge.type.replace(/-/g, ' ')}{edge.predicate.replace(/_/g, ' ')}{edge.truthClass}
+
+ )} +
+ +
+

+ Diagnostics ({sortedDiagnostics.length}) +

+ {sortedDiagnostics.length === 0 ? ( +

No diagnostics were recorded for this snapshot.

+ ) : ( +
+ + + + + + + + + + + + {sortedDiagnostics.map((diagnostic, index) => ( + + + + + + + + ))} + +
SeverityCodeMessageLocationRelated modules
{diagnostic.severity}{diagnostic.code}{diagnostic.message} + {diagnostic.path + ? `${diagnostic.path}${diagnostic.startLine ? `:${diagnostic.startLine}` : ''}` + : 'Not localised'} + + {diagnostic.nodeIds && diagnostic.nodeIds.length > 0 ? ( +
+ {diagnostic.nodeIds.map((id) => ( + + ))} +
+ ) : ( + + )} +
+
+ )} +
+
+ ); +} + +function NodeRefButton({ + id, + name, + onSelect, + compact = false, +}: { + id: string; + name: string | undefined; + onSelect: (id: string) => void; + compact?: boolean; +}) { + return ( + + ); +} diff --git a/apps/frontend/src/features/architecture/components/ArchitectureNode.test.tsx b/apps/frontend/src/features/architecture/components/ArchitectureNode.test.tsx new file mode 100644 index 00000000..dc958e0a --- /dev/null +++ b/apps/frontend/src/features/architecture/components/ArchitectureNode.test.tsx @@ -0,0 +1,88 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { ReactFlowProvider, type NodeProps } from '@xyflow/react'; +import type { ArchFlowNode } from './ArchitectureNode'; +import { ArchitectureNode } from './ArchitectureNode'; + +describe('ArchitectureNode', () => { + it('keeps the name, type, layer, and relationship state visible', () => { + const props = { + id: 'module:orders', + type: 'architectureNode', + data: { + label: 'Orders', + nodeType: 'service', + layer: 'business-logic', + relationshipState: 'connected', + description: 'Coordinates order processing.', + filesCount: 3, + complexity: 'medium', + }, + } as NodeProps; + + render( + + + , + ); + + expect(screen.getByText('Orders')).toBeInTheDocument(); + expect(screen.getByText('Service · Business Logic')).toBeInTheDocument(); + expect(screen.getByText('Connected')).toBeInTheDocument(); + }); + + it('exposes the full node detail as an accessible name when the card truncates', () => { + const props = { + id: 'module:notifications', + type: 'architectureNode', + data: { + label: 'Notifications Delivery Coordinator Service', + nodeType: 'service', + layer: 'business-logic', + relationshipState: 'connected', + description: 'Fans out delivery attempts across every configured channel.', + filesCount: 12, + complexity: 'high', + }, + } as NodeProps; + + render( + + + , + ); + + // Card text is truncated to keep node size stable, so the untruncated + // detail has to remain reachable to assistive tech and on hover (#112). + const node = screen.getByRole('group', { name: /Notifications Delivery Coordinator Service/ }); + expect(node).toHaveAttribute('title', expect.stringContaining('Fans out delivery attempts')); + expect(node.getAttribute('aria-label')).toContain('12 files'); + expect(node.getAttribute('aria-label')).toContain('high complexity'); + }); + + it('never renders a complexity badge or claim when complexity is not computed (#217)', () => { + const props = { + id: 'module:billing', + type: 'architectureNode', + data: { + label: 'Billing', + nodeType: 'service', + layer: 'business-logic', + relationshipState: 'connected', + description: 'Handles billing.', + filesCount: 4, + complexity: 'not_computed', + }, + } as NodeProps; + + render( + + + , + ); + + expect(screen.queryByText('not_computed')).not.toBeInTheDocument(); + const node = screen.getByRole('group', { name: /Billing/ }); + expect(node.getAttribute('aria-label')).not.toContain('complexity'); + }); +}); diff --git a/apps/frontend/src/features/architecture/components/ArchitectureNode.tsx b/apps/frontend/src/features/architecture/components/ArchitectureNode.tsx index d125ed4a..b85b9b0e 100644 --- a/apps/frontend/src/features/architecture/components/ArchitectureNode.tsx +++ b/apps/frontend/src/features/architecture/components/ArchitectureNode.tsx @@ -3,14 +3,15 @@ import { Handle, Position, type Node, type NodeProps } from '@xyflow/react'; import { Monitor, Server, Waypoints, Route, Cog, Database, Settings, Shield, Layers, Wrench, Box, Globe, Library, CloudCog, - ListTodo, Zap, + ListTodo, Zap, Play, } from 'lucide-react'; import { cn } from '@/shared/utils/cn'; -import type { ArchNodeType } from '@/shared/types/architecture'; +import type { ArchNodeType, RelationshipState } from '@/shared/types/architecture'; const nodeConfig: Record = { frontend: { icon: Monitor, color: 'text-blue-400', bg: 'bg-blue-500/10 border-blue-500/30' }, backend: { icon: Server, color: 'text-emerald-400', bg: 'bg-emerald-500/10 border-emerald-500/30' }, + entrypoint: { icon: Play, color: 'text-lime-400', bg: 'bg-lime-500/10 border-lime-500/30' }, controller: { icon: Waypoints, color: 'text-amber-400', bg: 'bg-amber-500/10 border-amber-500/30' }, route: { icon: Route, color: 'text-cyan-400', bg: 'bg-cyan-500/10 border-cyan-500/30' }, service: { icon: Cog, color: 'text-violet-400', bg: 'bg-violet-500/10 border-violet-500/30' }, @@ -31,9 +32,13 @@ const nodeConfig: Record; +const relationshipStateConfig: Record = { + connected: { label: 'Connected', className: 'bg-success/10 text-success' }, + 'no-observed-relationships': { label: 'No observed', className: 'bg-warning/10 text-warning' }, + unresolved: { label: 'Unresolved', className: 'bg-destructive/10 text-destructive' }, + 'not-extracted': { label: 'Not extracted', className: 'bg-muted text-muted-foreground' }, +}; + +function formatLabel(value: string): string { + return value.replace(/[-_]/g, ' ').replace(/\b\w/g, (letter) => letter.toUpperCase()); +} + export const ArchitectureNode = memo(({ data }: NodeProps) => { const config = nodeConfig[data.nodeType] || nodeConfig.utilities; const Icon = config.icon; @@ -56,10 +72,29 @@ export const ArchitectureNode = memo(({ data }: NodeProps) => { : 'ring-1 ring-yellow-500/30' : ''; + // Card text is truncated to keep node dimensions stable, so the full label, + // classification and description are also exposed as the node's accessible + // name and native tooltip. A truncated label must stay recoverable without + // zooming (#112). + const accessibleName = [ + data.label, + `${formatLabel(data.nodeType)}, ${formatLabel(data.layer)}`, + data.description, + data.filesCount > 0 ? `${data.filesCount} files` : null, + data.complexity !== 'not_computed' ? `${data.complexity} complexity` : null, + relationshipStateConfig[data.relationshipState].label, + ] + .filter(Boolean) + .join('. '); + return (
) => { >
@@ -77,32 +112,40 @@ export const ArchitectureNode = memo(({ data }: NodeProps) => {
-

{data.label}

+

{data.label}

{data.isBookmarked && ( * )}
-

{data.description}

-
+

+ {formatLabel(data.nodeType)} · {formatLabel(data.layer)} +

+

{data.description}

+
{data.filesCount > 0 && ( - {data.filesCount} files + {data.filesCount} files + )} + {data.complexity !== 'not_computed' && ( + + {data.complexity} + )} - - {data.complexity} + + {relationshipStateConfig[data.relationshipState].label}
diff --git a/apps/frontend/src/features/architecture/components/AuthenticationExplanationPanel.test.tsx b/apps/frontend/src/features/architecture/components/AuthenticationExplanationPanel.test.tsx new file mode 100644 index 00000000..89086154 --- /dev/null +++ b/apps/frontend/src/features/architecture/components/AuthenticationExplanationPanel.test.tsx @@ -0,0 +1,233 @@ +import { render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { describe, expect, it, vi } from 'vitest'; +import type { AuthenticationExplanationResponse } from '@/shared/services/api/types'; +import { useAuthenticationExplanation } from '../hooks/useAuthenticationExplanation'; +import { AuthenticationExplanationPanel } from './AuthenticationExplanationPanel'; + +vi.mock('../hooks/useAuthenticationExplanation'); + +const mockedUseAuthenticationExplanation = vi.mocked(useAuthenticationExplanation); + +function readyExplanation(overrides: Partial = {}): AuthenticationExplanationResponse { + return { + schemaVersion: 'auth-explanation.v1', + repositoryId: 'repo-1', + repositoryName: 'fixture', + revisionKind: 'upload', + revisionValue: 'sha256:0', + snapshotId: 'snap_1', + status: 'ready', + summary: 'Found 1 authentication-relevant route(s).', + claims: [ + { + kind: 'route', + name: '/me', + confidence: 'observed', + evidence: [{ snapshotId: 'snap_1', factId: 'src/routes.py::(anonymous:route#1)', path: 'src/routes.py', startLine: 19, endLine: 19 }], + }, + { + kind: 'middleware', + name: 'get_current_user', + confidence: 'heuristic', + evidence: [{ snapshotId: 'snap_1', factId: 'src/dependencies.py::get_current_user', path: 'src/dependencies.py', startLine: 6, endLine: 7 }], + }, + { + kind: 'service', + name: 'UserService', + confidence: 'heuristic', + evidence: [{ snapshotId: 'snap_1', factId: 'src/services.py::UserService', path: 'src/services.py', startLine: 3, endLine: 4 }], + }, + ], + relationships: [ + { + subject: '/me', + subjectKind: 'route', + predicate: 'routes_to', + object: 'read_me', + objectKind: 'handler', + evidence: [{ snapshotId: 'snap_1', factId: 'edge:1', path: 'src/routes.py', startLine: 19, endLine: 20 }], + }, + { + subject: 'read_me', + subjectKind: 'handler', + predicate: 'injects', + object: 'get_current_user', + objectKind: 'middleware', + evidence: [{ snapshotId: 'snap_1', factId: 'edge:2', path: 'src/routes.py', startLine: 20, endLine: 20 }], + }, + { + subject: 'get_current_user', + subjectKind: 'middleware', + predicate: 'calls', + object: 'UserService', + objectKind: 'service', + evidence: [{ snapshotId: 'snap_1', factId: 'edge:3', path: 'src/dependencies.py', startLine: 7, endLine: 7 }], + }, + ], + chains: [ + { + route: '/me', + hops: [ + { + subject: '/me', + subjectKind: 'route', + predicate: 'routes_to', + object: 'read_me', + objectKind: 'handler', + evidence: [{ snapshotId: 'snap_1', factId: 'edge:1', path: 'src/routes.py', startLine: 19, endLine: 20 }], + }, + { + subject: 'read_me', + subjectKind: 'handler', + predicate: 'injects', + object: 'get_current_user', + objectKind: 'middleware', + evidence: [{ snapshotId: 'snap_1', factId: 'edge:2', path: 'src/routes.py', startLine: 20, endLine: 20 }], + }, + { + subject: 'get_current_user', + subjectKind: 'middleware', + predicate: 'calls', + object: 'UserService', + objectKind: 'service', + evidence: [{ snapshotId: 'snap_1', factId: 'edge:3', path: 'src/dependencies.py', startLine: 7, endLine: 7 }], + }, + ], + }, + ], + diagnostics: [], + ...overrides, + }; +} + +function renderPanel(open: boolean, onClose = vi.fn()) { + return render( + + + , + ); +} + +describe('AuthenticationExplanationPanel', () => { + it('renders nothing when closed', () => { + mockedUseAuthenticationExplanation.mockReturnValue({ + explanation: null, + status: 'idle', + loading: false, + error: null, + empty: false, + success: false, + retry: vi.fn(), + }); + + renderPanel(false); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('renders the route -> handler -> guard -> service flow with grouped claims', () => { + mockedUseAuthenticationExplanation.mockReturnValue({ + explanation: readyExplanation(), + status: 'success', + loading: false, + error: null, + empty: false, + success: true, + retry: vi.fn(), + }); + + renderPanel(true); + + expect(screen.getByRole('dialog', { name: 'Authentication explanation' })).toBeInTheDocument(); + expect(screen.getByText('How authentication works')).toBeInTheDocument(); + expect(screen.getByText('Routes')).toBeInTheDocument(); + expect(screen.getByText('Middleware & guards')).toBeInTheDocument(); + expect(screen.getByText('Services')).toBeInTheDocument(); + + // The chain shows every hop: route -> handler, handler -> guard, guard -> service. + expect(screen.getAllByText('/me').length).toBeGreaterThan(0); + expect(screen.getAllByText('read_me').length).toBeGreaterThan(0); + expect(screen.getAllByText('get_current_user').length).toBeGreaterThan(0); + expect(screen.getAllByText('UserService').length).toBeGreaterThan(0); + expect(screen.getByText('routes_to')).toBeInTheDocument(); + expect(screen.getByText('injects')).toBeInTheDocument(); + expect(screen.getByText('calls')).toBeInTheDocument(); + }); + + it('renders every evidence citation as a real, keyboard-accessible link to the exact snapshot/path/line', () => { + mockedUseAuthenticationExplanation.mockReturnValue({ + explanation: readyExplanation(), + status: 'success', + loading: false, + error: null, + empty: false, + success: true, + retry: vi.fn(), + }); + + renderPanel(true); + + const citation = screen.getByRole('link', { name: 'View evidence at src/dependencies.py, line 6-7' }); + expect(citation).toBeInTheDocument(); + expect(citation.tagName).toBe('A'); + // A real is natively keyboard-focusable and activates on Enter -- no + // onClick-only handler standing in for navigation. + expect(citation).toHaveAttribute('href'); + const href = citation.getAttribute('href') ?? ''; + const url = new URL(href, 'http://localhost'); + expect(url.pathname).toBe('/repositories/repo-1'); + expect(url.searchParams.get('path')).toBe('src/dependencies.py'); + expect(url.searchParams.get('startLine')).toBe('6'); + expect(url.searchParams.get('endLine')).toBe('7'); + expect(url.searchParams.get('snapshotId')).toBe('snap_1'); + expect(url.searchParams.get('factId')).toBe('src/dependencies.py::get_current_user'); + expect(url.searchParams.get('tab')).toBe('Explorer'); + + // The relationship (chain hop) citations are real links too, not just claim citations. + const relationshipCitation = screen.getByRole('link', { name: 'View evidence at src/routes.py, line 19-20' }); + const relationshipHref = new URL(relationshipCitation.getAttribute('href') ?? '', 'http://localhost'); + expect(relationshipHref.searchParams.get('startLine')).toBe('19'); + expect(relationshipHref.searchParams.get('endLine')).toBe('20'); + }); + + it('reports a missing snapshot honestly instead of an empty result', () => { + mockedUseAuthenticationExplanation.mockReturnValue({ + explanation: readyExplanation({ + status: 'missing_snapshot', + snapshotId: null, + claims: [], + relationships: [], + chains: [], + summary: 'No sealed repository intelligence snapshot is available for this repository yet.', + }), + status: 'success', + loading: false, + error: null, + empty: false, + success: true, + retry: vi.fn(), + }); + + renderPanel(true); + expect( + screen.getByText('No sealed repository intelligence snapshot is available for this repository yet.'), + ).toBeInTheDocument(); + }); + + it('offers a retry action on error', () => { + const retry = vi.fn(); + mockedUseAuthenticationExplanation.mockReturnValue({ + explanation: null, + status: 'error', + loading: false, + error: 'Request failed', + empty: false, + success: false, + retry, + }); + + renderPanel(true); + screen.getByText('Retry').click(); + expect(retry).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/frontend/src/features/architecture/components/AuthenticationExplanationPanel.tsx b/apps/frontend/src/features/architecture/components/AuthenticationExplanationPanel.tsx new file mode 100644 index 00000000..bdc8521d --- /dev/null +++ b/apps/frontend/src/features/architecture/components/AuthenticationExplanationPanel.tsx @@ -0,0 +1,246 @@ +import { AnimatePresence, motion } from 'framer-motion'; +import { Link } from 'react-router-dom'; +import { ArrowRight, X, ShieldCheck, ChevronRight } from 'lucide-react'; +import { cn } from '@/shared/utils/cn'; +import type { AuthClaim, AuthClaimKind, AuthEvidenceRef, AuthRelationshipNodeKind } from '@/shared/services/api/types'; +import { useAuthenticationExplanation } from '../hooks/useAuthenticationExplanation'; + +interface AuthenticationExplanationPanelProps { + open: boolean; + onClose: () => void; +} + +const GROUP_ORDER: Array<{ kind: AuthClaimKind; label: string }> = [ + { kind: 'route', label: 'Routes' }, + { kind: 'middleware', label: 'Middleware & guards' }, + { kind: 'service', label: 'Services' }, + { kind: 'model', label: 'Models' }, + { kind: 'dependency', label: 'Dependencies' }, +]; + +const ROLE_LABEL: Record = { + route: 'route', + handler: 'handler', + middleware: 'guard', + service: 'service', + model: 'model', + dependency: 'dependency', +}; + +function evidenceHref(repositoryId: string, evidence: AuthEvidenceRef): string { + const params = new URLSearchParams({ + tab: 'Explorer', + path: evidence.path, + startLine: String(evidence.startLine), + endLine: String(evidence.endLine), + snapshotId: evidence.snapshotId, + factId: evidence.factId, + }); + return `/repositories/${repositoryId}?${params.toString()}`; +} + +function EvidenceCitation({ repositoryId, evidence }: { repositoryId: string; evidence: AuthEvidenceRef }) { + const span = evidence.startLine === evidence.endLine ? `${evidence.startLine}` : `${evidence.startLine}-${evidence.endLine}`; + const label = `View evidence at ${evidence.path}, line ${span}`; + + return ( + + {evidence.path}:{span} + + ); +} + +function ClaimRow({ claim, repositoryId }: { claim: AuthClaim; repositoryId: string }) { + return ( +
  • +
    + {claim.name} + + {claim.confidence === 'observed' ? 'observed' : 'inferred'} + +
    +
    + {claim.evidence.map((item, index) => ( + + ))} +
    +
  • + ); +} + +export function AuthenticationExplanationPanel({ open, onClose }: AuthenticationExplanationPanelProps) { + const { explanation, loading, error, status, retry } = useAuthenticationExplanation(open); + + const claimsByKind = new Map(); + for (const claim of explanation?.claims ?? []) { + const bucket = claimsByKind.get(claim.kind) ?? []; + bucket.push(claim); + claimsByKind.set(claim.kind, bucket); + } + + return ( + + {open && ( + <> +
    + {loading && ( +
    +
    + Loading authentication explanation... +
    + )} + + {!loading && error && ( +
    +

    {error}

    + +
    + )} + + {!loading && !error && status === 'empty' && ( +

    + Select an analysed repository to see how authentication works. +

    + )} + + {!loading && !error && explanation && explanation.status === 'missing_snapshot' && ( +

    {explanation.summary}

    + )} + + {!loading && !error && explanation && explanation.status === 'ready' && ( +
    +

    {explanation.summary}

    + + {explanation.chains.length > 0 && ( +
    +

    + + How authentication works +

    +
      + {explanation.chains.map((chain) => ( +
    • +

      {chain.route}

      +
        + {chain.hops.map((hop, index) => ( +
      1. +
        + {hop.subject} + ({ROLE_LABEL[hop.subjectKind]}) + + {hop.object} + ({ROLE_LABEL[hop.objectKind]}) + {hop.predicate} +
        + {hop.evidence.map((item, evidenceIndex) => ( + + ))} +
      2. + ))} +
      +
    • + ))} +
    +
    + )} + + {GROUP_ORDER.map(({ kind, label }) => { + const claims = claimsByKind.get(kind); + if (!claims || claims.length === 0) return null; + return ( +
    +

    + + {label} +

    +
      + {claims.map((claim, index) => ( + + ))} +
    +
    + ); + })} + + {explanation.claims.length === 0 && ( +

    + No authentication-relevant routes, middleware, services, or models were found in this + repository's analysed snapshot. +

    + )} + + {explanation.diagnostics.length > 0 && ( +
    +

    + Gaps and unresolved facts +

    +
      + {explanation.diagnostics.map((diagnostic, index) => ( +
    • + {diagnostic.message} + {diagnostic.path && ` (${diagnostic.path}${diagnostic.startLine ? `:${diagnostic.startLine}` : ''})`} +
    • + ))} +
    +
    + )} +
    + )} +
    + + + )} + + ); +} diff --git a/apps/frontend/src/features/architecture/components/GraphToolbar.tsx b/apps/frontend/src/features/architecture/components/GraphToolbar.tsx index 1f4e849a..ec9cc127 100644 --- a/apps/frontend/src/features/architecture/components/GraphToolbar.tsx +++ b/apps/frontend/src/features/architecture/components/GraphToolbar.tsx @@ -42,8 +42,8 @@ export function GraphToolbar({ } = useArchitectureStore(); return ( -
    -
    +
    +
    setExplorerOpen(!explorerOpen)} @@ -61,7 +61,7 @@ export function GraphToolbar({
    -
    +
    @@ -112,6 +112,8 @@ function ToolbarButton({ }) { return (
    ); diff --git a/apps/frontend/src/features/architecture/components/RelationshipPanel.test.tsx b/apps/frontend/src/features/architecture/components/RelationshipPanel.test.tsx new file mode 100644 index 00000000..d6c1c7dd --- /dev/null +++ b/apps/frontend/src/features/architecture/components/RelationshipPanel.test.tsx @@ -0,0 +1,149 @@ +import { render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ArchitectureModel, ArchNode } from '@/shared/types/architecture'; +import { useArchitectureStore } from '../store'; +import { RelationshipPanel } from './RelationshipPanel'; + +function node(id: string, name: string, relationshipState: ArchNode['relationshipState']): ArchNode { + return { + id, + name, + type: 'shared-library', + description: '', + responsibilities: [], + files: [`src/${name.toLowerCase()}.ts`], + dependencies: [], + dependents: [], + estimatedComplexity: 'low', + estimatedLines: 1, + tags: [], + layer: 'shared', + relationshipState, + }; +} + +function model(): ArchitectureModel { + return { + repositoryId: 'repo-1', + repositoryName: 'fixture', + architectureType: 'Repository Architecture', + detectedLayers: [], + nodes: [node('module:alpha', 'Alpha', 'connected'), node('module:beta', 'Beta', 'connected')], + edges: [ + { + id: 'edge:1', + source: 'module:alpha', + target: 'module:beta', + type: 'import', + predicate: 'imports', + truthClass: 'inferred', + evidence: [ + { + snapshotId: 'snap_1', + factId: 'edge:sha256:abc', + path: 'src/alpha.ts', + startLine: 4, + endLine: 4, + }, + ], + }, + ], + modules: [], + requestFlow: [], + relationshipSnapshotId: 'snap_1', + diagnostics: [], + summary: { + language: 'TypeScript', + framework: 'Unknown', + totalModules: 2, + totalNodes: 2, + entryPoint: '/', + architecturePattern: 'Repository Architecture', + }, + }; +} + +describe('RelationshipPanel', () => { + beforeEach(() => { + useArchitectureStore.setState({ + model: model(), + selectedNodeId: 'module:alpha', + bottomPanelOpen: true, + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('shows edge truth class and its persisted evidence span', () => { + render(); + + expect(screen.getByText('Beta')).toBeInTheDocument(); + expect(screen.getByText('imports · inferred')).toBeInTheDocument(); + expect(screen.getByText('src/alpha.ts:4')).toHaveAttribute( + 'title', + 'edge:sha256:abc in snapshot snap_1', + ); + }); + + it('distinguishes no observed relationship from missing extraction', () => { + const isolated = node('module:isolated', 'Isolated', 'no-observed-relationships'); + useArchitectureStore.setState({ + model: { ...model(), nodes: [isolated], edges: [] }, + selectedNodeId: isolated.id, + }); + const { rerender } = render(); + expect(screen.getByText('No observed relationships in the persisted snapshot')).toBeInTheDocument(); + + useArchitectureStore.setState({ + model: { ...model(), nodes: [{ ...isolated, relationshipState: 'not-extracted' }], edges: [] }, + }); + rerender(); + expect(screen.getByText('Relationship extraction is not available for this module')).toBeInTheDocument(); + }); + + it('shows node-attributed duplicate diagnostics beyond the visible file cap', () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const alpha = { + ...node('module:alpha', 'Alpha', 'unresolved'), + files: Array.from({ length: 25 }, (_, index) => `src/alpha/visible-${index}.ts`), + }; + useArchitectureStore.setState({ + model: { + ...model(), + nodes: [alpha], + edges: [], + diagnostics: [ + { + code: 'RI-RES-UNRESOLVED', + category: 'resolution', + severity: 'warning', + message: 'First hidden-file diagnostic', + path: 'src/alpha/hidden.ts', + startLine: 1, + nodeIds: [alpha.id], + }, + { + code: 'RI-RES-UNRESOLVED', + category: 'resolution', + severity: 'warning', + message: 'Second hidden-file diagnostic', + path: 'src/alpha/hidden.ts', + startLine: 1, + nodeIds: [alpha.id], + }, + ], + }, + selectedNodeId: alpha.id, + }); + + render(); + + expect(screen.getByText(/First hidden-file diagnostic/)).toBeInTheDocument(); + expect(screen.getByText(/Second hidden-file diagnostic/)).toBeInTheDocument(); + expect( + consoleError.mock.calls.some(([message]) => String(message).includes('Encountered two children with the same key')), + ).toBe(false); + }); +}); diff --git a/apps/frontend/src/features/architecture/components/RelationshipPanel.tsx b/apps/frontend/src/features/architecture/components/RelationshipPanel.tsx index e3e4fd92..f7adb672 100644 --- a/apps/frontend/src/features/architecture/components/RelationshipPanel.tsx +++ b/apps/frontend/src/features/architecture/components/RelationshipPanel.tsx @@ -12,6 +12,20 @@ export function RelationshipPanel() { const edges = selectedNodeId ? model.edges.filter((e) => e.source === selectedNodeId || e.target === selectedNodeId) : []; + const diagnostics = selectedNode + ? model.diagnostics.filter((diagnostic) => ( + diagnostic.nodeIds?.includes(selectedNode.id) + || (diagnostic.path && selectedNode.files.includes(diagnostic.path)) + || diagnostic.subjectKey === selectedNode.id + || diagnostic.objectKey === selectedNode.id + )) + : []; + + const emptyMessage = selectedNode?.relationshipState === 'no-observed-relationships' + ? 'No observed relationships in the persisted snapshot' + : selectedNode?.relationshipState === 'unresolved' + ? 'Relationships were observed but could not be resolved confidently' + : 'Relationship extraction is not available for this module'; return (
    @@ -40,7 +54,9 @@ export function RelationshipPanel() { {!selectedNode ? (

    Select a node to view its relationships

    ) : edges.length === 0 ? ( -

    No relationships found

    +
    +

    {emptyMessage}

    +
    ) : (
    {edges.map((edge) => { @@ -61,15 +77,36 @@ export function RelationshipPanel() { {isOutgoing ? 'OUT' : 'IN'} -
    +

    {targetNode.name}

    -

    {edge.type.replace('-', ' ')}

    +

    + {edge.predicate.replace('_', ' ')} · {edge.truthClass} +

    + {edge.evidence[0] && ( +

    + {edge.evidence[0].path}:{edge.evidence[0].startLine} + {edge.evidence[0].endLine !== edge.evidence[0].startLine && `-${edge.evidence[0].endLine}`} +

    + )}
    ); })}
    )} + {selectedNode && diagnostics.length > 0 && ( +
    + {diagnostics.map((diagnostic, index) => ( +

    + {diagnostic.code}: {diagnostic.message} + {diagnostic.path && ` (${diagnostic.path}${diagnostic.startLine ? `:${diagnostic.startLine}` : ''})`} +

    + ))} +
    + )}
    )} diff --git a/apps/frontend/src/features/architecture/components/RequestFlow.tsx b/apps/frontend/src/features/architecture/components/RequestFlow.tsx index 480d6b4b..7aae5669 100644 --- a/apps/frontend/src/features/architecture/components/RequestFlow.tsx +++ b/apps/frontend/src/features/architecture/components/RequestFlow.tsx @@ -29,7 +29,7 @@ export function RequestFlow({ steps }: RequestFlowProps) { return (
    -

    Request Flow

    +

    Request Flow

    {steps.map((step, index) => { const Icon = stepIcons[step.type] || Cog; diff --git a/apps/frontend/src/features/architecture/components/RevisionManifestPanel.test.tsx b/apps/frontend/src/features/architecture/components/RevisionManifestPanel.test.tsx new file mode 100644 index 00000000..6bbd09c6 --- /dev/null +++ b/apps/frontend/src/features/architecture/components/RevisionManifestPanel.test.tsx @@ -0,0 +1,110 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { architectureService } from '@/shared/services/api/architecture'; +import type { RevisionManifestResponse } from '@/shared/services/api/types'; +import { RevisionManifestPanel } from './RevisionManifestPanel'; + +vi.mock('@/shared/services/api/architecture', () => ({ + architectureService: { getRevisionManifest: vi.fn() }, +})); + +const mockedGetManifest = vi.mocked(architectureService.getRevisionManifest); + +const response: RevisionManifestResponse = { + manifest: { + schemaVersion: 'revision-manifest.v1', + repositoryId: 'repo-1', + revisionKind: 'upload', + revisionValue: `sha256:${'0'.repeat(64)}`, + revisionRef: null, + snapshotId: 'snap_example', + snapshotSchemaVersion: 'ri.v1', + extractors: [ + { name: 'typescript-extractor', version: '1.0.0' }, + { name: 'relationship-resolver', version: '1.0.0' }, + ], + producerSetHash: `sha256:${'1'.repeat(64)}`, + configHash: `sha256:${'2'.repeat(64)}`, + canonicalGraphHash: `sha256:${'3'.repeat(64)}`, + createdAt: '2026-07-25T00:00:00Z', + sealedAt: '2026-07-25T00:00:05Z', + }, + manifestDigest: `sha256:${'4'.repeat(64)}`, + verificationMethod: 'sha256-canonical-json', + verificationState: 'verified', + verificationNote: + 'This digest is a SHA-256 over the canonical JSON encoding of the manifest fields above. It is a content hash, not a digital signature.', +}; + +describe('RevisionManifestPanel', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('stays collapsed by default so it cannot crowd out the graph', async () => { + // Browser review of #112 found the expanded panel squeezing the + // architecture graph into a thin strip. The graph is the primary content + // on this page, so detail is opt-in. + mockedGetManifest.mockResolvedValue(response); + + render(); + + await waitFor(() => expect(screen.getByTestId('revision-manifest')).toBeInTheDocument()); + expect(screen.getByRole('button', { name: /details/i })).toHaveAttribute('aria-expanded', 'false'); + // The identity that binds citations to a revision stays visible collapsed. + expect(screen.getByText('snap_example')).toBeInTheDocument(); + expect(screen.getByTestId('verification-state')).toHaveTextContent('verified'); + // Detail is not rendered until asked for. + expect(screen.queryByText('ri.v1')).not.toBeInTheDocument(); + expect(screen.queryByText(/not a digital signature/)).not.toBeInTheDocument(); + }); + + it('shows the revision identity, extractor versions and digest', async () => { + mockedGetManifest.mockResolvedValue(response); + + render(); + + await waitFor(() => expect(screen.getByTestId('revision-manifest')).toBeInTheDocument()); + fireEvent.click(screen.getByRole('button', { name: /details/i })); + expect(screen.getByText('snap_example')).toBeInTheDocument(); + expect(screen.getByText('ri.v1')).toBeInTheDocument(); + expect(screen.getByText(`sha256:${'4'.repeat(64)}`)).toBeInTheDocument(); + expect(screen.getByText('typescript-extractor@1.0.0')).toBeInTheDocument(); + expect(screen.getByTestId('verification-state')).toHaveTextContent('verified'); + }); + + it('states that the digest is a content hash rather than a signature', async () => { + mockedGetManifest.mockResolvedValue(response); + + render(); + + await waitFor(() => expect(screen.getByTestId('revision-manifest')).toBeInTheDocument()); + fireEvent.click(screen.getByRole('button', { name: /details/i })); + expect(screen.getByText(/not a digital signature/)).toBeInTheDocument(); + }); + + it('copies the exact response body so it can be re-verified later', async () => { + mockedGetManifest.mockResolvedValue(response); + const writeText = vi.fn().mockResolvedValue(undefined); + Object.assign(navigator, { clipboard: { writeText } }); + + render(); + await waitFor(() => expect(screen.getByTestId('revision-manifest')).toBeInTheDocument()); + + screen.getByRole('button', { name: /copy/i }).click(); + + await waitFor(() => expect(writeText).toHaveBeenCalledOnce()); + expect(JSON.parse(writeText.mock.calls[0][0])).toEqual(response); + }); + + it('reports an honest unavailable state instead of inventing a revision', async () => { + mockedGetManifest.mockRejectedValue(new Error('No sealed snapshot is available.')); + + render(); + + await waitFor(() => + expect(screen.getByText(/No revision manifest is available/)).toBeInTheDocument(), + ); + expect(screen.queryByTestId('revision-manifest')).not.toBeInTheDocument(); + }); +}); diff --git a/apps/frontend/src/features/architecture/components/RevisionManifestPanel.tsx b/apps/frontend/src/features/architecture/components/RevisionManifestPanel.tsx new file mode 100644 index 00000000..9222324a --- /dev/null +++ b/apps/frontend/src/features/architecture/components/RevisionManifestPanel.tsx @@ -0,0 +1,190 @@ +import { useCallback, useEffect, useState } from 'react'; +import { Check, ChevronDown, Copy, Download, ShieldCheck } from 'lucide-react'; +import { architectureService } from '@/shared/services/api/architecture'; +import { getErrorMessage } from '@/shared/services/api'; +import type { RevisionManifestResponse } from '@/shared/services/api/types'; + +interface RevisionManifestPanelProps { + repositoryId: string; +} + +function Field({ label, value }: { label: string; value: string }) { + return ( +
    +
    {label}
    +
    + {value} +
    +
    + ); +} + +/** + * Shows the verifiable identity of the snapshot the surrounding evidence was + * derived from, and lets the user take it away (#113). + * + * The exported JSON is the exact response body, so it can be replayed against + * the verify endpoint later. The digest is a canonical content hash: the panel + * states that plainly rather than implying the manifest is signed. + */ +export function RevisionManifestPanel({ repositoryId }: RevisionManifestPanelProps) { + const [manifest, setManifest] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + const [copied, setCopied] = useState(false); + // Collapsed by default. Browser review of #112 showed the expanded panel + // consuming most of the page and squeezing the graph into a thin strip; the + // graph is the primary content here, so the manifest summarises and expands + // on request. + const [expanded, setExpanded] = useState(false); + + useEffect(() => { + let cancelled = false; + setManifest(null); + setExpanded(false); + setLoading(true); + setError(null); + architectureService + .getRevisionManifest(repositoryId) + .then((response) => { + if (!cancelled) setManifest(response); + }) + .catch((caught) => { + if (!cancelled) setError(getErrorMessage(caught)); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [repositoryId]); + + const serialized = manifest ? JSON.stringify(manifest, null, 2) : ''; + + const handleCopy = useCallback(async () => { + if (!serialized) return; + await navigator.clipboard.writeText(serialized); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }, [serialized]); + + const handleDownload = useCallback(() => { + if (!manifest) return; + const blob = new Blob([serialized], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `partha-revision-manifest-${manifest.manifest.snapshotId}.json`; + link.click(); + URL.revokeObjectURL(url); + }, [manifest, serialized]); + + if (loading) { + return ( +
    + Loading revision manifest... +
    + ); + } + + if (error) { + return ( +
    +

    + No revision manifest is available for this repository yet. {error} +

    +
    + ); + } + + if (!manifest) return null; + + const { manifest: body, manifestDigest, verificationState, verificationNote } = manifest; + + return ( +
    +
    +
    +
    +
    + + + +
    +
    + + {!expanded ? null : ( +
    +
    + {/* Snapshot id is always shown in the header, so it is not repeated. */} + + + + + +
    + +
    +

    Extractors

    +
      + {body.extractors.map((extractor) => ( +
    • + {extractor.name}@{extractor.version} +
    • + ))} +
    +
    + +

    {verificationNote}

    +
    + )} +
    + ); +} diff --git a/apps/frontend/src/features/architecture/hooks/useArchitecture.ts b/apps/frontend/src/features/architecture/hooks/useArchitecture.ts index 31655977..73ffd705 100644 --- a/apps/frontend/src/features/architecture/hooks/useArchitecture.ts +++ b/apps/frontend/src/features/architecture/hooks/useArchitecture.ts @@ -1,8 +1,8 @@ import { useCallback, useEffect, useState } from 'react'; -import type { DataSource, FeatureStatus } from '@/shared/types'; +import type { RepositorySource, FeatureStatus } from '@/shared/types'; import type { ArchitectureModel } from '@/shared/types/architecture'; import { backendService } from '@/shared/services/backend'; -import { getErrorMessage } from '@/shared/services/api'; +import { getErrorMessage, isApiError } from '@/shared/services/api'; import { useRepository } from '@/features/repositories/hooks/useRepository'; import { useArchitectureStore } from '../store'; @@ -11,11 +11,15 @@ export type ArchitectureEmptyReason = 'no-completed-repositories' | 'no-active-r export function useArchitecture() { const { activeRepository, completedRepositories } = useRepository(); const setStoreModel = useArchitectureStore((state) => state.setModel); - const storeModel = useArchitectureStore((state) => state.model); - const [model, setModel] = useState(storeModel); - const [source, setSource] = useState(null); + const resetForRepository = useArchitectureStore((state) => state.resetForRepository); + const [model, setModel] = useState(null); + const [source, setSource] = useState(null); const [status, setStatus] = useState('idle'); const [error, setError] = useState(null); + // A repository can be "completed" (analysed) yet still have no sealed ri.v1 + // snapshot yet, the same 404 Dependencies/Review/Insights already surface + // (#217). That must never be mistaken for a real, empty architecture. + const [noSnapshot, setNoSnapshot] = useState(false); const [refreshKey, setRefreshKey] = useState(0); const refresh = useCallback(() => setRefreshKey((key) => key + 1), []); @@ -24,25 +28,33 @@ export function useArchitecture() { if (completedRepositories.length === 0) { setStatus('empty'); setModel(null); + setStoreModel(null); setSource(null); setError(null); + setNoSnapshot(false); return; } if (!activeRepository || activeRepository.status !== 'completed') { setStatus('empty'); setModel(null); + setStoreModel(null); setSource(null); setError(null); + setNoSnapshot(false); return; } let cancelled = false; + resetForRepository(); + setModel(null); + setSource(null); async function loadArchitecture() { if (!activeRepository) return; setStatus('loading'); setError(null); + setNoSnapshot(false); try { const nextModel = await backendService.fetchArchitecture(activeRepository); @@ -50,14 +62,19 @@ export function useArchitecture() { setModel(nextModel); setStoreModel(nextModel); - setSource('real'); + setSource(activeRepository.source); setStatus('success'); } catch (caught) { if (cancelled) return; setModel(null); setSource(null); - setError(getErrorMessage(caught)); - setStatus('error'); + if (isApiError(caught) && caught.isNotFound) { + setNoSnapshot(true); + setStatus('error'); + } else { + setError(getErrorMessage(caught)); + setStatus('error'); + } } } @@ -66,7 +83,7 @@ export function useArchitecture() { return () => { cancelled = true; }; - }, [activeRepository, completedRepositories.length, refreshKey, setStoreModel]); + }, [activeRepository, completedRepositories.length, refreshKey, resetForRepository, setStoreModel]); const emptyReason: ArchitectureEmptyReason = status === 'empty' @@ -82,6 +99,7 @@ export function useArchitecture() { status, loading: status === 'loading', error, + noSnapshot, empty: status === 'empty', success: status === 'success', retry: refresh, @@ -89,6 +107,5 @@ export function useArchitecture() { activeRepository, completedRepositories, emptyReason, - usingMockData: false, }; } diff --git a/apps/frontend/src/features/architecture/hooks/useAuthenticationExplanation.ts b/apps/frontend/src/features/architecture/hooks/useAuthenticationExplanation.ts new file mode 100644 index 00000000..f903bf2b --- /dev/null +++ b/apps/frontend/src/features/architecture/hooks/useAuthenticationExplanation.ts @@ -0,0 +1,64 @@ +import { useCallback, useEffect, useState } from 'react'; +import type { FeatureStatus } from '@/shared/types'; +import type { AuthenticationExplanationResponse } from '@/shared/services/api/types'; +import { backendService } from '@/shared/services/backend'; +import { getErrorMessage } from '@/shared/services/api'; +import { useRepository } from '@/features/repositories/hooks/useRepository'; + +export function useAuthenticationExplanation(enabled: boolean) { + const { activeRepository } = useRepository(); + const [explanation, setExplanation] = useState(null); + const [status, setStatus] = useState('idle'); + const [error, setError] = useState(null); + const [refreshKey, setRefreshKey] = useState(0); + + const refresh = useCallback(() => setRefreshKey((key) => key + 1), []); + + useEffect(() => { + if (!enabled) return; + + if (!activeRepository || activeRepository.status !== 'completed') { + setStatus('empty'); + setExplanation(null); + setError(null); + return; + } + + let cancelled = false; + setExplanation(null); + + async function load() { + if (!activeRepository) return; + setStatus('loading'); + setError(null); + + try { + const response = await backendService.fetchAuthenticationExplanation(activeRepository); + if (cancelled) return; + setExplanation(response); + setStatus('success'); + } catch (caught) { + if (cancelled) return; + setExplanation(null); + setError(getErrorMessage(caught)); + setStatus('error'); + } + } + + void load(); + + return () => { + cancelled = true; + }; + }, [activeRepository, enabled, refreshKey]); + + return { + explanation, + status, + loading: status === 'loading', + error, + empty: status === 'empty', + success: status === 'success', + retry: refresh, + }; +} diff --git a/apps/frontend/src/features/architecture/layout.test.ts b/apps/frontend/src/features/architecture/layout.test.ts new file mode 100644 index 00000000..2693e1f5 --- /dev/null +++ b/apps/frontend/src/features/architecture/layout.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from 'vitest'; +import { getLayoutedElements } from './layout'; +import type { ArchEdge, ArchLayer, ArchNode } from '@/shared/types/architecture'; + +function node(id: string, layer: string, relationshipState: ArchNode['relationshipState'] = 'connected'): ArchNode { + return { + id, + name: id, + type: 'service', + description: `${id} description`, + responsibilities: [], + files: [`${id}.ts`], + dependencies: [], + dependents: [], + estimatedComplexity: 'low', + estimatedLines: 20, + tags: [layer], + layer, + relationshipState, + }; +} + +function edge(source: string, target: string): ArchEdge { + return { + id: `${source}->${target}`, + source, + target, + type: 'dependency', + predicate: 'depends_on', + truthClass: 'resolved', + evidence: [], + }; +} + +const layers: ArchLayer[] = [ + { id: 'presentation', name: 'Presentation', order: 0, nodes: ['web'] }, + { id: 'business-logic', name: 'Business Logic', order: 1, nodes: ['api', 'worker'] }, + { id: 'infrastructure', name: 'Infrastructure', order: 2, nodes: ['db'] }, +]; + +describe('getLayoutedElements', () => { + it('places disconnected nodes into readable left-to-right layer columns', () => { + const result = getLayoutedElements( + [node('web', 'presentation'), node('api', 'business-logic'), node('worker', 'business-logic'), node('db', 'infrastructure')], + [edge('web', 'api')], + { layers }, + ); + + const byId = new Map(result.nodes.map((item) => [item.id, item])); + expect(byId.get('web')!.position.x).toBeLessThan(byId.get('api')!.position.x); + expect(byId.get('api')!.position.x).toBeLessThan(byId.get('db')!.position.x); + expect(byId.get('api')!.position.x).toBe(byId.get('worker')!.position.x); + expect(byId.get('api')!.position.y).not.toBe(byId.get('worker')!.position.y); + }); + + it('keeps node geometry non-overlapping and preserves only real edges', () => { + const result = getLayoutedElements( + [node('web', 'presentation'), node('api', 'business-logic'), node('db', 'infrastructure')], + [edge('web', 'api')], + { layers }, + ); + + const positions = result.nodes.map((item) => item.position); + for (let left = 0; left < positions.length; left += 1) { + for (let right = left + 1; right < positions.length; right += 1) { + const sameColumn = positions[left].x === positions[right].x; + const sameRow = positions[left].y === positions[right].y; + expect(sameColumn && sameRow).toBe(false); + } + } + expect(result.edges.map((item) => item.id)).toEqual(['web->api']); + }); + + it('removes collapsed layers and their incident edges', () => { + const result = getLayoutedElements( + [node('web', 'presentation'), node('api', 'business-logic'), node('db', 'infrastructure')], + [edge('web', 'api'), edge('api', 'db')], + { layers, collapsedLayers: new Set(['business-logic']) }, + ); + + expect(result.nodes.map((item) => item.id)).toEqual(['web', 'db']); + expect(result.edges).toEqual([]); + }); + + it('wraps a busy single semantic layer into a compact deterministic grid', () => { + const busyNodes = Array.from({ length: 14 }, (_, index) => + node(`segment-${String(index + 1).padStart(2, '0')}`, 'shared'), + ); + const busyLayer: ArchLayer[] = [ + { id: 'shared', name: 'Shared', order: 0, nodes: busyNodes.map((item) => item.id) }, + ]; + + const first = getLayoutedElements(busyNodes, [], { layers: busyLayer }); + const second = getLayoutedElements(busyNodes, [], { layers: busyLayer }); + const xPositions = new Set(first.nodes.map((item) => item.position.x)); + const yPositions = new Set(first.nodes.map((item) => item.position.y)); + + expect(first).toEqual(second); + expect(xPositions.size).toBeGreaterThan(1); + expect(yPositions.size).toBeGreaterThan(1); + expect(Math.max(...xPositions) - Math.min(...xPositions)).toBeLessThan(1000); + expect(Math.max(...yPositions) - Math.min(...yPositions)).toBeLessThan(1000); + }); +}); diff --git a/apps/frontend/src/features/architecture/layout.ts b/apps/frontend/src/features/architecture/layout.ts index 511b2bf1..10b61030 100644 --- a/apps/frontend/src/features/architecture/layout.ts +++ b/apps/frontend/src/features/architecture/layout.ts @@ -1,10 +1,15 @@ import { Graph, layout } from '@dagrejs/dagre'; import type { Edge } from '@xyflow/react'; -import type { ArchNode, ArchEdge, HeatmapMode } from '@/shared/types/architecture'; +import type { ArchLayer, ArchNode, ArchEdge, HeatmapMode } from '@/shared/types/architecture'; import type { ArchFlowNode } from './components/ArchitectureNode'; -const NODE_WIDTH = 180; -const NODE_HEIGHT = 80; +export const ARCH_NODE_WIDTH = 220; +export const ARCH_NODE_HEIGHT = 112; +// Keep the review-default graph inside a laptop viewport at the readable +// 0.85x zoom floor. Wider graphs remain pannable, but common five-layer and +// busy single-layer fixtures should not open with partially clipped cards. +const RANK_GAP = 50; +const NODE_GAP = 35; export function getLayoutedElements( archNodes: ArchNode[], @@ -15,15 +20,20 @@ export function getLayoutedElements( bookmarks?: Set; hiddenNodes?: Set; isolatedSubtree?: string | null; + layers?: ArchLayer[]; + collapsedLayers?: Set; } ): { nodes: ArchFlowNode[]; edges: Edge[] } { - const direction = options?.direction || 'TB'; + const direction = options?.direction || 'LR'; const heatmapMode = options?.heatmapMode || 'none'; const bookmarks = options?.bookmarks || new Set(); const hiddenNodes = options?.hiddenNodes || new Set(); const isolatedSubtree = options?.isolatedSubtree || null; + const collapsedLayers = options?.collapsedLayers || new Set(); - let filteredNodes = archNodes.filter((n) => !hiddenNodes.has(n.id)); + let filteredNodes = archNodes.filter( + (node) => !hiddenNodes.has(node.id) && !collapsedLayers.has(node.layer) + ); let filteredEdges = archEdges; if (isolatedSubtree) { @@ -34,29 +44,37 @@ export function getLayoutedElements( const g = new Graph(); g.setDefaultEdgeLabel(() => ({})); - g.setGraph({ rankdir: direction, ranksep: 80, nodesep: 40, marginx: 40, marginy: 40 }); + g.setGraph({ rankdir: direction, ranksep: RANK_GAP, nodesep: NODE_GAP, marginx: 40, marginy: 40 }); filteredNodes.forEach((node) => { - g.setNode(node.id, { width: NODE_WIDTH, height: NODE_HEIGHT }); + g.setNode(node.id, { width: ARCH_NODE_WIDTH, height: ARCH_NODE_HEIGHT }); }); + const filteredNodeIds = new Set(filteredNodes.map((node) => node.id)); filteredEdges.forEach((edge) => { - if (filteredNodes.some((n) => n.id === edge.source) && filteredNodes.some((n) => n.id === edge.target)) { + if (filteredNodeIds.has(edge.source) && filteredNodeIds.has(edge.target)) { g.setEdge(edge.source, edge.target); } }); layout(g); + const orderedLayers = getOrderedLayers(filteredNodes, options?.layers); + const layerPositions = getLayerPositions(filteredNodes, orderedLayers, g, direction); + const nodes: ArchFlowNode[] = filteredNodes.map((node) => { - const pos = g.node(node.id); + const pos = layerPositions.get(node.id) || g.node(node.id); return { id: node.id, type: 'architectureNode' as const, - position: { x: pos.x - NODE_WIDTH / 2, y: pos.y - NODE_HEIGHT / 2 }, + initialWidth: ARCH_NODE_WIDTH, + initialHeight: ARCH_NODE_HEIGHT, + position: { x: pos.x - ARCH_NODE_WIDTH / 2, y: pos.y - ARCH_NODE_HEIGHT / 2 }, data: { label: node.name, nodeType: node.type, + layer: node.layer, + relationshipState: node.relationshipState, description: node.description, filesCount: node.files.length, complexity: node.estimatedComplexity, @@ -69,7 +87,7 @@ export function getLayoutedElements( }); const edges: Edge[] = filteredEdges - .filter((e) => filteredNodes.some((n) => n.id === e.source) && filteredNodes.some((n) => n.id === e.target)) + .filter((e) => filteredNodeIds.has(e.source) && filteredNodeIds.has(e.target)) .map((edge) => ({ id: edge.id, source: edge.source, @@ -86,6 +104,85 @@ export function getLayoutedElements( return { nodes, edges }; } +function getOrderedLayers(nodes: ArchNode[], layers?: ArchLayer[]): ArchLayer[] { + const visibleNodeIds = new Set(nodes.map((node) => node.id)); + const knownLayers = new Map((layers || []).map((layer) => [layer.id, layer])); + const layerIds = new Set(nodes.map((node) => node.layer)); + + return [...layerIds] + .sort((left, right) => { + const leftOrder = knownLayers.get(left)?.order ?? Number.MAX_SAFE_INTEGER; + const rightOrder = knownLayers.get(right)?.order ?? Number.MAX_SAFE_INTEGER; + return leftOrder - rightOrder || left.localeCompare(right); + }) + .map((id) => ({ + id, + name: knownLayers.get(id)?.name || id.replace('-', ' ').replace(/\b\w/g, (letter) => letter.toUpperCase()), + order: knownLayers.get(id)?.order ?? Number.MAX_SAFE_INTEGER, + nodes: (knownLayers.get(id)?.nodes || nodes.filter((node) => node.layer === id).map((node) => node.id)) + .filter((nodeId) => visibleNodeIds.has(nodeId)), + })); +} + +function getLayerPositions( + nodes: ArchNode[], + layers: ArchLayer[], + graph: Graph, + direction: 'TB' | 'LR', +): Map { + const positions = new Map(); + const nodesById = new Map(nodes.map((node) => [node.id, node])); + let layerCursor = 0; + + layers.forEach((layer, layerIndex) => { + const layerNodes = layer.nodes + .map((nodeId) => nodesById.get(nodeId)) + .filter((node): node is ArchNode => node !== undefined) + .sort((left, right) => { + const leftY = graph.node(left.id)?.y ?? 0; + const rightY = graph.node(right.id)?.y ?? 0; + return leftY - rightY || left.name.localeCompare(right.name) || left.id.localeCompare(right.id); + }); + + if (direction === 'LR') { + // A semantic layer is a band, not necessarily one physical column. + // Deterministically wrap a busy layer into sub-columns so a 14-node + // single-layer repository opens as a compact grid instead of one very + // tall column with an empty canvas beside it (#112). + // A small semantic layer reads best as one column. Only wrap layers + // large enough to become taller than the review canvas. + const maxRows = layerNodes.length <= 4 + ? Math.max(1, layerNodes.length) + : Math.max(2, Math.ceil(Math.sqrt(layerNodes.length))); + const columnCount = Math.max(1, Math.ceil(layerNodes.length / maxRows)); + layerNodes.forEach((node, nodeIndex) => { + const columnIndex = Math.floor(nodeIndex / maxRows); + const rowIndex = nodeIndex % maxRows; + const rowsInColumn = Math.min(maxRows, layerNodes.length - columnIndex * maxRows); + const offset = -((rowsInColumn - 1) * (ARCH_NODE_HEIGHT + NODE_GAP)) / 2; + positions.set(node.id, { + x: layerCursor + columnIndex * (ARCH_NODE_WIDTH + NODE_GAP) + ARCH_NODE_WIDTH / 2, + y: offset + rowIndex * (ARCH_NODE_HEIGHT + NODE_GAP) + ARCH_NODE_HEIGHT / 2, + }); + }); + layerCursor += + columnCount * ARCH_NODE_WIDTH + + Math.max(0, columnCount - 1) * NODE_GAP + + RANK_GAP; + } else { + const offset = -((layerNodes.length - 1) * (ARCH_NODE_WIDTH + NODE_GAP)) / 2; + layerNodes.forEach((node, nodeIndex) => { + positions.set(node.id, { + x: offset + nodeIndex * (ARCH_NODE_WIDTH + NODE_GAP) + ARCH_NODE_WIDTH / 2, + y: layerIndex * (ARCH_NODE_HEIGHT + RANK_GAP) + ARCH_NODE_HEIGHT / 2, + }); + }); + } + }); + + return positions; +} + function getSubtreeIds(rootId: string, nodes: ArchNode[], edges: ArchEdge[]): Set { const ids = new Set([rootId]); const queue = [rootId]; @@ -114,22 +211,15 @@ function computeHeatmapIntensity(node: ArchNode, allNodes: ArchNode[], mode: Hea if (mode === 'none') return 0; switch (mode) { - case 'complexity': { - const map = { low: 0.2, medium: 0.5, high: 0.9 }; - return map[node.estimatedComplexity] || 0; - } case 'usage': { const maxDeps = Math.max(...allNodes.map((n) => n.dependents.length), 1); return node.dependents.length / maxDeps; } - case 'size': { - const maxLines = Math.max(...allNodes.map((n) => n.estimatedLines), 1); - return node.estimatedLines / maxLines; - } case 'critical': { - const score = (node.dependents.length * 0.4) + - (node.estimatedComplexity === 'high' ? 0.4 : node.estimatedComplexity === 'medium' ? 0.2 : 0) + - (node.files.length > 3 ? 0.2 : 0); + // Real signals only (#217): dependents count and file count, both from + // the sealed snapshot. No complexity term -- nothing measures it today. + const maxDeps = Math.max(...allNodes.map((n) => n.dependents.length), 1); + const score = (node.dependents.length / maxDeps) * 0.6 + (node.files.length > 3 ? 0.4 : 0); return Math.min(score, 1); } default: diff --git a/apps/frontend/src/features/architecture/store.ts b/apps/frontend/src/features/architecture/store.ts index c10d73d7..55dd6faa 100644 --- a/apps/frontend/src/features/architecture/store.ts +++ b/apps/frontend/src/features/architecture/store.ts @@ -4,7 +4,8 @@ import type { HeatmapMode } from '@/shared/types/architecture'; interface ArchitectureState { model: ArchitectureModel | null; - setModel: (model: ArchitectureModel) => void; + setModel: (model: ArchitectureModel | null) => void; + resetForRepository: () => void; selectedNodeId: string | null; setSelectedNodeId: (id: string | null) => void; @@ -17,6 +18,9 @@ interface ArchitectureState { expandedModules: Set; toggleModule: (id: string) => void; + collapsedLayers: Set; + toggleLayer: (id: string) => void; + showAllLayers: () => void; showMiniMap: boolean; setShowMiniMap: (show: boolean) => void; @@ -24,8 +28,8 @@ interface ArchitectureState { showGrid: boolean; setShowGrid: (show: boolean) => void; - activeTab: 'graph' | 'request-flow' | 'heatmap'; - setActiveTab: (tab: 'graph' | 'request-flow' | 'heatmap') => void; + activeTab: 'graph' | 'request-flow' | 'heatmap' | 'list'; + setActiveTab: (tab: 'graph' | 'request-flow' | 'heatmap' | 'list') => void; inspectorOpen: boolean; setInspectorOpen: (open: boolean) => void; @@ -58,6 +62,19 @@ interface ArchitectureState { export const useArchitectureStore = create((set) => ({ model: null, setModel: (model) => set({ model }), + resetForRepository: () => + set({ + model: null, + selectedNodeId: null, + highlightedNodeIds: new Set(), + searchQuery: '', + collapsedLayers: new Set(), + inspectorOpen: false, + hiddenNodes: new Set(), + isolatedSubtree: null, + contextMenuTarget: null, + contextMenuPosition: null, + }), selectedNodeId: null, setSelectedNodeId: (id) => set({ selectedNodeId: id, inspectorOpen: !!id }), @@ -77,6 +94,16 @@ export const useArchitectureStore = create((set) => ({ return { expandedModules: next }; }), + collapsedLayers: new Set(), + toggleLayer: (id) => + set((state) => { + const next = new Set(state.collapsedLayers); + if (next.has(id)) next.delete(id); + else next.add(id); + return { collapsedLayers: next }; + }), + showAllLayers: () => set({ collapsedLayers: new Set() }), + showMiniMap: true, setShowMiniMap: (show) => set({ showMiniMap: show }), @@ -89,7 +116,10 @@ export const useArchitectureStore = create((set) => ({ inspectorOpen: false, setInspectorOpen: (open) => set({ inspectorOpen: open }), - explorerOpen: true, + // The graph is the primary review surface. The module explorer remains one + // click away, but opening it by default steals enough width to clip a + // readable five-layer graph at the minimum zoom. + explorerOpen: false, setExplorerOpen: (open) => set({ explorerOpen: open }), heatmapMode: 'none', diff --git a/apps/frontend/src/features/auth/authRedirect.test.tsx b/apps/frontend/src/features/auth/authRedirect.test.tsx new file mode 100644 index 00000000..aa9a3d8c --- /dev/null +++ b/apps/frontend/src/features/auth/authRedirect.test.tsx @@ -0,0 +1,63 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { createMemoryRouter, RouterProvider } from 'react-router-dom'; +import { resolveRedirectTarget } from './authRedirect'; +import { LoginPage } from '@/app/pages/LoginPage'; +import { RegisterPage } from '@/app/pages/RegisterPage'; +import { authService } from '@/shared/services/api'; +import { useAuthStore } from '@/app/store/useAuthStore'; + +describe('resolveRedirectTarget', () => { + it('returns / when there is no captured destination', () => { + expect(resolveRedirectTarget(null)).toBe('/dashboard'); + expect(resolveRedirectTarget(undefined)).toBe('/dashboard'); + expect(resolveRedirectTarget({})).toBe('/dashboard'); + }); + + it('reconstructs the full destination from pathname, search, and hash', () => { + expect(resolveRedirectTarget({ from: { pathname: '/repositories/abc' } })).toBe('/repositories/abc'); + expect( + resolveRedirectTarget({ from: { pathname: '/repositories/abc', search: '?tab=files', hash: '#section' } }), + ).toBe('/repositories/abc?tab=files#section'); + }); +}); + +describe('login/register redirect-destination consistency', () => { + afterEach(() => { + vi.restoreAllMocks(); + useAuthStore.setState({ status: 'initialising', accessToken: null, user: null }); + }); + + it('preserves the intended destination through the login<->register link and after a successful register', async () => { + const capturedFrom = { pathname: '/repositories/abc', search: '?tab=files', hash: '#section' }; + const router = createMemoryRouter( + [ + { path: '/login', element: }, + { path: '/register', element: }, + { path: '/repositories/:id', element:
    Repo Detail
    }, + ], + { initialEntries: [{ pathname: '/login', state: { from: capturedFrom } }] }, + ); + render(); + + // Same destination RequireAuth would have captured on the way to /login. + fireEvent.click(screen.getByRole('link', { name: 'Create one' })); + + await waitFor(() => expect(router.state.location.pathname).toBe('/register')); + expect(router.state.location.state).toEqual({ from: capturedFrom }); + + vi.spyOn(authService, 'register').mockResolvedValue({ + accessToken: 'tok', + tokenType: 'bearer', + user: { id: 'u1', email: 'new@example.com', createdAt: new Date().toISOString() }, + }); + + fireEvent.change(screen.getByLabelText('Email'), { target: { value: 'new@example.com' } }); + fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'longenoughpassword' } }); + fireEvent.click(screen.getByRole('button', { name: 'Create account' })); + + await waitFor(() => expect(router.state.location.pathname).toBe('/repositories/abc')); + expect(router.state.location.search).toBe('?tab=files'); + expect(router.state.location.hash).toBe('#section'); + }); +}); diff --git a/apps/frontend/src/features/auth/authRedirect.ts b/apps/frontend/src/features/auth/authRedirect.ts new file mode 100644 index 00000000..d7c72845 --- /dev/null +++ b/apps/frontend/src/features/auth/authRedirect.ts @@ -0,0 +1,16 @@ +export interface AuthRedirectState { + from?: { + pathname?: string; + search?: string; + hash?: string; + }; +} + +// Shared by login and register so the two behave identically: whichever one +// the user completes, they land back on the exact route RequireAuth sent +// them away from (path, query string, and hash), not just its pathname. +export function resolveRedirectTarget(state: unknown): string { + const from = (state as AuthRedirectState | null)?.from; + if (!from?.pathname) return '/dashboard'; + return `${from.pathname}${from.search ?? ''}${from.hash ?? ''}`; +} diff --git a/apps/frontend/src/features/auth/components/OAuthButtons.test.tsx b/apps/frontend/src/features/auth/components/OAuthButtons.test.tsx new file mode 100644 index 00000000..97e4847d --- /dev/null +++ b/apps/frontend/src/features/auth/components/OAuthButtons.test.tsx @@ -0,0 +1,64 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { OAuthButtons } from './OAuthButtons'; +import { authService, getErrorMessage } from '@/shared/services/api'; + +vi.mock('@/shared/services/api', () => ({ + authService: { getOAuthProviders: vi.fn(), startOAuthLogin: vi.fn() }, + getErrorMessage: vi.fn((error: unknown) => String(error)), +})); + +beforeEach(() => { + vi.mocked(authService.getOAuthProviders).mockReset(); + vi.mocked(authService.startOAuthLogin).mockReset(); + vi.mocked(getErrorMessage).mockImplementation((error: unknown) => String(error)); +}); + +describe('OAuthButtons', () => { + it('renders nothing while the capability check is in flight or once it resolves empty', async () => { + vi.mocked(authService.getOAuthProviders).mockResolvedValue({ providers: [] }); + + const { container } = render(); + + expect(container).toBeEmptyDOMElement(); + await waitFor(() => expect(authService.getOAuthProviders).toHaveBeenCalled()); + expect(container).toBeEmptyDOMElement(); + }); + + it('renders a button only for each configured provider', async () => { + vi.mocked(authService.getOAuthProviders).mockResolvedValue({ providers: ['google'] }); + + render(); + + expect(await screen.findByTestId('oauth-button-google')).toBeInTheDocument(); + expect(screen.queryByTestId('oauth-button-github')).not.toBeInTheDocument(); + }); + + it('navigates the browser to the returned authorize URL on click', async () => { + vi.mocked(authService.getOAuthProviders).mockResolvedValue({ providers: ['github'] }); + vi.mocked(authService.startOAuthLogin).mockResolvedValue({ authorizeUrl: 'https://github.example/authorize' }); + const assignSpy = vi.fn(); + vi.stubGlobal('location', { ...window.location, assign: assignSpy }); + + render(); + const button = await screen.findByTestId('oauth-button-github'); + fireEvent.click(button); + + await waitFor(() => expect(assignSpy).toHaveBeenCalledWith('https://github.example/authorize')); + expect(authService.startOAuthLogin).toHaveBeenCalledWith('github'); + + vi.unstubAllGlobals(); + }); + + it('shows an error and re-enables the button if starting the flow fails', async () => { + vi.mocked(authService.getOAuthProviders).mockResolvedValue({ providers: ['google'] }); + vi.mocked(authService.startOAuthLogin).mockRejectedValue(new Error('provider unavailable')); + + render(); + const button = await screen.findByTestId('oauth-button-google'); + fireEvent.click(button); + + expect(await screen.findByRole('alert')).toHaveTextContent('provider unavailable'); + expect(button).not.toBeDisabled(); + }); +}); diff --git a/apps/frontend/src/features/auth/components/OAuthButtons.tsx b/apps/frontend/src/features/auth/components/OAuthButtons.tsx new file mode 100644 index 00000000..d4fbbabb --- /dev/null +++ b/apps/frontend/src/features/auth/components/OAuthButtons.tsx @@ -0,0 +1,93 @@ +import { useState } from 'react'; +import { Github, Loader2 } from 'lucide-react'; +import { authService, getErrorMessage } from '@/shared/services/api'; +import type { OAuthProvider } from '@/shared/services/api/types'; +import { useOAuthProviders } from '../hooks/useOAuthProviders'; + +const PROVIDER_LABEL: Record = { + google: 'Google', + github: 'GitHub', +}; + +function GoogleGlyph() { + return ( + + ); +} + +/** "Continue with Google/GitHub" buttons for the Login page (#288, #374). + * + * Login-page only, deliberately not duplicated on Register: these buttons + * already cover every legitimate outcome an OAuth click can produce -- + * signing in to an already-linked account, the password-confirmed linking + * flow for one whose email matches, and (since #374) first-time signup + * itself when the provider's verified email is on the same admin-approved + * allowlist password registration checks. An unapproved email is rejected + * with a clear message either way; OAuth is never a looser door into the + * product than the password form is. + * + * Renders nothing (not even a placeholder) until the capability check + * resolves, and nothing at all if neither provider is configured -- password + * auth remains fully usable either way. */ +export function OAuthButtons() { + const { providers } = useOAuthProviders(); + const [pending, setPending] = useState(null); + const [error, setError] = useState(null); + + if (providers.length === 0) return null; + + const start = async (provider: OAuthProvider) => { + if (pending) return; + setPending(provider); + setError(null); + try { + const { authorizeUrl } = await authService.startOAuthLogin(provider); + window.location.assign(authorizeUrl); + // Intentionally no `finally` clearing `pending`: the page is + // navigating away, and leaving the button disabled avoids a flash of + // it becoming clickable again during that navigation. + } catch (caught) { + setError(getErrorMessage(caught)); + setPending(null); + } + }; + + return ( +
    + {providers.map((provider) => ( + + ))} + {error && ( +

    + {error} +

    + )} +
    + + or + +
    +
    + ); +} diff --git a/apps/frontend/src/features/auth/hooks/useLoginForm.ts b/apps/frontend/src/features/auth/hooks/useLoginForm.ts new file mode 100644 index 00000000..a9e51dcb --- /dev/null +++ b/apps/frontend/src/features/auth/hooks/useLoginForm.ts @@ -0,0 +1,35 @@ +import { useState, type FormEvent } from 'react'; +import { useLocation, useNavigate } from 'react-router-dom'; +import { useAuthStore } from '@/app/store/useAuthStore'; +import { getErrorMessage } from '@/shared/services/api'; +import { resolveRedirectTarget } from '../authRedirect'; + +export function useLoginForm() { + const login = useAuthStore((state) => state.login); + const navigate = useNavigate(); + const location = useLocation(); + + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + const submit = async (event: FormEvent) => { + event.preventDefault(); + if (submitting) return; + setSubmitting(true); + setError(null); + try { + await login(email.trim(), password); + navigate(resolveRedirectTarget(location.state), { replace: true }); + } catch (caught) { + setError(getErrorMessage(caught)); + } finally { + setSubmitting(false); + } + }; + + // Forwarded to the "create one"/"sign in" link so bouncing between login + // and register never loses the originally-intended destination. + return { email, setEmail, password, setPassword, submitting, error, submit, redirectState: location.state }; +} diff --git a/apps/frontend/src/features/auth/hooks/useOAuthProviders.test.ts b/apps/frontend/src/features/auth/hooks/useOAuthProviders.test.ts new file mode 100644 index 00000000..c3de4c27 --- /dev/null +++ b/apps/frontend/src/features/auth/hooks/useOAuthProviders.test.ts @@ -0,0 +1,42 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useOAuthProviders } from './useOAuthProviders'; +import { authService } from '@/shared/services/api'; + +vi.mock('@/shared/services/api', () => ({ + authService: { getOAuthProviders: vi.fn() }, +})); + +beforeEach(() => { + vi.mocked(authService.getOAuthProviders).mockReset(); +}); + +describe('useOAuthProviders', () => { + it('starts empty and reports the configured providers once the check resolves', async () => { + vi.mocked(authService.getOAuthProviders).mockResolvedValue({ providers: ['google', 'github'] }); + + const { result } = renderHook(() => useOAuthProviders()); + + expect(result.current.providers).toEqual([]); + await waitFor(() => expect(result.current.loaded).toBe(true)); + expect(result.current.providers).toEqual(['google', 'github']); + }); + + it('stays empty, without throwing, if the capability check fails', async () => { + vi.mocked(authService.getOAuthProviders).mockRejectedValue(new Error('network down')); + + const { result } = renderHook(() => useOAuthProviders()); + + await waitFor(() => expect(result.current.loaded).toBe(true)); + expect(result.current.providers).toEqual([]); + }); + + it('reports no providers when the backend has none configured', async () => { + vi.mocked(authService.getOAuthProviders).mockResolvedValue({ providers: [] }); + + const { result } = renderHook(() => useOAuthProviders()); + + await waitFor(() => expect(result.current.loaded).toBe(true)); + expect(result.current.providers).toEqual([]); + }); +}); diff --git a/apps/frontend/src/features/auth/hooks/useOAuthProviders.ts b/apps/frontend/src/features/auth/hooks/useOAuthProviders.ts new file mode 100644 index 00000000..0d673ff5 --- /dev/null +++ b/apps/frontend/src/features/auth/hooks/useOAuthProviders.ts @@ -0,0 +1,34 @@ +import { useEffect, useState } from 'react'; +import { authService } from '@/shared/services/api'; +import type { OAuthProvider } from '@/shared/services/api/types'; + +/** Which OAuth providers the backend actually has real credentials + * configured for (#288) -- the same capability-gating idea as the AI + * provider setup metadata: never render a button for a provider that isn't + * live. Starts empty (no flash of buttons that then disappear) and stays + * empty, silently, if the check itself fails -- a broken capability check + * should degrade to "no OAuth buttons," never block password sign-in. */ +export function useOAuthProviders() { + const [providers, setProviders] = useState([]); + const [loaded, setLoaded] = useState(false); + + useEffect(() => { + let cancelled = false; + authService + .getOAuthProviders() + .then((response) => { + if (!cancelled) setProviders(response.providers as OAuthProvider[]); + }) + .catch(() => { + // Deliberately silent -- see the module doc above. + }) + .finally(() => { + if (!cancelled) setLoaded(true); + }); + return () => { + cancelled = true; + }; + }, []); + + return { providers, loaded }; +} diff --git a/apps/frontend/src/features/auth/hooks/useRegisterForm.ts b/apps/frontend/src/features/auth/hooks/useRegisterForm.ts new file mode 100644 index 00000000..4ddcd33e --- /dev/null +++ b/apps/frontend/src/features/auth/hooks/useRegisterForm.ts @@ -0,0 +1,53 @@ +import { useState, type FormEvent } from 'react'; +import { useLocation, useNavigate } from 'react-router-dom'; +import { useAuthStore } from '@/app/store/useAuthStore'; +import { getErrorMessage } from '@/shared/services/api'; +import { resolveRedirectTarget } from '../authRedirect'; + +// Mirrors PASSWORD_MIN_LENGTH in apps/backend/app/schemas/auth.py — checked +// client-side too so a too-short password fails instantly, not after a +// round trip to the backend. +export const PASSWORD_MIN_LENGTH = 10; + +export function useRegisterForm() { + const register = useAuthStore((state) => state.register); + const navigate = useNavigate(); + const location = useLocation(); + + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + const submit = async (event: FormEvent) => { + event.preventDefault(); + if (submitting) return; + if (password.length < PASSWORD_MIN_LENGTH) { + setError(`Password must be at least ${PASSWORD_MIN_LENGTH} characters.`); + return; + } + setSubmitting(true); + setError(null); + try { + await register(email.trim(), password); + navigate(resolveRedirectTarget(location.state), { replace: true }); + } catch (caught) { + setError(getErrorMessage(caught)); + } finally { + setSubmitting(false); + } + }; + + // Forwarded to the "sign in" link so bouncing between register and login + // never loses the originally-intended destination. + return { + email, + setEmail, + password, + setPassword, + submitting, + error, + submit, + redirectState: location.state, + }; +} diff --git a/apps/frontend/src/features/dependencies/hooks/useDependencies.test.ts b/apps/frontend/src/features/dependencies/hooks/useDependencies.test.ts new file mode 100644 index 00000000..e1afffbd --- /dev/null +++ b/apps/frontend/src/features/dependencies/hooks/useDependencies.test.ts @@ -0,0 +1,143 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { backendService } from '@/shared/services/backend'; +import { ApiError } from '@/shared/services/api'; +import type { Repository } from '@/shared/types'; +import type { DependencyGraphResponse } from '@/shared/services/api/types'; +import { useDependencies } from './useDependencies'; + +const repositoryState = vi.hoisted(() => ({ + activeRepository: null as Repository | null, + completedRepositories: [] as Repository[], +})); + +vi.mock('@/features/repositories/hooks/useRepository', () => ({ + useRepository: () => ({ + activeRepository: repositoryState.activeRepository, + completedRepositories: repositoryState.completedRepositories, + }), +})); + +function repository(overrides: Partial = {}): Repository { + return { + id: 'repo-1', + name: 'sample', + source: 'upload', + size: 100, + fileCount: 2, + status: 'completed', + analysisStage: 'completed', + analysisProgress: 100, + uploadedAt: '2026-07-15T00:00:00Z', + meta: { packageManager: 'npm' } as Repository['meta'], + fileTree: [], + ...overrides, + }; +} + +const graph: DependencyGraphResponse = { + schemaVersion: 'dependency-graph.v2', + repositoryId: 'repo-1', + repositoryName: 'sample', + revisionKind: 'upload', + revisionValue: `sha256:${'0'.repeat(64)}`, + snapshotId: 'snap_example', + snapshotSchemaVersion: 'ri.v1', + canonicalGraphHash: `sha256:${'1'.repeat(64)}`, + manifestDigest: `sha256:${'2'.repeat(64)}`, + provenance: { + source: 'ri.v1', + snapshotId: 'snap_example', + snapshotSchemaVersion: 'ri.v1', + canonicalGraphHash: `sha256:${'1'.repeat(64)}`, + }, + generatedAt: '2026-07-25T00:00:00Z', + nodes: [], + edges: [], + totalDependencies: 0, + manifestCount: 0, + diagnostics: [], + vulnerabilityAssessment: { status: 'not_computed' }, + outdatedAssessment: { status: 'not_computed' }, +}; + +describe('useDependencies', () => { + beforeEach(() => { + repositoryState.activeRepository = null; + repositoryState.completedRepositories = []; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('loads the sealed snapshot graph for a completed repository', async () => { + repositoryState.activeRepository = repository(); + repositoryState.completedRepositories = [repository()]; + vi.spyOn(backendService, 'fetchDependencyGraph').mockResolvedValue(graph); + + const { result } = renderHook(() => useDependencies()); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.graph).toEqual(graph); + expect(result.current.noSnapshot).toBe(false); + expect(result.current.error).toBeNull(); + }); + + it('distinguishes a 404 no-sealed-snapshot response from a generic error', async () => { + repositoryState.activeRepository = repository(); + repositoryState.completedRepositories = [repository()]; + vi.spyOn(backendService, 'fetchDependencyGraph').mockRejectedValue( + new ApiError(404, 'Not Found', { message: 'No sealed Repository Intelligence snapshot is available.' }, '/analysis/repo-1/dependencies'), + ); + + const { result } = renderHook(() => useDependencies()); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.noSnapshot).toBe(true); + expect(result.current.graph).toBeNull(); + expect(result.current.error).toBeNull(); + }); + + it('surfaces a non-404 failure as a normal error, not a no-snapshot state', async () => { + repositoryState.activeRepository = repository(); + repositoryState.completedRepositories = [repository()]; + vi.spyOn(backendService, 'fetchDependencyGraph').mockRejectedValue( + new ApiError(500, 'Internal Server Error', null, '/analysis/repo-1/dependencies'), + ); + + const { result } = renderHook(() => useDependencies()); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.noSnapshot).toBe(false); + expect(result.current.graph).toBeNull(); + expect(result.current.error).not.toBeNull(); + }); + + it('reports the detected package manager from repository metadata', async () => { + repositoryState.activeRepository = repository({ meta: { packageManager: 'pnpm' } as Repository['meta'] }); + repositoryState.completedRepositories = [repository()]; + vi.spyOn(backendService, 'fetchDependencyGraph').mockResolvedValue(graph); + + const { result } = renderHook(() => useDependencies()); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.packageManager).toBe('pnpm'); + }); + + it('does not fetch when there is no completed active repository', () => { + repositoryState.activeRepository = null; + repositoryState.completedRepositories = []; + const fetchSpy = vi.spyOn(backendService, 'fetchDependencyGraph'); + + const { result } = renderHook(() => useDependencies()); + + expect(fetchSpy).not.toHaveBeenCalled(); + expect(result.current.graph).toBeNull(); + expect(result.current.noSnapshot).toBe(false); + }); +}); diff --git a/apps/frontend/src/features/dependencies/hooks/useDependencies.ts b/apps/frontend/src/features/dependencies/hooks/useDependencies.ts index 25707f3d..1413d22b 100644 --- a/apps/frontend/src/features/dependencies/hooks/useDependencies.ts +++ b/apps/frontend/src/features/dependencies/hooks/useDependencies.ts @@ -1,7 +1,7 @@ import { useCallback, useEffect, useState } from 'react'; import type { DependencyGraphResponse } from '@/shared/services/api/types'; import { backendService } from '@/shared/services/backend'; -import { getErrorMessage } from '@/shared/services/api'; +import { getErrorMessage, isApiError } from '@/shared/services/api'; import { useRepositoryFeatureStatus } from '@/shared/feature-state/useRepositoryFeatureStatus'; export function useDependencies() { @@ -9,6 +9,10 @@ export function useDependencies() { const [graph, setGraph] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + // A repository can be "completed" (analysed) yet still have no sealed ri.v1 + // snapshot yet, the same 404 Architecture/Review/Insights already surface. + // That must never be mistaken for a real, empty dependency inventory. + const [noSnapshot, setNoSnapshot] = useState(false); const [refreshKey, setRefreshKey] = useState(0); const refresh = useCallback(() => { @@ -20,6 +24,7 @@ export function useDependencies() { if (!state.activeRepository || state.activeRepository.status !== 'completed') { setGraph(null); setError(null); + setNoSnapshot(false); return; } @@ -28,11 +33,18 @@ export function useDependencies() { if (!state.activeRepository) return; setLoading(true); setError(null); + setNoSnapshot(false); try { const nextGraph = await backendService.fetchDependencyGraph(state.activeRepository.id); if (!cancelled) setGraph(nextGraph); } catch (caught) { - if (!cancelled) setError(getErrorMessage(caught)); + if (cancelled) return; + setGraph(null); + if (isApiError(caught) && caught.isNotFound) { + setNoSnapshot(true); + } else { + setError(getErrorMessage(caught)); + } } finally { if (!cancelled) setLoading(false); } @@ -46,10 +58,11 @@ export function useDependencies() { return { ...state, graph, + noSnapshot, loading: state.loading || loading, error: state.error || error, retry: refresh, refresh, - packageManager: state.activeRepository?.meta?.packageManager || 'npm', + packageManager: state.activeRepository?.meta?.packageManager ?? null, }; } diff --git a/apps/frontend/src/features/documentation/hooks/useDocumentation.ts b/apps/frontend/src/features/documentation/hooks/useDocumentation.ts index 0c211ca8..4ffd2f8e 100644 --- a/apps/frontend/src/features/documentation/hooks/useDocumentation.ts +++ b/apps/frontend/src/features/documentation/hooks/useDocumentation.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, useState } from 'react'; -import { documentationService, getErrorMessage } from '@/shared/services/api'; +import { documentationService, getErrorMessage, isApiError } from '@/shared/services/api'; import type { GenerateDocResponse } from '@/shared/services/api/types'; import { useRepositoryFeatureStatus } from '@/shared/feature-state/useRepositoryFeatureStatus'; @@ -18,6 +18,10 @@ export function useDocumentation() { const [format, setFormat] = useState<'markdown' | 'html'>('markdown'); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + // A repository can be "completed" (analysed) yet still have no sealed ri.v1 + // snapshot yet, the same 404 Dependencies/Architecture already surface + // (#178). That must never collapse into an unguided generic error message. + const [noSnapshot, setNoSnapshot] = useState(false); const [refreshKey, setRefreshKey] = useState(0); const refresh = useCallback(() => { @@ -37,6 +41,7 @@ export function useDocumentation() { if (!state.activeRepository || state.activeRepository.status !== 'completed') { setDocument(null); setError(null); + setNoSnapshot(false); return; } @@ -45,6 +50,7 @@ export function useDocumentation() { if (!state.activeRepository) return; setLoading(true); setError(null); + setNoSnapshot(false); try { const nextDocument = await documentationService.generate({ repositoryId: state.activeRepository.id, @@ -53,7 +59,13 @@ export function useDocumentation() { }); if (!cancelled) setDocument(nextDocument); } catch (caught) { - if (!cancelled) setError(getErrorMessage(caught)); + if (!cancelled) { + if (isApiError(caught) && caught.isNotFound) { + setNoSnapshot(true); + } else { + setError(getErrorMessage(caught)); + } + } } finally { if (!cancelled) setLoading(false); } @@ -73,6 +85,7 @@ export function useDocumentation() { toggleSection, loading: state.loading || loading, error: state.error || error, + noSnapshot, retry: refresh, refresh, }; diff --git a/apps/frontend/src/features/explorer/components/CodePreview.tsx b/apps/frontend/src/features/explorer/components/CodePreview.tsx index 9b8eac03..94612549 100644 --- a/apps/frontend/src/features/explorer/components/CodePreview.tsx +++ b/apps/frontend/src/features/explorer/components/CodePreview.tsx @@ -1,17 +1,21 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import Editor, { type OnMount } from '@monaco-editor/react'; -import { Copy, Check, FileWarning, Loader2 } from 'lucide-react'; +import { Copy, Check, FileWarning, Loader2, ShieldAlert, Target } from 'lucide-react'; import type { FileTreeNode } from '@/shared/types'; import { repositoryService } from '@/shared/services/api/repositories'; +import { architectureService } from '@/shared/services/api/architecture'; import { getErrorMessage } from '@/shared/services/api'; import { getMonacoLanguage } from '../fileUtils'; +import type { ExplorerCitation } from './RepositoryExplorer'; interface CodePreviewProps { node: FileTreeNode; repositoryId: string; + /** When set, the exact cited line span is fetched, verified, and highlighted. */ + citation?: ExplorerCitation | null; } -export function CodePreview({ node, repositoryId }: CodePreviewProps) { +export function CodePreview({ node, repositoryId, citation }: CodePreviewProps) { const [copied, setCopied] = useState(false); const [content, setContent] = useState(''); const [loading, setLoading] = useState(true); @@ -21,7 +25,17 @@ export function CodePreview({ node, repositoryId }: CodePreviewProps) { const [mediaType, setMediaType] = useState(null); const [truncated, setTruncated] = useState(false); const [copyError, setCopyError] = useState(null); - const editorRef = useRef[0] | null>(null); + // Set only once the cited snapshot/revision has actually been verified by + // the backend -- never optimistically from the (unverified) URL params. + const [verifiedRevision, setVerifiedRevision] = useState<{ kind: string | null; value: string | null } | null>( + null, + ); + const [unavailableReason, setUnavailableReason] = useState(null); + type Editor = Parameters[0]; + type Monaco = Parameters[1]; + const editorRef = useRef(null); + const monacoRef = useRef(null); + const decorationsRef = useRef | null>(null); const copyResetTimerRef = useRef(null); const language = getMonacoLanguage(node.extension); @@ -45,6 +59,47 @@ export function CodePreview({ node, repositoryId }: CodePreviewProps) { setIsImage(false); setMediaType(null); setTruncated(false); + setVerifiedRevision(null); + setUnavailableReason(null); + + if (citation) { + // Snapshot-and-revision-verified fetch: the backend proves the + // returned content genuinely belongs to the cited snapshot's exact + // immutable revision before returning it. It never falls back to + // `getFile` -- an unprovable citation reports `status: "unavailable"` + // with no content, rather than silently showing different content + // under a trusted-looking snapshot badge. + architectureService + .getEvidenceSource( + repositoryId, + citation.snapshotId, + citation.factId, + citation.path, + citation.startLine, + citation.endLine, + ) + .then((source) => { + if (cancelled) return; + if (source.status === 'unavailable') { + setUnavailableReason(source.reason ?? 'Evidence source unavailable for this snapshot revision.'); + return; + } + setContent(source.content ?? ''); + setTruncated(source.truncated); + setVerifiedRevision({ kind: source.revisionKind, value: source.revisionValue }); + }) + .catch((caught) => { + if (cancelled) return; + setError(getErrorMessage(caught)); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + + return () => { + cancelled = true; + }; + } repositoryService .getFile(repositoryId, node.path) @@ -53,7 +108,7 @@ export function CodePreview({ node, repositoryId }: CodePreviewProps) { setContent(file.content); setIsBinary(file.isBinary); setIsImage(file.isImage); - setMediaType(file.mediaType); + setMediaType(file.mediaType ?? null); setTruncated(file.truncated); }) .catch((caught) => { @@ -67,14 +122,37 @@ export function CodePreview({ node, repositoryId }: CodePreviewProps) { return () => { cancelled = true; }; - }, [clearCopyResetTimer, repositoryId, node.path]); + }, [clearCopyResetTimer, repositoryId, node.path, citation]); useEffect(() => clearCopyResetTimer, [clearCopyResetTimer]); - const handleMount: OnMount = (editor) => { + const handleMount: OnMount = (editor, monaco) => { editorRef.current = editor; + monacoRef.current = monaco; }; + useEffect(() => { + const editor = editorRef.current; + const monaco = monacoRef.current; + if (!editor || !monaco || loading) return; + decorationsRef.current?.clear(); + // Lines are highlighted only once verification has actually succeeded -- + // never while loading, and never for an unavailable/mismatched citation. + if (!citation || unavailableReason !== null || error !== null) return; + const { startLine: start, endLine: end } = citation; + decorationsRef.current = editor.createDecorationsCollection([ + { + range: new monaco.Range(start, 1, end, 1), + options: { + isWholeLine: true, + className: 'citation-highlight-line', + linesDecorationsClassName: 'citation-highlight-margin', + }, + }, + ]); + editor.revealLineInCenter(start); + }, [citation, loading, unavailableReason, error, content]); + const handleCopy = useCallback(async () => { clearCopyResetTimer(); setCopyError(null); @@ -92,7 +170,7 @@ export function CodePreview({ node, repositoryId }: CodePreviewProps) { } }, [clearCopyResetTimer, content]); - const showCopy = !loading && !error && !isBinary && !isImage; + const showCopy = !loading && !error && !unavailableReason && !isBinary && !isImage; return (
    @@ -103,6 +181,14 @@ export function CodePreview({ node, repositoryId }: CodePreviewProps) { {truncated && !isImage && ( truncated )} + {citation && verifiedRevision && !unavailableReason && !loading && !error && ( + + + Cited lines {citation.startLine} + {citation.endLine !== citation.startLine ? `-${citation.endLine}` : ''} · snapshot{' '} + {citation.snapshotId} · revision {verifiedRevision.kind}:{verifiedRevision.value} + + )}
    {showCopy && (
    @@ -121,7 +207,13 @@ export function CodePreview({ node, repositoryId }: CodePreviewProps) { {loading ? (
    - Loading file... + {citation ? 'Verifying evidence...' : 'Loading file...'} +
    + ) : unavailableReason ? ( +
    + +

    Evidence source unavailable for this snapshot revision.

    +

    {unavailableReason}

    ) : error ? (
    @@ -153,7 +245,7 @@ export function CodePreview({ node, repositoryId }: CodePreviewProps) { height="100%" language={language} value={content} - theme="vs-dark" + theme="vs" onMount={handleMount} options={{ readOnly: true, diff --git a/apps/frontend/src/features/explorer/components/FileTreeView.tsx b/apps/frontend/src/features/explorer/components/FileTreeView.tsx index 34407a98..4ecf4c9f 100644 --- a/apps/frontend/src/features/explorer/components/FileTreeView.tsx +++ b/apps/frontend/src/features/explorer/components/FileTreeView.tsx @@ -151,7 +151,7 @@ function TreeItem({ node, depth, searchQuery }: TreeItemProps) { )}> {node.name} - {node.size !== undefined && node.type === 'file' && ( + {node.size != null && node.type === 'file' && ( {formatSize(node.size)} diff --git a/apps/frontend/src/features/explorer/components/RepositoryExplorer.test.tsx b/apps/frontend/src/features/explorer/components/RepositoryExplorer.test.tsx new file mode 100644 index 00000000..044af9c3 --- /dev/null +++ b/apps/frontend/src/features/explorer/components/RepositoryExplorer.test.tsx @@ -0,0 +1,191 @@ +import { render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { FileTreeNode } from '@/shared/types'; +import { repositoryService } from '@/shared/services/api/repositories'; +import { architectureService } from '@/shared/services/api/architecture'; +import { useExplorerStore } from '../store'; +import { RepositoryExplorer } from './RepositoryExplorer'; + +vi.mock('@/shared/services/api/repositories', () => ({ + repositoryService: { + getFile: vi.fn(), + }, +})); + +vi.mock('@/shared/services/api/architecture', () => ({ + architectureService: { + getEvidenceSource: vi.fn(), + }, +})); + +vi.mock('@monaco-editor/react', () => ({ + default: ({ value }: { value: string }) =>
    {value}
    , +})); + +const mockedGetFile = vi.mocked(repositoryService.getFile); +const mockedGetEvidenceSource = vi.mocked(architectureService.getEvidenceSource); + +const FILE_TREE: FileTreeNode[] = [ + { + id: 'src', + name: 'src', + type: 'folder', + path: '/src', + children: [ + { + id: 'src/dependencies.py', + name: 'dependencies.py', + type: 'file', + path: '/src/dependencies.py', + extension: 'py', + }, + ], + }, +]; + +describe('RepositoryExplorer citation deep-link', () => { + beforeEach(() => { + vi.clearAllMocks(); + useExplorerStore.setState({ + expandedFolders: new Set(), + selectedFileId: null, + selectedNode: null, + detailsTab: 'details', + }); + mockedGetFile.mockResolvedValue({ + path: 'src/dependencies.py', + content: 'def get_current_user(token):\n return token\n', + size: 47, + isBinary: false, + isImage: false, + mediaType: null, + truncated: false, + }); + mockedGetEvidenceSource.mockResolvedValue({ + schemaVersion: 'evidence-source.v1', + repositoryId: 'repo-1', + snapshotId: 'snap_1', + factId: 'fact_1', + revisionKind: 'upload', + revisionValue: 'sha256:' + '0'.repeat(64), + path: 'src/dependencies.py', + startLine: 6, + endLine: 7, + status: 'ready', + reason: null, + content: 'def get_current_user(token):\n return token\n', + truncated: false, + size: 47, + }); + }); + + it('opens the exact cited file, verifies it, and highlights the line span', async () => { + render( + , + ); + + expect(await screen.findByTestId('editor-stub')).toBeInTheDocument(); + expect(screen.getByText(/Cited lines 6-7/)).toBeInTheDocument(); + expect(screen.getByText(/snapshot snap_1/)).toBeInTheDocument(); + expect(screen.getByText(/revision upload:sha256:0{64}/)).toBeInTheDocument(); + expect(mockedGetEvidenceSource).toHaveBeenCalledWith( + 'repo-1', + 'snap_1', + 'fact_1', + 'src/dependencies.py', + 6, + 7, + ); + // Never the unverified, unversioned file endpoint for a citation. + expect(mockedGetFile).not.toHaveBeenCalled(); + }); + + it('shows an honest unavailable state instead of falling back to unverified content', async () => { + mockedGetEvidenceSource.mockResolvedValue({ + schemaVersion: 'evidence-source.v1', + repositoryId: 'repo-1', + snapshotId: 'snap_1', + factId: 'fact_1', + revisionKind: 'upload', + revisionValue: 'sha256:' + '0'.repeat(64), + path: 'src/dependencies.py', + startLine: 6, + endLine: 7, + status: 'unavailable', + reason: 'The cited line span is outside the available source content.', + content: null, + truncated: false, + size: 0, + }); + + render( + , + ); + + expect(await screen.findByText('Evidence source unavailable for this snapshot revision.')).toBeInTheDocument(); + expect(screen.getByText('The cited line span is outside the available source content.')).toBeInTheDocument(); + expect(screen.queryByTestId('editor-stub')).not.toBeInTheDocument(); + expect(screen.queryByText(/Cited lines/)).not.toBeInTheDocument(); + }); + + it('shows no citation banner when there is no deep-link and uses the plain file endpoint', async () => { + render(); + + expect(screen.getByText('Select a file from the explorer')).toBeInTheDocument(); + expect(screen.queryByText(/Cited lines/)).not.toBeInTheDocument(); + expect(mockedGetEvidenceSource).not.toHaveBeenCalled(); + }); + + it('opens a file selected by global search without treating it as an evidence citation', async () => { + render( + , + ); + + expect(await screen.findByTestId('editor-stub')).toHaveTextContent('def get_current_user'); + expect(mockedGetFile).toHaveBeenCalledWith('repo-1', '/src/dependencies.py'); + expect(mockedGetEvidenceSource).not.toHaveBeenCalled(); + }); + + it('fails safely when a global-search file path is stale', () => { + useExplorerStore.setState({ + selectedFileId: 'old-file', + selectedNode: FILE_TREE[0].children![0], + }); + + render( + , + ); + + expect(screen.getByText('Select a file from the explorer')).toBeInTheDocument(); + expect(mockedGetFile).not.toHaveBeenCalled(); + expect(mockedGetEvidenceSource).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/frontend/src/features/explorer/components/RepositoryExplorer.tsx b/apps/frontend/src/features/explorer/components/RepositoryExplorer.tsx index e6a39a6d..3828fc72 100644 --- a/apps/frontend/src/features/explorer/components/RepositoryExplorer.tsx +++ b/apps/frontend/src/features/explorer/components/RepositoryExplorer.tsx @@ -4,21 +4,29 @@ import { cn } from '@/shared/utils/cn'; import type { FileTreeNode } from '@/shared/types'; import { useExplorerStore } from '../store'; import { useResizable } from '../useResizable'; -import { deriveFileDetails } from '../fileUtils'; +import { deriveFileDetails, flattenTree, type ExplorerCitation } from '../fileUtils'; import { FileTreeView } from './FileTreeView'; import { ExplorerToolbar } from './ExplorerToolbar'; import { FileDetailsPanel } from './FileDetailsPanel'; import { CodePreview } from './CodePreview'; import { Breadcrumbs } from './Breadcrumbs'; +export type { ExplorerCitation } from '../fileUtils'; + +const normalizedPath = (path: string) => path.replace(/^\/+/, ''); + interface RepositoryExplorerProps { fileTree: FileTreeNode[]; repositoryId: string; + /** File path selected outside Explorer, such as an authenticated global-search result. */ + initialPath?: string | null; + /** Deep-link from an evidence citation: opens and highlights an exact span. */ + citation?: ExplorerCitation | null; } -export function RepositoryExplorer({ fileTree, repositoryId }: RepositoryExplorerProps) { +export function RepositoryExplorer({ fileTree, repositoryId, initialPath, citation }: RepositoryExplorerProps) { const { - selectedNode, detailsTab, setDetailsTab, expandedFolders, expandFolder, + selectedNode, selectFile, clearSelection, detailsTab, setDetailsTab, expandedFolders, expandFolder, } = useExplorerStore(); const { width: explorerWidth, onMouseDown } = useResizable({ @@ -37,6 +45,29 @@ export function RepositoryExplorer({ fileTree, repositoryId }: RepositoryExplore } }, [expandedFolders.size, expandFolder, rootFolders]); + useEffect(() => { + const requestedPath = citation?.path ?? initialPath; + if (!requestedPath) return; + const match = flattenTree(fileTree).find( + (node) => node.type === 'file' && normalizedPath(node.path) === normalizedPath(requestedPath), + ); + if (!match) { + clearSelection(); + return; + } + selectFile(match); + setDetailsTab('preview'); + const segments = match.path.split('/').slice(0, -1); + let prefix = ''; + for (const segment of segments) { + prefix = prefix ? `${prefix}/${segment}` : segment; + const folder = flattenTree(fileTree).find( + (node) => node.type === 'folder' && normalizedPath(node.path) === normalizedPath(prefix), + ); + if (folder) expandFolder(folder.id); + } + }, [citation, initialPath, fileTree, selectFile, clearSelection, setDetailsTab, expandFolder]); + const fileDetails = useMemo(() => { if (!selectedNode || selectedNode.type === 'folder') return null; return deriveFileDetails(selectedNode, fileTree); @@ -110,7 +141,15 @@ export function RepositoryExplorer({ fileTree, repositoryId }: RepositoryExplore

    ) : detailsTab === 'preview' ? ( - + ) : fileDetails ? ( ) : null} diff --git a/apps/frontend/src/features/explorer/fileUtils.test.ts b/apps/frontend/src/features/explorer/fileUtils.test.ts new file mode 100644 index 00000000..11d4766f --- /dev/null +++ b/apps/frontend/src/features/explorer/fileUtils.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from 'vitest'; +import { parseEvidenceCitation } from './fileUtils'; + +function params(entries: Record): URLSearchParams { + return new URLSearchParams({ factId: 'fact_1', ...entries }); +} + +describe('parseEvidenceCitation', () => { + it('parses a fully valid citation', () => { + expect( + parseEvidenceCitation( + params({ path: 'src/routes.py', startLine: '6', endLine: '7', snapshotId: 'snap_1' }), + ), + ).toEqual({ path: 'src/routes.py', startLine: 6, endLine: 7, snapshotId: 'snap_1', factId: 'fact_1' }); + }); + + it('allows a single-line span (start === end)', () => { + const result = parseEvidenceCitation( + params({ path: 'src/routes.py', startLine: '10', endLine: '10', snapshotId: 'snap_1' }), + ); + expect(result).toEqual({ + path: 'src/routes.py', + startLine: 10, + endLine: 10, + snapshotId: 'snap_1', + factId: 'fact_1', + }); + }); + + it('returns null when a required param is missing', () => { + expect(parseEvidenceCitation(params({ startLine: '1', endLine: '1', snapshotId: 'snap_1' }))).toBeNull(); + expect(parseEvidenceCitation(params({ path: 'a.py', endLine: '1', snapshotId: 'snap_1' }))).toBeNull(); + expect(parseEvidenceCitation(params({ path: 'a.py', startLine: '1', snapshotId: 'snap_1' }))).toBeNull(); + expect(parseEvidenceCitation(params({ path: 'a.py', startLine: '1', endLine: '1' }))).toBeNull(); + const missingFactId = params({ path: 'a.py', startLine: '1', endLine: '1', snapshotId: 'snap_1' }); + missingFactId.delete('factId'); + expect(parseEvidenceCitation(missingFactId)).toBeNull(); + }); + + it('returns null for non-integer line numbers instead of NaN', () => { + const malformed = parseEvidenceCitation( + params({ path: 'a.py', startLine: 'not-a-number', endLine: '1', snapshotId: 'snap_1' }), + ); + expect(malformed).toBeNull(); + }); + + it('returns null for a decimal line number', () => { + expect( + parseEvidenceCitation(params({ path: 'a.py', startLine: '1.5', endLine: '2', snapshotId: 'snap_1' })), + ).toBeNull(); + }); + + it('returns null for zero or negative line numbers', () => { + expect( + parseEvidenceCitation(params({ path: 'a.py', startLine: '0', endLine: '1', snapshotId: 'snap_1' })), + ).toBeNull(); + expect( + parseEvidenceCitation(params({ path: 'a.py', startLine: '-1', endLine: '1', snapshotId: 'snap_1' })), + ).toBeNull(); + }); + + it('returns null when endLine is before startLine', () => { + expect( + parseEvidenceCitation(params({ path: 'a.py', startLine: '10', endLine: '2', snapshotId: 'snap_1' })), + ).toBeNull(); + }); + + it('returns null for an empty snapshotId or path', () => { + expect( + parseEvidenceCitation(params({ path: '', startLine: '1', endLine: '1', snapshotId: 'snap_1' })), + ).toBeNull(); + expect( + parseEvidenceCitation(params({ path: 'a.py', startLine: '1', endLine: '1', snapshotId: '' })), + ).toBeNull(); + expect( + parseEvidenceCitation( + params({ path: 'a.py', startLine: '1', endLine: '1', snapshotId: 'snap_1', factId: '' }), + ), + ).toBeNull(); + }); + + it('returns null for injected/oversized garbage instead of throwing', () => { + expect( + parseEvidenceCitation( + params({ path: 'a.py', startLine: '1e300', endLine: '1', snapshotId: 'snap_1' }), + ), + ).toBeNull(); + expect(() => + parseEvidenceCitation(params({ path: 'a.py', startLine: ' + + +
    + + + diff --git a/apps/marketing/package-lock.json b/apps/marketing/package-lock.json new file mode 100644 index 00000000..980a8867 --- /dev/null +++ b/apps/marketing/package-lock.json @@ -0,0 +1,4155 @@ +{ + "name": "@partha/marketing", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@partha/marketing", + "version": "0.1.0", + "dependencies": { + "clsx": "^2.1.1", + "lucide-react": "^0.577.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "tailwind-merge": "^2.5.2", + "zustand": "^5.0.15" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@types/node": "^22.10.1", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^6.1.0", + "autoprefixer": "^10.5.4", + "eslint": "^10.8.1", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.4", + "globals": "^17.11.0", + "postcss": "^8.5.26", + "tailwindcss": "^3.4.10", + "typescript": "^5.5.4", + "typescript-eslint": "^8.67.0", + "vite": "^8.2.2" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.3.0.tgz", + "integrity": "sha512-U4+70Pc5ZS9osnCBCE5Jha/ciHM+Yp+CNMNC/7HvYbNRk1Ldd+f7qO65W5qfhu/TCv+/ozljlXXe9Nj8419DMA==", + "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.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "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.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "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/@oxc-project/types": { + "version": "0.147.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.147.0.tgz", + "integrity": "sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.6.tgz", + "integrity": "sha512-b+jTcARdTiFLI6jB4a5XjTm0RWd6KcRfQj/I2356fxUZemiho9zQLxo0RtCuMDAyKcLo6cEltkgbQp6d1+sjjQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.6.tgz", + "integrity": "sha512-lkWU8ZJaRk9q3CIEY1Tc7vIFALp3Xw5NfGJo2hQg5oIqNgxWi1zI+IiDEK3r70BF5Dzol1tcXsnzsRc8NLhG+Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.6.tgz", + "integrity": "sha512-dgR56NYnvAszm7Ob1B2/Vn0e8bUQYZH2UjVaMMtMVOCKFSfjhfLmuA/9+O+F+ajUdG6B/bSssrKW6JJYASa8jA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.6.tgz", + "integrity": "sha512-vpVxFvUCFioJqug7OTvqptkc4yb8UX0AwfDmJpaR/0sWz+BUmqSVAf7c8JkUgnN8YLspb4a/N6NhTyMAmdyQ7Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.6.tgz", + "integrity": "sha512-h1wG6Y6K3JlRswxsI64qQJqBAy4vrLuHgRbc8CZMGSWTOFRY6ghMApM1NKzB2I0n5xV1fjkE18SuVl2QpLeNpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.6.tgz", + "integrity": "sha512-tbCiqub0q2MVWJKgF5PoAlNWCtQydiOYSLIkd8sByqK/6MMYLJRcSXSYodqYtd0O+Fw7QaVmKKlS4oL94YRZ0w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.6.tgz", + "integrity": "sha512-oxK9+baEBPhZG5HB4URY+uU04zJWeZlH6Tb9rB5DK4DF9XR1uXNLXt5Q5ZsugTKayNCNLhkcwz/ye74hRI98dg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.6.tgz", + "integrity": "sha512-muWCk27FVBEZtv0MsK8gnfSmgczA8KQ0uRVJbTABKhkRfQc38aUrcb7fhi3BNiyseFmgcRsoMfQsSNJ+DbZdSw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.6.tgz", + "integrity": "sha512-eWDoSfU7Co2qj3vgB3Dt4lj1mG6CoWbcJQkRMP3XJplyCMtuaq3LHvPFjS9QIPvMGWVadJC04Xiy0IdcVPtnwQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.6.tgz", + "integrity": "sha512-2bWNjRSIayvupRKxXUY2tWG9fYdoUlTqWywHRvE8Eq3GvuQ+f2HeIkve697fIt+IQs/PV8yFsdWuhp1aJ1PdnA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.6.tgz", + "integrity": "sha512-KekI0gS0wLxe1UBSQSjenBVwou/JkcQPDzBPICGZjxUv9k3RteHDPBQaiOicZUFKRIH2wKEimGwVpnJsbPzu7w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.6.tgz", + "integrity": "sha512-TvtPnfVr+HtyGiDmPK4VWmlNm7QhNNAcK5Q9A7aOXsI8545yCyaoMaicXrFZ72JzeYjaUVk7yT243zT0jzjFKQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.6.tgz", + "integrity": "sha512-iOo0VEay2XFhaCcH0sps5XIimkSuOnNaZrf6+ZkoSOQBJPKNU48RkmJv0/lSpipexu5P+ouFgafe5IGr/DiQfg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.6.tgz", + "integrity": "sha512-y5NTmmasMS455JlOCO4ZM9krIchv3Mvm1crL1iUPGOPgEzSkves9n0SdC5Sjz6+qWDFhd8/JpfWMH8NSWNHe+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.6.tgz", + "integrity": "sha512-np8iZSLfXlAD4kWhiyq/u0Yt8oZDtRQ8lGhQaCXo2rl37KNjeU0GjJuwr4P3oeZ++ROfofsKNBqR5LTO8aXyWQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "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/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "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==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.69.0.tgz", + "integrity": "sha512-t5jQTKPIgVW1PE6dR6H6Qz5gm8zjMlX5/2gRaOGd9eO6V7J+tQc6iWKukEe7dY8u9HyYasQ0yfF0/FSSTEO2gA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.69.0", + "@typescript-eslint/type-utils": "8.69.0", + "@typescript-eslint/utils": "8.69.0", + "@typescript-eslint/visitor-keys": "8.69.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.69.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.8", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.8.tgz", + "integrity": "sha512-YYNsSlXBjMk92SKnkwvB5LOVSa6OznlFUGcsvrFgNJbJCd0M1XKeFVRc8ZByeCqz32FivYNHJVooLmdqrmvp/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.69.0.tgz", + "integrity": "sha512-l4b0DhWioGg6Gt2ebGlvfkFMOjRsauxtsnDRwUSRX1qHq3HdTfQHV8wW9zEXeciai6HfeaKOedQn2Zoofx3WBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.69.0", + "@typescript-eslint/types": "8.69.0", + "@typescript-eslint/typescript-estree": "8.69.0", + "@typescript-eslint/visitor-keys": "8.69.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.69.0.tgz", + "integrity": "sha512-yi4obFrHMmnsesWehHbkg9zMA7Jt8cXT+mKM08G999pH1yT6nqgsHx7MYm0uY1wAj8CqiBXYRJ7WAT0QdQHQXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.69.0", + "@typescript-eslint/types": "^8.69.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.69.0.tgz", + "integrity": "sha512-ewfspqWvSxKSOaplqAUNbaSFO0eB6w1EtQ+esfYFRm3614Ty4uNtExkcbgd6nWsXphbqKyf9ZYdbZdv2xEoWEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.69.0", + "@typescript-eslint/visitor-keys": "8.69.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.69.0.tgz", + "integrity": "sha512-xNqK7YTDZsLniQMV/4rpFR8Z5JlqeRvVjuG1YgF/mdPVH84HSD19L8CczMA0qg2RfwEV231GHH3VnToJDo4MfQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.69.0.tgz", + "integrity": "sha512-ZfoJAVg3JZndQEpEl9petVlxau3lRuElc4HRMuAlLCf8to04/iHz692RUSNmXKDjEuJmIL+KZ2/BsOcBc16dsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.69.0", + "@typescript-eslint/typescript-estree": "8.69.0", + "@typescript-eslint/utils": "8.69.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.69.0.tgz", + "integrity": "sha512-K3VrubUPhlo9VDBS6QdI8YB5j7ClpqLRdefcz6PFrhnwicehBweqQ9Evhl4l+FYz0HdDmMqIiSX0aldGRYtDCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.69.0.tgz", + "integrity": "sha512-AdFkgqck3Vudb/kWnxlyafU/4aBhHrbQ9locP2N4psXTy5mOBg0SHJumnLvx7r6g1gV4DKvUFwV2nJZBoqOD8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.69.0", + "@typescript-eslint/tsconfig-utils": "8.69.0", + "@typescript-eslint/types": "8.69.0", + "@typescript-eslint/visitor-keys": "8.69.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.69.0.tgz", + "integrity": "sha512-tUbx60BBqQa31kXF5MCsOOLL5E/WzUuxIn7YpAvq+eaUlqvk8/NXnXMBNAdLCr0icjkzem7iUA5QqWHe/hJ1aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.69.0", + "@typescript-eslint/types": "8.69.0", + "@typescript-eslint/typescript-estree": "8.69.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.69.0.tgz", + "integrity": "sha512-+rmdgPA+EXkNgKYvHvFfhrs35utXbwaC5PGpDquSXcoXQDKUA5UjV0LmTucG/4JXkM31BTu4TilHtrN8IVBe8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.69.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz", + "integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "oxc-transform-react": "^0.145.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "oxc-transform-react": { + "optional": true + } + } + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "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/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/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.20", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.20.tgz", + "integrity": "sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw==", + "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/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "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.8", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.12", + "caniuse-lite": "^1.0.30001809", + "electron-to-chromium": "^1.5.402", + "node-releases": "^2.0.53", + "update-browserslist-db": "^1.3.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/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.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", + "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/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/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/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "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/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/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "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.418", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.418.tgz", + "integrity": "sha512-UzS26r3AEbG5wSoGVpJKqwHIU9zwQN7LHdVIThDrJpS0I5KdlXFMEb8543fhc9dVnIIAST6ar8rhwa00AL5MlA==", + "dev": true, + "license": "ISC" + }, + "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.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "peer": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "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/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.9.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.9.1.tgz", + "integrity": "sha512-9VaAkDURekixUQJy0oJYl2DcN6oKMfxay7XzaGYAWQwsb6qfKf+x76R2k1L8kb1boc+FyCAaTA9GmiKaaiaF+A==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.7.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.2", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.5.tgz", + "integrity": "sha512-vG7yLURXNvCHy0FBdbZRwIu0BLPJMlUUJS2Ep7ud9w1YCLftFZtuEjyjhym0Qq9yuZ6LJUitNlu/hMk0gakXAw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": "^9 || ^10" + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "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/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.3.tgz", + "integrity": "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "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/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, + "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/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/globals": { + "version": "17.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.12.0.tgz", + "integrity": "sha512-cezEd/DTyyht9cvSSURyygXPfy04GtWO/5e6ZPvH7fCtjKz9PYOmuawphw1Ctd1f6C+5JypXfGD7ahNMXvevBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "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/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "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/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "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/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "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/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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/lucide-react": { + "version": "0.577.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.577.0.tgz", + "integrity": "sha512-4LjoFv2eEPwYDPg/CUdBJQSDfPyzXCRrVW1X7jrx/trgxnxkHFjnVZINbzvzxjN70dxychOfg+FTYwBiS3pQ5A==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "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/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "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.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.54", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz", + "integrity": "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==", + "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/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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/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.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/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/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/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/read-cache": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.2.tgz", + "integrity": "sha512-/peqiBB/n07gQGLsWaHho3WfvUyRscw0gYTsEFMhrIe/nWLkYaf5SbKYjGYqtRV3aPwykJgF2VEMo1ac4bnsGA==", + "dev": true, + "license": "MIT" + }, + "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/rolldown": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.6.tgz", + "integrity": "sha512-vMM4q3aixf46GiF1Kok8jDPFsEpXgFWGjUHXNkNHNm+Y2adXAG2dbX91jkti3i0ZRsOlcmbuzAz1poObSHCmUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.147.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.6", + "@rolldown/binding-android-arm64": "1.2.6", + "@rolldown/binding-darwin-arm64": "1.2.6", + "@rolldown/binding-darwin-x64": "1.2.6", + "@rolldown/binding-freebsd-x64": "1.2.6", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.6", + "@rolldown/binding-linux-arm64-gnu": "1.2.6", + "@rolldown/binding-linux-arm64-musl": "1.2.6", + "@rolldown/binding-linux-ppc64-gnu": "1.2.6", + "@rolldown/binding-linux-s390x-gnu": "1.2.6", + "@rolldown/binding-linux-x64-gnu": "1.2.6", + "@rolldown/binding-linux-x64-musl": "1.2.6", + "@rolldown/binding-openharmony-arm64": "1.2.6", + "@rolldown/binding-win32-arm64-msvc": "1.2.6", + "@rolldown/binding-win32-x64-msvc": "1.2.6" + } + }, + "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/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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/tailwind-merge": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.1.tgz", + "integrity": "sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "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.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "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-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "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/tsx": { + "version": "4.23.13", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.13.tgz", + "integrity": "sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "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/typescript-eslint": { + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.69.0.tgz", + "integrity": "sha512-B3MltX0VqjUBNEe3b3sSuiRbfa6XrfHFtBiPamjT5AsW/dfq+y+bc0wyuS9DxAS1LyzCxRp2+rxzpLUvqM2BvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.69.0", + "@typescript-eslint/parser": "8.69.0", + "@typescript-eslint/typescript-estree": "8.69.0", + "@typescript-eslint/utils": "8.69.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "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.3.2", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz", + "integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==", + "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/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.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": "8.2.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz", + "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.26", + "rolldown": "~1.2.4", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0 || ^0.5.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "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/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.5.4.tgz", + "integrity": "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, + "node_modules/zustand": { + "version": "5.0.15", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.15.tgz", + "integrity": "sha512-MpSEjRiBkA9crSYeOUH32rJC7SVqAbm0Fqcqge/bUi2PPoLcBWKOsG+C8mevmpr8TwXHBVkChbbJiyvkE+i/3A==", + "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/apps/marketing/package.json b/apps/marketing/package.json new file mode 100644 index 00000000..530e74cf --- /dev/null +++ b/apps/marketing/package.json @@ -0,0 +1,39 @@ +{ + "name": "@partha/marketing", + "private": true, + "version": "0.1.0", + "description": "Static marketing site for PARTHA: the real product's landing page, repurposed with a scripted product simulation and a 'run it yourself' CTA to the main repository. Deliberately independent of apps/frontend -- no backend dependency, deployable to Vercel on its own (#382).", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "lint": "eslint .", + "typecheck": "tsc -b --noEmit" + }, + "dependencies": { + "clsx": "^2.1.1", + "lucide-react": "^0.577.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "tailwind-merge": "^2.5.2", + "zustand": "^5.0.15" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@types/node": "^22.10.1", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^6.1.0", + "autoprefixer": "^10.5.4", + "eslint": "^10.8.1", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.4", + "globals": "^17.11.0", + "postcss": "^8.5.26", + "tailwindcss": "^3.4.10", + "typescript": "^5.5.4", + "typescript-eslint": "^8.67.0", + "vite": "^8.2.2" + } +} diff --git a/apps/marketing/postcss.config.js b/apps/marketing/postcss.config.js new file mode 100644 index 00000000..2aa7205d --- /dev/null +++ b/apps/marketing/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/apps/marketing/public/favicon.svg b/apps/marketing/public/favicon.svg new file mode 100644 index 00000000..715749be --- /dev/null +++ b/apps/marketing/public/favicon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/marketing/src/App.tsx b/apps/marketing/src/App.tsx new file mode 100644 index 00000000..3be94e6b --- /dev/null +++ b/apps/marketing/src/App.tsx @@ -0,0 +1,175 @@ +import { useEffect, useState } from 'react'; +import { ThemeSwitcher } from '@/components/ThemeSwitcher'; +import { useLandingTheme } from '@/hooks/useLandingTheme'; +import { useMediaQuery } from '@/hooks/useMediaQuery'; +import { DemoModal } from '@/components/DemoModal'; +import { RunItYourselfModal } from '@/components/RunItYourselfModal'; +import { MobileLanding } from '@/components/MobileLanding'; +import { Modal } from '@/components/Modal'; +import { faqAnswers, faqQuestions } from '@/data/faq'; +import { FOOTER_COLUMNS, type FooterLink } from '@/data/site'; +import landingReference from '@/assets/landing/landing-reference.svg'; +import landingReferenceDark from '@/assets/landing/landing-reference-dark.svg'; + +/** + * Adapted from apps/frontend/src/app/pages/LandingPage.tsx (#382 redesign): + * the real, signed-off marketing page -- same 1728-wide authored design + * canvas, same light/dark theme system, same FAQ and footer. Two behavioral + * differences from the real app, since this standalone site has no backend + * and no accounts at all: + * + * - The nav's "Log in" hotspot and the hero's "See how it works" hotspot + * both open the scripted demo simulation (DemoModal). + * - Every "Analyze a Repository" hotspot opens local setup instructions + * (RunItYourselfModal) -- there is no live backend to analyze against. + * + * The authored canvas is a fixed 1728-wide composition: on a narrow screen + * it only shrinks, so its text becomes unreadable. At >= 1024px this renders + * that canvas; below it, a real responsive layout (MobileLanding) with the + * same content and the same dialogs. Only one of the two is ever in the DOM, + * so the 3.3 MB design SVG never loads on a phone. + */ +export function App() { + const [faqIndex, setFaqIndex] = useState(null); + const [demoOpen, setDemoOpen] = useState(false); + const [runItYourselfOpen, setRunItYourselfOpen] = useState(false); + const theme = useLandingTheme(); + const dark = theme.resolved === 'dark'; + const isDesktop = useMediaQuery('(min-width: 1024px)'); + + useEffect(() => { + document.documentElement.removeAttribute('data-landing-theme-boot'); + }, []); + + const analyzeCta = (className: string) => ( + + ); + + return ( +
    +

    Reveal the system behind the code.

    + + {isDesktop ? ( +
    + PARTHA repository intelligence system overview, capabilities, frequently asked questions, and call to action. + + + + + +
    +
    +
    +
    + + + {analyzeCta('absolute left-[49.4%] top-[13.5%] z-20 h-[1.35%] w-[22.4%]')} + + {faqQuestions.map((question, index) => ( +
    + ) : ( + setDemoOpen(true)} + onOpenRunItYourself={() => setRunItYourselfOpen(true)} + /> + )} + + {faqIndex !== null && ( + setFaqIndex(null)} labelledBy="landing-faq-title" maxWidthClassName="max-w-xl"> +
    +
    +
    +

    FAQ

    +

    {faqQuestions[faqIndex]}

    +
    + +
    +

    {faqAnswers[faqIndex]}

    +
    +
    + )} + + {demoOpen && setDemoOpen(false)} />} + {runItYourselfOpen && setRunItYourselfOpen(false)} />} +
    + ); +} + +// Desktop overlay only: the authored footer sits at fixed positions in the +// 1728-wide canvas. Columns are 39.1 / 55 / 70.6 / 86.3 % from the left; rows +// step down 0.8 % each. Derived from FOOTER_COLUMNS so the link set stays a +// single source of truth shared with MobileLanding. +const FOOTER_COLUMN_LEFT = ['39.1%', '55%', '70.6%', '86.3%'] as const; +const FOOTER_ROW_TOP = ['93.65%', '94.45%', '95.25%', '96.05%'] as const; + +type PositionedFooterLink = FooterLink & { left: string; top: string }; + +const footerControls: PositionedFooterLink[] = FOOTER_COLUMNS.flatMap((column, columnIndex) => + column.links.map((link, rowIndex) => ({ + ...link, + left: FOOTER_COLUMN_LEFT[columnIndex], + top: FOOTER_ROW_TOP[rowIndex], + })), +); + +function FooterControl({ item }: { item: PositionedFooterLink }) { + return ( + + ); +} diff --git a/apps/marketing/src/assets/landing/landing-reference-dark.svg b/apps/marketing/src/assets/landing/landing-reference-dark.svg new file mode 100644 index 00000000..b873f62d --- /dev/null +++ b/apps/marketing/src/assets/landing/landing-reference-dark.svg @@ -0,0 +1,658 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + + + + + + + + + + + + + + + + + + + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    \ No newline at end of file diff --git a/apps/marketing/src/assets/landing/landing-reference.svg b/apps/marketing/src/assets/landing/landing-reference.svg new file mode 100644 index 00000000..aab5a6cc --- /dev/null +++ b/apps/marketing/src/assets/landing/landing-reference.svg @@ -0,0 +1,653 @@ + + + + +
    + + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    diff --git a/apps/marketing/src/assets/landing/mobile-accent-star.svg b/apps/marketing/src/assets/landing/mobile-accent-star.svg new file mode 100644 index 00000000..6b29b7be --- /dev/null +++ b/apps/marketing/src/assets/landing/mobile-accent-star.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/apps/marketing/src/assets/landing/mobile-blob.svg b/apps/marketing/src/assets/landing/mobile-blob.svg new file mode 100644 index 00000000..8f42ad44 --- /dev/null +++ b/apps/marketing/src/assets/landing/mobile-blob.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/marketing/src/assets/landing/mobile-hero-visual.svg b/apps/marketing/src/assets/landing/mobile-hero-visual.svg new file mode 100644 index 00000000..5c55cc7c --- /dev/null +++ b/apps/marketing/src/assets/landing/mobile-hero-visual.svg @@ -0,0 +1,43 @@ + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    diff --git a/apps/marketing/src/assets/landing/mobile-step-1.svg b/apps/marketing/src/assets/landing/mobile-step-1.svg new file mode 100644 index 00000000..3fed32a6 --- /dev/null +++ b/apps/marketing/src/assets/landing/mobile-step-1.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/marketing/src/assets/landing/mobile-step-2.svg b/apps/marketing/src/assets/landing/mobile-step-2.svg new file mode 100644 index 00000000..3d72086d --- /dev/null +++ b/apps/marketing/src/assets/landing/mobile-step-2.svg @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/marketing/src/assets/landing/mobile-step-3.svg b/apps/marketing/src/assets/landing/mobile-step-3.svg new file mode 100644 index 00000000..2c3887d1 --- /dev/null +++ b/apps/marketing/src/assets/landing/mobile-step-3.svg @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/marketing/src/assets/landing/mobile-story-evidence.svg b/apps/marketing/src/assets/landing/mobile-story-evidence.svg new file mode 100644 index 00000000..6e14c80d --- /dev/null +++ b/apps/marketing/src/assets/landing/mobile-story-evidence.svg @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/marketing/src/assets/landing/mobile-story-limits.svg b/apps/marketing/src/assets/landing/mobile-story-limits.svg new file mode 100644 index 00000000..f02e32b1 --- /dev/null +++ b/apps/marketing/src/assets/landing/mobile-story-limits.svg @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/marketing/src/assets/landing/mobile-story-system.svg b/apps/marketing/src/assets/landing/mobile-story-system.svg new file mode 100644 index 00000000..40e6454f --- /dev/null +++ b/apps/marketing/src/assets/landing/mobile-story-system.svg @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/marketing/src/assets/partha-logo.svg b/apps/marketing/src/assets/partha-logo.svg new file mode 100644 index 00000000..669ed86e --- /dev/null +++ b/apps/marketing/src/assets/partha-logo.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/apps/marketing/src/components/DemoModal.tsx b/apps/marketing/src/components/DemoModal.tsx new file mode 100644 index 00000000..d7565885 --- /dev/null +++ b/apps/marketing/src/components/DemoModal.tsx @@ -0,0 +1,251 @@ +import { useEffect, useRef, useState } from 'react'; +import { Modal } from '@/components/Modal'; +import { + CATEGORY_LABELS, + SAMPLE_CATEGORIES, + SAMPLE_FINDINGS, + SAMPLE_LANGUAGES, + SAMPLE_METRICS, + SAMPLE_REPO, + SIMULATION_STEPS, + type ReviewSeverity, +} from '@/data/sampleAnalysis'; + +type Phase = 'idle' | 'running' | 'done'; + +const SEVERITY_STYLE: Record = { + critical: 'bg-destructive/10 text-destructive', + high: 'bg-destructive/10 text-destructive', + medium: 'bg-warning/10 text-warning', + low: 'bg-primary/10 text-primary', + info: 'bg-muted text-muted-foreground', +}; + +const CATEGORY_STATE_STYLE: Record = { + assessed: 'bg-success/10 text-success', + partially_assessed: 'bg-warning/10 text-warning', + not_assessed: 'bg-muted text-muted-foreground', + insufficient_evidence: 'bg-muted text-muted-foreground', +}; + +/** Opened from the reused LandingPage's "Log in" nav hotspot and its "See + * how it works" hero hotspot (#382 redesign) -- there is no login flow, no + * dashboard, and no anchor-scrollable walkthrough in this standalone site, + * so both hotspots lead here instead. A centered modal dialog, not a page + * section: keeps the reused landing artwork's own length and layout + * completely untouched. */ +export function DemoModal({ onClose }: { onClose: () => void }) { + const [phase, setPhase] = useState('idle'); + const [stepIndex, setStepIndex] = useState(0); + const [tab, setTab] = useState<'review' | 'insights'>('review'); + const timers = useRef[]>([]); + + useEffect(() => () => timers.current.forEach(clearTimeout), []); + + const run = () => { + if (phase === 'running') return; + timers.current.forEach(clearTimeout); + timers.current = []; + setPhase('running'); + setStepIndex(0); + SIMULATION_STEPS.forEach((_, index) => { + timers.current.push(setTimeout(() => setStepIndex(index + 1), 550 * (index + 1))); + }); + timers.current.push(setTimeout(() => setPhase('done'), 550 * SIMULATION_STEPS.length + 300)); + }; + + const reset = () => { + timers.current.forEach(clearTimeout); + timers.current = []; + setPhase('idle'); + setStepIndex(0); + setTab('review'); + }; + + return ( + +
    +
    +
    + Scripted simulation · sample repository +
    +

    + See what a PARTHA analysis produces +

    +

    + A scripted walkthrough of a made-up sample repository ({SAMPLE_REPO.name}) -- not a live analysis of any + real code. It uses PARTHA's actual finding categories and output shape. +

    +
    + +
    + +
    +
    +
    +
    +

    {SAMPLE_REPO.name}

    +

    + revision {SAMPLE_REPO.revision} · {SAMPLE_REPO.languages} +

    +
    + {phase !== 'idle' && ( + + )} +
    + + {phase === 'idle' && ( +
    +

    + Run the simulation to watch PARTHA walk through a sample repository and produce Engineering Review + and Repository Insights output. +

    + +
    + )} + + {phase !== 'idle' && ( +
    +
      + {SIMULATION_STEPS.map((step, index) => { + const complete = index < stepIndex; + const active = index === stepIndex && phase === 'running'; + return ( +
    1. + + {complete ? '✓' : index + 1} + + {step} +
    2. + ); + })} +
    +
    + )} + + {phase === 'done' && ( +
    +
    + {(['review', 'insights'] as const).map((value) => ( + + ))} +
    + + {tab === 'review' && ( +
    +
    + {SAMPLE_CATEGORIES.map((category) => ( + + {category.label} + {category.findingCount > 0 ? ` (${category.findingCount})` : ''} + + ))} +
    +
      + {SAMPLE_FINDINGS.map((finding) => ( +
    • +
      +

      {finding.title}

      + + {finding.severity} + +
      +

      + {CATEGORY_LABELS[finding.category]} +

      +

      {finding.explanation}

      +

      + Remediation: + {finding.remediationGuidance} +

      +

      + {finding.path}:{finding.startLine} + {finding.endLine !== finding.startLine ? `–${finding.endLine}` : ''} +

      +
    • + ))} +
    +
    + )} + + {tab === 'insights' && ( +
    +
    + {SAMPLE_METRICS.map((metric) => ( +
    +

    {metric.value}

    +

    {metric.label}

    +
    + ))} +
    +
    +

    Languages

    +
    + {SAMPLE_LANGUAGES.map((language, index) => ( +
    + ))} +
    +
    + {SAMPLE_LANGUAGES.map((language) => ( + + {language.label} {language.value}% + + ))} +
    +
    +
    + )} +
    + )} +
    +
    + + ); +} diff --git a/apps/marketing/src/components/MobileLanding.tsx b/apps/marketing/src/components/MobileLanding.tsx new file mode 100644 index 00000000..f27a1f35 --- /dev/null +++ b/apps/marketing/src/components/MobileLanding.tsx @@ -0,0 +1,334 @@ +import type { ReactNode } from 'react'; +import { ArrowRight, ExternalLink, Play, Plus } from 'lucide-react'; +import { ThemeSwitcher } from '@/components/ThemeSwitcher'; +import { cn } from '@/utils/cn'; +import type { useLandingTheme } from '@/hooks/useLandingTheme'; +import { faqAnswers, faqQuestions } from '@/data/faq'; +import { DISCORD_URL, FOOTER_COLUMNS } from '@/data/site'; +import parthaLogo from '@/assets/partha-logo.svg'; +import heroVisual from '@/assets/landing/mobile-hero-visual.svg'; +import accentStar from '@/assets/landing/mobile-accent-star.svg'; +import blob from '@/assets/landing/mobile-blob.svg'; +import storySystem from '@/assets/landing/mobile-story-system.svg'; +import storyEvidence from '@/assets/landing/mobile-story-evidence.svg'; +import storyLimits from '@/assets/landing/mobile-story-limits.svg'; +import step1 from '@/assets/landing/mobile-step-1.svg'; +import step2 from '@/assets/landing/mobile-step-2.svg'; +import step3 from '@/assets/landing/mobile-step-3.svg'; + +/** + * Phone / tablet layout for the landing page (< 1024px). The authored 1728-wide + * design canvas only shrinks on a narrow screen, so App.tsx renders this below + * `lg`. It follows PARTHA Foundations v1 -- Montserrat Alternates + * (`font-display`) for headings and buttons, Proza Libre (`font-sans`) for + * reading text, Cormorant Upright (`font-accent`) for one expressive phrase, + * the four brand colours in their roles, ~70% neutral surface -- and composes + * the designer's own illustration assets (hero visual, product-story graphics, + * how-it-works steps). Same content and dialogs as desktop; the 3.3 MB design + * SVG never loads here. + */ +interface MobileLandingProps { + theme: ReturnType; + onOpenDemo: () => void; + onOpenRunItYourself: () => void; +} + +const STORY_CARDS = [ + { + tone: 'blue' as const, + label: 'System view', + title: 'See how the system fits together', + body: 'Modules, dependencies, routes, and relationships, mapped from one repository model rather than a parser per feature.', + art: storySystem, + artAlt: 'A resolved graph of services — database, web service, auth service, API gateway — connected by dependency edges.', + }, + { + tone: 'orange' as const, + label: 'Source evidence', + title: 'Know where every finding came from', + body: 'Every supported fact traces to the exact file, symbol, line span, and revision it was extracted from.', + art: storyEvidence, + artAlt: 'An AuthService finding linked to auth.service.ts, the login() symbol, lines 12–19, at a specific revision.', + }, + { + tone: 'neutral' as const, + label: 'Honest by default', + title: 'Honest about its limits', + body: 'The same revision always seals the same snapshot, and anything that could not be assessed stays visibly unassessed.', + art: storyLimits, + artAlt: 'An assessment summary: items extracted, items partially assessed, and items explicitly not assessed.', + }, +]; + +const STEPS = [ + { + title: 'Add a repository', + body: 'Upload a ZIP/TAR archive or import a public GitHub repository over HTTPS.', + art: step1, + artAlt: 'A repository and a revision resolving into one sealed snapshot.', + }, + { + title: 'Run analysis', + body: 'A durable background job extracts files, symbols, dependencies, and relationships, then seals an immutable snapshot for that exact revision.', + art: step2, + artAlt: 'Files, symbols, dependencies, and relationships extracted into the repository model.', + }, + { + title: 'Inspect it from every angle', + body: 'Architecture, Dependencies, Engineering Review, Insights, and Documentation all read that one shared model.', + art: step3, + artAlt: 'A finding traced through the model to its evidence in source.', + }, +]; + +const CAPABILITIES = [ + ['Architecture', 'A snapshot-backed module and relationship graph. Heuristic layers are labelled as heuristic.'], + ['Dependency Graph', 'Direct declarations from three manifest formats and pins from two lockfiles, on one identity.'], + ['Engineering Review', 'Evidence-addressed findings only. No overall score, grade, or health percentage.'], + ['Repository Insights', 'Defined counts, ratios, diagnostics, and extraction coverage from one sealed snapshot.'], + ['Documentation & exports', 'Structural docs plus JSON, Markdown, HTML, and PDF exports from the shared model.'], + ['Optional AI', 'A provider you configure receives structural facts only — never source bytes or line spans.'], +] as const; + +const BTN_BASE = + 'inline-flex items-center justify-center gap-2 rounded-full px-6 py-3 font-display text-sm font-semibold transition-colors'; + +function PrimaryButton({ children, onClick, href }: { children: ReactNode; onClick?: () => void; href?: string }) { + const cls = cn( + BTN_BASE, + 'bg-primary text-primary-foreground shadow-[0_12px_26px_hsl(var(--primary)/0.24)] hover:bg-primary/90', + ); + return href ? ( +
    + {children} + + ) : ( + + ); +} + +function SecondaryButton({ children, onClick, href }: { children: ReactNode; onClick?: () => void; href?: string }) { + const cls = cn(BTN_BASE, 'border border-foreground/25 bg-card text-foreground hover:bg-accent'); + return href ? ( + + {children} + + ) : ( + + ); +} + +function SectionHeading({ id, children }: { id: string; children: ReactNode }) { + return ( +

    + {children} +

    + ); +} + +/** Wraps a designer illustration so the (light-surfaced) art keeps a consistent + * frame in both themes without fighting its own internal background. */ +function Illustration({ src, alt, className }: { src: string; alt: string; className?: string }) { + return ( +
    + {alt} +
    + ); +} + +export function MobileLanding({ theme, onOpenDemo, onOpenRunItYourself }: MobileLandingProps) { + const dark = theme.resolved === 'dark'; + + return ( +
    +
    + + PARTHA + + +
    + + {/* Hero */} +
    + + + + Repository intelligence system + +

    + Reveal the system behind the code. +

    +

    + PARTHA analyses a Git repository at a specific revision and produces a sealed, evidence-backed model of the + system. Deterministic. Reproducible. Honest about its limits. +

    +
    + + Analyze a repository + + +
    +

    + Connect a repository. PARTHA maps the system. You verify the evidence. +

    +
    + + {/* Meet Partha + product story */} +
    +

    + Meet Partha +

    + +
    + {STORY_CARDS.map((card) => ( +
    +

    + {card.label} +

    +

    {card.title}

    +

    {card.body}

    + +
    + ))} +
    +
    + + {/* How it works */} +
    + How it works +
      + {STEPS.map((step, index) => ( +
    1. +
      + + {index + 1} + +
      +

      {step.title}

      +

      {step.body}

      +
      +
      + +
    2. + ))} +
    +
    + + {/* Capabilities */} +
    + Capabilities +
      + {CAPABILITIES.map(([title, body]) => ( +
    • +

      {title}

      +

      {body}

      +
    • + ))} +
    +
    + + {/* FAQ */} +
    + Frequently asked questions +
    + {faqQuestions.map((question, index) => ( +
    + + {question} + +

    {faqAnswers[index]}

    +
    + ))} +
    +
    + + {/* CTA */} +
    + +

    Run PARTHA on your own code

    +

    + It's open source and self-hosted — no waitlist, no hosted service. Fork it, run it locally, and point it + at a repository you actually care about. +

    +
    + + Get started + Join the Discord +
    +
    + + {/* Footer */} +
    +
    + {FOOTER_COLUMNS.map((column) => ( +
    +

    + {column.heading} +

    + +
    + ))} +
    +
    +

    © {new Date().getFullYear()} PARTHA · Apache-2.0

    + +
    +
    +
    + ); +} diff --git a/apps/marketing/src/components/Modal.tsx b/apps/marketing/src/components/Modal.tsx new file mode 100644 index 00000000..7f7f50b4 --- /dev/null +++ b/apps/marketing/src/components/Modal.tsx @@ -0,0 +1,78 @@ +import { useEffect, useRef, useState, type ReactNode } from 'react'; + +interface ModalProps { + onClose: () => void; + labelledBy: string; + children: ReactNode; + /** Tailwind max-width class controlling how wide the dialog is on larger + * viewports. Full-width (minus page gutters) on narrow ones either way. */ + maxWidthClassName?: string; +} + +/** Shared centered modal dialog used by every overlay on the reused landing + * page (DemoModal, RunItYourselfModal, the FAQ panel in App.tsx). + * + * A centered card over a dimmed backdrop is the conventional pattern for a + * marketing page's supporting dialogs -- it reads as part of the page rather + * than as an app chrome element sliding in from the edge. Purely + * presentational: what each dialog shows and does is unchanged, only the + * container positions and animates. + * + * Behavior: closes on Escape and on a backdrop click, locks background + * scroll while open, and moves focus to the dialog on mount so keyboard and + * screen-reader users start inside it. */ +export function Modal({ onClose, labelledBy, children, maxWidthClassName = 'max-w-2xl' }: ModalProps) { + const [entered, setEntered] = useState(false); + const panelRef = useRef(null); + + useEffect(() => { + // Mount hidden (slightly down and scaled back) then flip to the resting + // state on the next frame, so the transition has something to animate + // instead of the card just appearing already in place. + const frame = requestAnimationFrame(() => setEntered(true)); + panelRef.current?.focus(); + return () => cancelAnimationFrame(frame); + }, []); + + useEffect(() => { + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + return () => { + document.body.style.overflow = previousOverflow; + }; + }, []); + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') onClose(); + }; + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, [onClose]); + + return ( +
    + + ); +} diff --git a/apps/marketing/src/components/RunItYourselfModal.tsx b/apps/marketing/src/components/RunItYourselfModal.tsx new file mode 100644 index 00000000..2a6bbdaf --- /dev/null +++ b/apps/marketing/src/components/RunItYourselfModal.tsx @@ -0,0 +1,104 @@ +import { Modal } from '@/components/Modal'; + +const GITHUB_URL = 'https://github.com/Second-Origin/PARTHA'; + +// Mirrors the exact commands in the repository's own README ("Run PARTHA +// locally") -- kept as a copy here (not a fetch of that file), but checked +// against it directly rather than invented. +const BACKEND_COMMANDS = `git clone ${GITHUB_URL}.git +cd PARTHA/apps/backend +python3.13 -m venv .venv +source .venv/bin/activate +pip install -e . +cd ../.. +npm run dev:backend`; + +const FRONTEND_COMMANDS = `# in a second terminal +cd PARTHA +npm ci --prefix apps/frontend +npm run dev:frontend`; + +/** Opened from the reused LandingPage's "Analyze a Repository" hotspots + * (#382 redesign) -- there is no live backend for this button to actually + * analyze anything against, so instead of running the scripted demo (see + * DemoModal, reached from "Log in" instead) it shows how to run the real + * product against a visitor's own code. No waitlist/hosted-beta path: the + * product direction is self-host-only for the foreseeable future, so + * "run it yourself" is the only call to action here. */ +export function RunItYourselfModal({ onClose }: { onClose: () => void }) { + return ( + +
    +
    +
    +

    Run it yourself

    +

    + Analyze your own repository +

    +
    + +
    +

    + PARTHA isn't running as a hosted service, and there are no plans to host it -- it's open source and + self-hosted only. It runs locally with no external service beyond a public GitHub repository to analyze -- + fork or clone it, run it, and try it on your own code. +

    + +
    +
    +

    1. Start the backend

    +
    +              {BACKEND_COMMANDS}
    +            
    +
    +
    +

    2. Start the frontend

    +
    +              {FRONTEND_COMMANDS}
    +            
    +

    + Open localhost:5173, register a local + account, add a repository, and start analysis. +

    +
    +
    + + + +
    +

    + Finding this useful?{' '} + + Star the repo + {' '} + to support the project and help others find it. +

    +
    +
    +
    + ); +} diff --git a/apps/marketing/src/components/ThemeSwitcher.tsx b/apps/marketing/src/components/ThemeSwitcher.tsx new file mode 100644 index 00000000..836f2724 --- /dev/null +++ b/apps/marketing/src/components/ThemeSwitcher.tsx @@ -0,0 +1,41 @@ +// Ported verbatim from +// apps/frontend/src/features/landing/components/ThemeSwitcher.tsx (#382). +import { Laptop, Moon, Sun } from 'lucide-react'; +import { cn } from '@/utils/cn'; +import type { LandingThemePreference } from '@/hooks/useLandingTheme'; + +interface ThemeSwitcherProps { + preference: LandingThemePreference; + onChange: (preference: LandingThemePreference) => void; + className?: string; +} + +const OPTIONS: { value: LandingThemePreference; label: string; icon: typeof Sun }[] = [ + { value: 'system', label: 'System', icon: Laptop }, + { value: 'light', label: 'Light', icon: Sun }, + { value: 'dark', label: 'Dark', icon: Moon }, +]; + +export function ThemeSwitcher({ preference, onChange, className }: ThemeSwitcherProps) { + return ( +
    + {OPTIONS.map(({ value, label, icon: Icon }) => ( + + ))} +
    + ); +} diff --git a/apps/marketing/src/data/faq.ts b/apps/marketing/src/data/faq.ts new file mode 100644 index 00000000..330b46ec --- /dev/null +++ b/apps/marketing/src/data/faq.ts @@ -0,0 +1,21 @@ +/** The landing page FAQ, shared by the desktop overlay's dialog (App.tsx) and + * the mobile/tablet accordion (MobileLanding.tsx). Wording matches the + * authored design canvas. */ + +export const faqQuestions = [ + 'Is PARTHA an AI product?', + 'How do you handle dynamic dispatch, reflection, and generated code?', + 'Which languages are supported?', + 'Where does PARTHA run?', + 'How does PARTHA integrate with our CI?', + 'What is the ri.v1 format?', +] as const; + +export const faqAnswers = [ + 'PARTHA creates deterministic, evidence-backed repository models. AI assistance is optional and is limited to structural facts that have already been computed from a sealed snapshot.', + 'PARTHA makes supported evidence and limits visible. Findings that cannot be verified from the selected revision are not presented as facts.', + 'Language support is determined by the extractors available for the selected repository. The sealed snapshot records exactly what was assessed.', + 'PARTHA analyses a repository at a specific revision and retains a reproducible model for the workspace.', + 'Connect a repository, select a revision, then use the generated evidence and exports in the engineering workflow that suits your team.', + 'ri.v1 is PARTHA’s sealed repository-intelligence snapshot format. It records the exact revision, extracted facts, and available evidence.', +] as const; diff --git a/apps/marketing/src/data/sampleAnalysis.ts b/apps/marketing/src/data/sampleAnalysis.ts new file mode 100644 index 00000000..810641ab --- /dev/null +++ b/apps/marketing/src/data/sampleAnalysis.ts @@ -0,0 +1,155 @@ +/** + * Canned data for the scripted product simulation (#382). + * + * Every category id, severity level, and field name here matches the real + * product's actual Engineering Review / Repository Insights response shape + * (apps/backend/app/schemas -- cross-checked against + * apps/frontend/src/shared/services/api/generated.ts, the generated OpenAPI + * types) and the real category labels + * (apps/backend/app/review/review_service.py's `_CATEGORY_LABELS`). The + * repository, findings, and numbers themselves are entirely made up for a + * fictional sample repo -- nothing here is captured from a real analysis. + */ + +export const SAMPLE_REPO = { + name: 'acme/checkout-service', + revision: 'a3f9c21', + languages: 'Python, TypeScript', +}; + +export const SIMULATION_STEPS = [ + 'Cloning acme/checkout-service at a3f9c21', + 'Extracting structural facts (Python, TypeScript)', + 'Resolving module and dependency relationships', + 'Sealing the ri.v1 snapshot', + 'Running Engineering Review and Repository Insights', +] as const; + +export type ReviewSeverity = 'info' | 'low' | 'medium' | 'high' | 'critical'; + +export type ReviewCategoryId = + | 'architecture_boundaries' + | 'relationship_resolution' + | 'source_extraction' + | 'dependency_declarations' + | 'security_vulnerability_scanning' + | 'authentication_evidence' + | 'repository_structure' + | 'analysis_integrity'; + +// Matches apps/backend/app/review/review_service.py's _CATEGORY_LABELS +// exactly, so the demo never invents category names the real product +// doesn't have. +export const CATEGORY_LABELS: Record = { + architecture_boundaries: 'Architecture and boundaries', + relationship_resolution: 'Relationship resolution', + source_extraction: 'Source extraction', + dependency_declarations: 'Dependency declarations', + security_vulnerability_scanning: 'Security vulnerability scanning', + authentication_evidence: 'Authentication evidence', + repository_structure: 'Repository structure', + analysis_integrity: 'Analysis integrity', +}; + +export interface SampleFinding { + id: string; + title: string; + category: ReviewCategoryId; + severity: ReviewSeverity; + explanation: string; + remediationGuidance: string; + path: string; + startLine: number; + endLine: number; +} + +export const SAMPLE_FINDINGS: SampleFinding[] = [ + { + id: 'finding-1', + title: 'Payment adapter imports directly from the checkout domain layer', + category: 'architecture_boundaries', + severity: 'medium', + explanation: + 'src/payments/stripe_adapter.py imports CheckoutOrder directly from src/checkout/domain.py, crossing the boundary the module layout otherwise keeps between payment adapters and the checkout domain.', + remediationGuidance: + 'Route the dependency through the shared checkout interface (src/checkout/ports.py) instead of importing the domain model directly.', + path: 'src/payments/stripe_adapter.py', + startLine: 12, + endLine: 14, + }, + { + id: 'finding-2', + title: 'Declared dependency pin resolves to a version not requested anywhere', + category: 'dependency_declarations', + severity: 'low', + explanation: + 'package-lock.json resolves "fast-xml-parser" to 4.2.5, but no direct or transitive declaration in package.json requests it -- likely a stale lockfile entry from a removed dependency.', + remediationGuidance: 'Regenerate the lockfile and confirm the resolved dependency tree still matches package.json.', + path: 'package-lock.json', + startLine: 1841, + endLine: 1841, + }, + { + id: 'finding-3', + title: 'Session cookie is issued without an explicit SameSite attribute', + category: 'authentication_evidence', + severity: 'high', + explanation: + 'src/auth/session.py sets the session cookie without a SameSite attribute, so browsers fall back to a permissive default that varies by browser rather than an explicit, reviewable policy.', + remediationGuidance: 'Set SameSite explicitly (Lax or Strict) when issuing the session cookie.', + path: 'src/auth/session.py', + startLine: 47, + endLine: 52, + }, + { + id: 'finding-4', + title: 'Two modules independently resolve the same order-total calculation', + category: 'relationship_resolution', + severity: 'info', + explanation: + 'src/checkout/domain.py and src/reporting/order_summary.py both implement order-total logic independently rather than one calling the other, a duplicate-interpretation risk if the two drift.', + remediationGuidance: 'Consider extracting one shared calculation both call, if the duplication was not intentional.', + path: 'src/reporting/order_summary.py', + startLine: 88, + endLine: 104, + }, +]; + +export interface SampleCategoryAssessment { + id: ReviewCategoryId; + label: string; + state: 'assessed' | 'partially_assessed' | 'not_assessed' | 'insufficient_evidence'; + findingCount: number; + explanation: string; +} + +export const SAMPLE_CATEGORIES: SampleCategoryAssessment[] = [ + { id: 'architecture_boundaries', label: CATEGORY_LABELS.architecture_boundaries, state: 'assessed', findingCount: 1, explanation: 'Module boundaries were assessed from resolved import relationships.' }, + { id: 'relationship_resolution', label: CATEGORY_LABELS.relationship_resolution, state: 'assessed', findingCount: 1, explanation: 'Cross-module relationships were assessed from resolved facts.' }, + { id: 'source_extraction', label: CATEGORY_LABELS.source_extraction, state: 'assessed', findingCount: 0, explanation: 'No source-extraction diagnostics were raised for this snapshot.' }, + { id: 'dependency_declarations', label: CATEGORY_LABELS.dependency_declarations, state: 'assessed', findingCount: 1, explanation: 'Direct declarations and lockfile pins were assessed for this snapshot.' }, + { id: 'security_vulnerability_scanning', label: CATEGORY_LABELS.security_vulnerability_scanning, state: 'not_assessed', findingCount: 0, explanation: 'Vulnerability scanning is not implemented; this category is not assessed.' }, + { id: 'authentication_evidence', label: CATEGORY_LABELS.authentication_evidence, state: 'assessed', findingCount: 1, explanation: 'The supported Python/FastAPI authentication subgraph was assessed.' }, + { id: 'repository_structure', label: CATEGORY_LABELS.repository_structure, state: 'assessed', findingCount: 0, explanation: 'No repository-structure diagnostics were raised for this snapshot.' }, + { id: 'analysis_integrity', label: CATEGORY_LABELS.analysis_integrity, state: 'assessed', findingCount: 0, explanation: 'The analysis completed with no integrity diagnostics.' }, +]; + +export interface SampleMetric { + id: string; + label: string; + value: string; + definition: string; +} + +export const SAMPLE_METRICS: SampleMetric[] = [ + { id: 'files_analyzed', label: 'Files analyzed', value: '212', definition: 'Files included in this snapshot after extraction.' }, + { id: 'modules', label: 'Modules identified', value: '18', definition: 'Distinct modules resolved from the repository layout.' }, + { id: 'relationships', label: 'Relationships resolved', value: '341', definition: 'Import and dependency edges resolved between facts.' }, + { id: 'evidence_records', label: 'Evidence records', value: '1,204', definition: 'Stored source spans backing supported facts.' }, +]; + +export const SAMPLE_LANGUAGES = [ + { key: 'python', label: 'Python', value: 63 }, + { key: 'typescript', label: 'TypeScript', value: 29 }, + { key: 'other', label: 'Other', value: 8 }, +]; diff --git a/apps/marketing/src/data/site.ts b/apps/marketing/src/data/site.ts new file mode 100644 index 00000000..315c853f --- /dev/null +++ b/apps/marketing/src/data/site.ts @@ -0,0 +1,58 @@ +/** Single source of truth for the landing page's external destinations and + * footer structure. The desktop overlay (App.tsx) derives its positioned + * hotspot list from FOOTER_COLUMNS; the mobile/tablet layout + * (MobileLanding.tsx) renders the columns directly. */ + +export const GITHUB_URL = 'https://github.com/Second-Origin/PARTHA'; +const REPO_BLOB = `${GITHUB_URL}/blob/dev`; +export const DISCORD_URL = 'https://discord.gg/qvk9DcxDA'; +export const LINKEDIN_URL = 'https://www.linkedin.com/in/parthrohit'; + +export type FooterLink = { + label: string; + href: string; + /** true opens in a new tab (rel=noreferrer); false is a same-page anchor. */ + external: boolean; +}; + +export const FOOTER_COLUMNS: { heading: string; links: FooterLink[] }[] = [ + { + heading: 'Product', + links: [ + { label: 'How it works', href: '#how-it-works', external: false }, + { label: 'Capabilities', href: '#capabilities', external: false }, + { label: 'FAQ', href: '#faq', external: false }, + { label: 'Privacy', href: `${REPO_BLOB}/README.md#limitations-and-security`, external: true }, + ], + }, + { + heading: 'Resources', + links: [ + { label: 'Docs', href: `${REPO_BLOB}/docs/README.md`, external: true }, + { label: 'ri.v1 spec', href: `${REPO_BLOB}/docs/architecture/REPOSITORY_INTELLIGENCE_V1_RFC.md`, external: true }, + { + label: 'Language matrix', + href: `${REPO_BLOB}/docs/architecture/REPOSITORY_INTELLIGENCE.md#what-is-currently-extracted`, + external: true, + }, + { label: 'Changelog', href: `${GITHUB_URL}/releases`, external: true }, + ], + }, + { + heading: 'Company', + links: [ + { label: 'About', href: `${REPO_BLOB}/README.md`, external: true }, + { label: 'Security', href: `${REPO_BLOB}/SECURITY.md`, external: true }, + { label: 'Contact', href: DISCORD_URL, external: true }, + { label: 'Legal', href: `${REPO_BLOB}/LICENSE`, external: true }, + ], + }, + { + heading: 'Connect', + links: [ + { label: 'LinkedIn', href: LINKEDIN_URL, external: true }, + { label: 'X', href: DISCORD_URL, external: true }, + { label: 'GitHub', href: GITHUB_URL, external: true }, + ], + }, +]; diff --git a/apps/marketing/src/hooks/useLandingTheme.ts b/apps/marketing/src/hooks/useLandingTheme.ts new file mode 100644 index 00000000..713e5bb3 --- /dev/null +++ b/apps/marketing/src/hooks/useLandingTheme.ts @@ -0,0 +1,51 @@ +// Ported verbatim from apps/frontend/src/features/landing/hooks/useLandingTheme.ts +// (#382 redesign): this marketing site reuses the real landing page as its +// visual basis, so the light/dark toggle needs to behave identically, not +// just look similar. No cross-package import -- copied so this project +// stays fully independent and buildable/deployable on its own. +import { create } from 'zustand'; + +export type LandingThemePreference = 'light' | 'dark' | 'system'; +type ResolvedLandingTheme = 'light' | 'dark'; + +const STORAGE_KEY = 'partha-landing-theme'; + +function systemPrefersDark(): boolean { + return window.matchMedia('(prefers-color-scheme: dark)').matches; +} + +function resolve(preference: LandingThemePreference): ResolvedLandingTheme { + return preference === 'system' ? (systemPrefersDark() ? 'dark' : 'light') : preference; +} + +function readStoredPreference(): LandingThemePreference { + const stored = window.localStorage.getItem(STORAGE_KEY); + return stored === 'light' || stored === 'dark' || stored === 'system' ? stored : 'system'; +} + +interface LandingThemeState { + preference: LandingThemePreference; + resolved: ResolvedLandingTheme; + setPreference: (preference: LandingThemePreference) => void; +} + +export const useLandingThemeStore = create((set) => ({ + preference: readStoredPreference(), + resolved: resolve(readStoredPreference()), + setPreference: (preference) => { + window.localStorage.setItem(STORAGE_KEY, preference); + set({ preference, resolved: resolve(preference) }); + }, +})); + +if (typeof window !== 'undefined') { + window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { + const { preference } = useLandingThemeStore.getState(); + if (preference !== 'system') return; + useLandingThemeStore.setState({ resolved: resolve('system') }); + }); +} + +export function useLandingTheme() { + return useLandingThemeStore(); +} diff --git a/apps/marketing/src/hooks/useMediaQuery.ts b/apps/marketing/src/hooks/useMediaQuery.ts new file mode 100644 index 00000000..b49e3ea3 --- /dev/null +++ b/apps/marketing/src/hooks/useMediaQuery.ts @@ -0,0 +1,27 @@ +import { useCallback, useSyncExternalStore } from 'react'; + +/** Track a CSS media query via the browser's own matchMedia store. Read + * synchronously on first render (client-only Vite SPA) so there is no + * first-paint flash between the two landing layouts. */ +export function useMediaQuery(query: string): boolean { + const subscribe = useCallback( + (onStoreChange: () => void) => { + const media = window.matchMedia(query); + media.addEventListener('change', onStoreChange); + // Belt-and-braces: a few engines (and emulated devtools viewports) don't + // fire MediaQueryList 'change' reliably. useSyncExternalStore only + // re-renders when the boolean snapshot actually flips, so the extra + // resize events are cheap. + window.addEventListener('resize', onStoreChange); + return () => { + media.removeEventListener('change', onStoreChange); + window.removeEventListener('resize', onStoreChange); + }; + }, + [query], + ); + + const getSnapshot = useCallback(() => window.matchMedia(query).matches, [query]); + + return useSyncExternalStore(subscribe, getSnapshot, () => false); +} diff --git a/apps/marketing/src/main.tsx b/apps/marketing/src/main.tsx new file mode 100644 index 00000000..0f4a228a --- /dev/null +++ b/apps/marketing/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { App } from './App'; +import './styles/globals.css'; + +createRoot(document.getElementById('root')!).render( + + + , +); diff --git a/apps/marketing/src/styles/globals.css b/apps/marketing/src/styles/globals.css new file mode 100644 index 00000000..27988328 --- /dev/null +++ b/apps/marketing/src/styles/globals.css @@ -0,0 +1,74 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +/* Token values follow PARTHA Foundations v1 (Typography and Color, July 2026): + Signal Orange #FA4D01 (primary CTA / focus), Burnt Orange #AA3D00 + (secondary emphasis), Deep Blue #006298 (architecture / technical), Deep + Plum #392135 (headlines, body text, dark surfaces). Neutral surfaces carry + ~70% of the page; Signal Orange stays scarce. .landing-dark is scoped to the + landing root element only (see App.tsx / useLandingTheme.ts). */ +@layer base { + :root { + --background: 40 30% 95%; + --foreground: 310 27% 18%; /* Deep Plum */ + --card: 0 0% 100%; + --card-foreground: 310 27% 18%; + --popover: 0 0% 100%; + --popover-foreground: 310 27% 18%; + --primary: 18 99% 49%; /* Signal Orange */ + --primary-foreground: 0 0% 100%; + --secondary: 201 100% 30%; /* Deep Blue */ + --secondary-foreground: 0 0% 100%; + --burnt-orange: 22 100% 33%; /* Burnt Orange */ + --muted: 40 24% 92%; + --muted-foreground: 310 13% 38%; + --accent: 20 100% 94%; /* pale Signal Orange surface tint */ + --accent-foreground: 310 27% 18%; + --destructive: 0 84% 60%; + --destructive-foreground: 210 40% 98%; + --success: 142 72% 29%; + --success-foreground: 0 0% 100%; + --warning: 33 100% 33%; + --warning-foreground: 0 0% 100%; + --border: 24 34% 85%; + --input: 24 30% 88%; + --ring: 18 99% 49%; + --radius: 1rem; + } + + .landing-dark { + --background: 315 26% 8%; /* Deep Plum as a dark surface */ + --foreground: 0 0% 100%; + --card: 315 18% 12%; + --card-foreground: 0 0% 100%; + --primary: 18 99% 53%; + --primary-foreground: 0 0% 100%; + --secondary: 201 66% 56%; /* Deep Blue lifted for dark surfaces */ + --secondary-foreground: 0 0% 100%; + --burnt-orange: 22 88% 58%; + --muted: 315 14% 15%; + --muted-foreground: 315 8% 64%; + --accent: 315 14% 16%; + --accent-foreground: 0 0% 100%; + --border: 315 12% 21%; + --input: 315 12% 21%; + --ring: 18 99% 53%; + } + + body { + @apply bg-background text-foreground font-sans antialiased; + } + + button, + a, + input { + @apply focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background; + } +} + +@layer components { + .partha-surface { + @apply rounded-2xl border border-border bg-card shadow-[0_12px_24px_hsl(var(--foreground)/0.06)]; + } +} diff --git a/apps/marketing/src/utils/cn.ts b/apps/marketing/src/utils/cn.ts new file mode 100644 index 00000000..2819a830 --- /dev/null +++ b/apps/marketing/src/utils/cn.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from 'clsx'; +import { twMerge } from 'tailwind-merge'; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/apps/marketing/tailwind.config.ts b/apps/marketing/tailwind.config.ts new file mode 100644 index 00000000..dd3183b1 --- /dev/null +++ b/apps/marketing/tailwind.config.ts @@ -0,0 +1,87 @@ +import type { Config } from 'tailwindcss'; + +// Matches apps/frontend/tailwind.config.ts's token system (#382 redesign: +// this site now reuses the real LandingPage component, so its Tailwind +// config needs to resolve the exact same tokens that component's classes +// reference). Copied rather than shared so this project stays a genuinely +// standalone, independently buildable/deployable project. +export default { + darkMode: 'class', + content: ['./index.html', './src/**/*.{ts,tsx}'], + theme: { + extend: { + colors: { + border: 'hsl(var(--border))', + input: 'hsl(var(--input))', + ring: 'hsl(var(--ring))', + background: 'hsl(var(--background))', + foreground: 'hsl(var(--foreground))', + primary: { + DEFAULT: 'hsl(var(--primary))', + foreground: 'hsl(var(--primary-foreground))', + }, + secondary: { + DEFAULT: 'hsl(var(--secondary))', + foreground: 'hsl(var(--secondary-foreground))', + }, + destructive: { + DEFAULT: 'hsl(var(--destructive))', + foreground: 'hsl(var(--destructive-foreground))', + }, + muted: { + DEFAULT: 'hsl(var(--muted))', + foreground: 'hsl(var(--muted-foreground))', + }, + accent: { + DEFAULT: 'hsl(var(--accent))', + foreground: 'hsl(var(--accent-foreground))', + }, + success: { + DEFAULT: 'hsl(var(--success))', + foreground: 'hsl(var(--success-foreground))', + }, + warning: { + DEFAULT: 'hsl(var(--warning))', + foreground: 'hsl(var(--warning-foreground))', + }, + card: { + DEFAULT: 'hsl(var(--card))', + foreground: 'hsl(var(--card-foreground))', + }, + popover: { + DEFAULT: 'hsl(var(--popover))', + foreground: 'hsl(var(--popover-foreground))', + }, + // Foundations v1: Burnt Orange for selective secondary emphasis. + 'burnt-orange': 'hsl(var(--burnt-orange))', + }, + borderRadius: { + lg: 'var(--radius)', + md: 'calc(var(--radius) - 2px)', + sm: 'calc(var(--radius) - 4px)', + }, + fontFamily: { + // Foundations v1 typography roles. `sans` is the default body/reading + // family (Proza Libre); `display` is the brand/heading/button family + // (Montserrat Alternates); `accent` is Cormorant Upright, for a single + // expressive landing phrase only. + sans: ['"Proza Libre"', 'ui-sans-serif', 'system-ui', 'sans-serif'], + display: ['"Montserrat Alternates"', 'ui-sans-serif', 'system-ui', 'sans-serif'], + accent: ['"Cormorant Upright"', 'ui-serif', 'Georgia', 'serif'], + mono: ['"JetBrains Mono"', 'ui-monospace', 'monospace'], + }, + fontSize: { + '2xs': ['0.625rem', { lineHeight: '0.875rem' }], + }, + keyframes: { + fadeIn: { '0%': { opacity: '0' }, '100%': { opacity: '1' } }, + slideIn: { '0%': { opacity: '0', transform: 'translateY(8px)' }, '100%': { opacity: '1', transform: 'translateY(0)' } }, + }, + animation: { + 'fade-in': 'fadeIn 0.3s ease-out', + 'slide-in': 'slideIn 0.3s ease-out', + }, + }, + }, + plugins: [], +} satisfies Config; diff --git a/apps/marketing/tsconfig.json b/apps/marketing/tsconfig.json new file mode 100644 index 00000000..ec4fb101 --- /dev/null +++ b/apps/marketing/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src", "api", "vite-env.d.ts"] +} diff --git a/apps/marketing/vercel.json b/apps/marketing/vercel.json new file mode 100644 index 00000000..4974736e --- /dev/null +++ b/apps/marketing/vercel.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "framework": "vite", + "buildCommand": "npm run build", + "outputDirectory": "dist" +} diff --git a/apps/marketing/vite-env.d.ts b/apps/marketing/vite-env.d.ts new file mode 100644 index 00000000..11f02fe2 --- /dev/null +++ b/apps/marketing/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/apps/marketing/vite.config.ts b/apps/marketing/vite.config.ts new file mode 100644 index 00000000..d1be0f6c --- /dev/null +++ b/apps/marketing/vite.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import { fileURLToPath } from 'node:url'; + +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)), + }, + }, +}); diff --git a/design-qa.md b/design-qa.md new file mode 100644 index 00000000..e0412b2b --- /dev/null +++ b/design-qa.md @@ -0,0 +1,38 @@ +# Iteration 1 design QA + +> **Point-in-time record — not current-state documentation.** This captures one +> QA pass against the Iteration 1 design reference. Its findings and test counts +> were accurate when recorded and are deliberately left unedited, so it is +> evidence of what was checked rather than a description of the product today. +> The interface has since changed — navigation is grouped into Analysis and +> Assist with Settings pinned (#289), and the AI Workspace copy was corrected to +> disclose conversation retention. For current behaviour see the +> [README capability registry](README.md#what-works-today) and +> [System Overview](docs/architecture/SYSTEM_OVERVIEW.md). + +## Reference + +- Figma prototype: Iteration 1, starting at node `285:2987`. +- Compared states: sign in, dashboard, architecture, AI Workspace, and Settings → AI Providers. +- Comparison viewport: 1224 × 768. Responsive checks: 320 × 768. +- Comparison images are local QA artifacts and are not part of the public repository. + +## Result + +- Brand: designer-supplied PARTHA logo is used in authentication and navigation; no substitute mark is generated. +- Visual system: the implementation matches the reference cream canvas, orange primary/action colour, outlined surfaces, compact sidebar, and low-shadow card treatment. +- Dashboard: hierarchy, metric cards, repository list, navigation, and control density match the reference while all values remain authentic fixture data. +- Architecture: the graph, tabs, manifest, toolbar, and evidence labels match the reference. At 1224px and 320px, the document and main region have equal client and scroll widths. React Flow has non-zero dimensions at both sizes and emits no dimension warning. +- AI Workspace: the question composer remains visible at both viewports. The reference's blank workspace state is corrected while keeping the disclosed Preview limitation and sealed-snapshot-only context. +- AI Providers: provider selection, model identifier, masked key field, saved/not-configured state, test action, and save action are visible and functional. No credentials were entered during QA. +- Upload: source tabs expose real tab/tabpanel state so the selected GitHub or archive input cannot disagree with the visible content. +- Authentication: the existing supported email/password flow is retained. The reference's Google and GitHub controls were not copied because those authentication methods do not exist in the current backend and would create fake interactions. +- Responsive and accessibility: keyboard focus styling is visible, mobile navigation remains a labelled modal drawer, page headers wrap, contained tables scroll internally, and no page-level horizontal scrolling was observed in the tested states. White text on the primary burnt-orange action colour measures 5.24:1 contrast; status text maintains at least 4.62:1 against its tinted backgrounds. + +## Evidence + +- Browser measurements at 1224px: Dashboard, Architecture, AI Workspace, and AI Providers each reported `body.scrollWidth === body.clientWidth`; authenticated main regions also reported equal scroll and client widths. +- Browser measurements at 320px: Architecture reported a 320 × 288 graph and no document/main overflow; AI Workspace reported a visible composer ending above the viewport bottom. +- Automated checks: 41 frontend test files / 222 tests passed; frontend lint passed; production build passed. Backend verification covered 888 passing tests and 4 expected environment-gated skips; 886 passed in the full sandboxed run, and the two loopback TLS cases blocked by sandbox socket permissions passed when rerun with loopback permission. + +final result: passed diff --git a/dist/assets/AIWorkspacePage-Ddm0QeRu.js b/dist/assets/AIWorkspacePage-Ddm0QeRu.js deleted file mode 100644 index e94b5833..00000000 --- a/dist/assets/AIWorkspacePage-Ddm0QeRu.js +++ /dev/null @@ -1 +0,0 @@ -import{a as e,c as t,i as n,p as r,s as i}from"./client-S4ekmpXx.js";import{t as a}from"./circle-alert-BUYmyF7c.js";import{E as o,S as s,x as c}from"./index-QB2QUwKm.js";import{t as l}from"./PageHeader-DsNWbEI1.js";import{t as u}from"./EmptyState-BeMA8Ikt.js";import{t as d}from"./DataSourceBadge-D7dhrb9n.js";import{t as f}from"./ai-DpySo4Gb.js";import{t as p}from"./useRepositoryFeatureStatus-BAj4lrBf.js";var m=s(`Send`,[[`path`,{d:`M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z`,key:`1ffxy3`}],[`path`,{d:`m21.854 2.147-10.94 10.939`,key:`12cjpa`}]]),h=r(t(),1);function g(){let e=p(),[t,r]=(0,h.useState)(``),[i,a]=(0,h.useState)([]),[o,s]=(0,h.useState)([]),[c,l]=(0,h.useState)(!1),[u,d]=(0,h.useState)(null),m=(0,h.useCallback)(async()=>{let o=e.activeRepository,c=t.trim();if(!o||!c)return;let u={role:`user`,content:c,timestamp:new Date().toISOString()};a(e=>[...e,u]),r(``),l(!0),d(null);try{let e=new Date().toISOString(),t={role:`assistant`,content:``,timestamp:e,citations:[]};a(e=>[...e,t]),await f.streamQuery({repositoryId:o.id,query:c,context:{conversationHistory:i.slice(-8)}},t=>{t.type===`content`&&t.content&&a(n=>n.map(n=>n.timestamp===e?{...n,content:`${n.content}${t.content}`}:n)),t.type===`citation`&&t.citation&&a(n=>n.map(n=>n.timestamp===e?{...n,citations:[...n.citations||[],t.citation]}:n)),t.type===`error`&&t.error&&d(t.error)}),s([`Explain the main architecture boundaries.`,`What files should I read first?`,`What are the highest-risk engineering issues?`])}catch(e){d(n(e))}finally{l(!1)}},[i,t,e.activeRepository]);return{...e,query:t,setQuery:r,messages:i,suggestions:o,loading:e.loading||c,error:e.error||u,ask:m}}var _=i();function v(){let t=o(),n=g(),r=n.activeRepository,i=!!n.query.trim()&&!n.loading;return n.emptyReason===`no-completed-repositories`?(0,_.jsxs)(`div`,{children:[(0,_.jsx)(l,{title:`AI Workspace`,description:`Ask questions about your codebase using AI`}),(0,_.jsx)(u,{icon:c,title:`No analysed repositories`,description:`Upload and analyse a repository first. AI-powered explanations require a completed analysis pipeline.`,action:{label:`Upload Repository`,onClick:()=>t(`/upload`)}})]}):n.emptyReason===`no-active-repository`||!r?(0,_.jsxs)(`div`,{children:[(0,_.jsx)(l,{title:`AI Workspace`,description:`Ask questions about your codebase using AI`}),(0,_.jsx)(u,{icon:c,title:`Select a repository`,description:`Choose an analysed repository from the top bar to start asking questions.`})]}):(0,_.jsxs)(`div`,{className:`flex flex-col h-[calc(100vh-8rem)]`,children:[(0,_.jsx)(l,{title:`AI Workspace`,description:`AI-powered exploration of ${r.name}`,children:(0,_.jsx)(d,{source:n.source})}),(0,_.jsxs)(`div`,{className:`flex-1 flex flex-col rounded-xl border border-border bg-card overflow-hidden`,children:[(0,_.jsxs)(`div`,{className:`flex-1 overflow-y-auto p-5 space-y-4 scrollbar-thin`,children:[n.messages.length===0?(0,_.jsx)(`div`,{className:`flex h-full items-center justify-center text-center`,children:(0,_.jsxs)(`div`,{className:`max-w-sm`,children:[(0,_.jsx)(`div`,{className:`flex h-14 w-14 items-center justify-center rounded-2xl bg-muted mx-auto mb-4`,children:(0,_.jsx)(c,{className:`h-6 w-6 text-muted-foreground`})}),(0,_.jsxs)(`p`,{className:`text-sm font-medium text-foreground mb-1`,children:[`Ask about `,r.name]}),(0,_.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`Questions are sent to your configured AI provider with repository context and file citations when available.`})]})}):n.messages.map((t,n)=>(0,_.jsx)(`div`,{className:e(`flex`,t.role===`user`?`justify-end`:`justify-start`),children:(0,_.jsxs)(`div`,{className:e(`max-w-[78%] rounded-xl px-4 py-3 text-sm`,t.role===`user`?`bg-primary text-primary-foreground`:`bg-muted text-foreground`),children:[(0,_.jsx)(`p`,{className:`whitespace-pre-wrap`,children:t.content}),t.citations&&t.citations.length>0&&(0,_.jsx)(`div`,{className:`mt-3 border-t border-border/60 pt-2 space-y-1`,children:t.citations.map(e=>(0,_.jsx)(`p`,{className:`text-2xs text-muted-foreground font-mono`,children:e.file},e.file))})]})},`${t.timestamp}-${n}`)),n.loading&&(0,_.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`AI provider is thinking...`}),n.error&&(0,_.jsxs)(`div`,{className:`flex items-center gap-2 rounded-lg border border-destructive/50 bg-destructive/5 px-4 py-3`,children:[(0,_.jsx)(a,{className:`h-4 w-4 text-destructive`}),(0,_.jsx)(`p`,{className:`text-sm text-destructive`,children:n.error})]}),n.suggestions.length>0&&(0,_.jsx)(`div`,{className:`flex flex-wrap gap-2`,children:n.suggestions.map(e=>(0,_.jsx)(`button`,{onClick:()=>n.setQuery(e),className:`rounded-md border border-border px-2.5 py-1 text-xs text-muted-foreground hover:text-foreground hover:bg-accent transition-colors`,children:e},e))})]}),(0,_.jsxs)(`div`,{className:`border-t border-border p-4`,children:[(0,_.jsxs)(`form`,{className:`flex items-center gap-2`,onSubmit:e=>{e.preventDefault(),n.ask()},children:[(0,_.jsx)(`input`,{type:`text`,placeholder:`Ask about the codebase...`,value:n.query,onChange:e=>n.setQuery(e.target.value),className:`flex-1 rounded-md border border-border bg-background px-4 py-2.5 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring`}),(0,_.jsx)(`button`,{disabled:!i,className:e(`flex h-10 w-10 items-center justify-center rounded-md bg-primary text-primary-foreground transition-colors`,!i&&`opacity-50 cursor-not-allowed`),children:(0,_.jsx)(m,{className:`h-4 w-4`})})]}),(0,_.jsx)(`p`,{className:`text-2xs text-muted-foreground mt-2`,children:`Configure a real AI provider in Settings before asking questions.`})]})]})]})}export{v as AIWorkspacePage}; \ No newline at end of file diff --git a/dist/assets/AnalysisPipelinePage-Zvy8DKxE.js b/dist/assets/AnalysisPipelinePage-Zvy8DKxE.js deleted file mode 100644 index 93b1d054..00000000 --- a/dist/assets/AnalysisPipelinePage-Zvy8DKxE.js +++ /dev/null @@ -1 +0,0 @@ -import{a as e,c as t,i as n,p as r,s as i}from"./client-S4ekmpXx.js";import{t as a}from"./arrow-left-BeIPd3Ya.js";import{t as o}from"./circle-Ds7OrsG4.js";import{C as s,D as c,E as l,S as u,T as d,b as f,f as p,n as m,r as h,t as g}from"./index-QB2QUwKm.js";import{t as _}from"./PageHeader-DsNWbEI1.js";import{t as v}from"./DataSourceBadge-D7dhrb9n.js";var y=u(`CircleX`,[[`circle`,{cx:`12`,cy:`12`,r:`10`,key:`1mglay`}],[`path`,{d:`m15 9-6 6`,key:`1uzhvr`}],[`path`,{d:`m9 9 6 6`,key:`z0biqf`}]]),b=r(t(),1),x=[{key:`uploading`,label:`Uploading Repository`},{key:`extracting`,label:`Extracting Files`},{key:`reading-structure`,label:`Reading Repository Structure`},{key:`detecting-languages`,label:`Detecting Languages`},{key:`detecting-framework`,label:`Detecting Framework`},{key:`building-file-tree`,label:`Building File Tree`},{key:`extracting-modules`,label:`Extracting Modules`},{key:`building-dependency-graph`,label:`Building Dependency Graph`},{key:`preparing-architecture`,label:`Preparing Architecture Model`},{key:`completed`,label:`Analysis Complete`}];function S(e){let{repositories:t}=g(),r=h(e=>e.completeAnalysis),i=h(e=>e.failAnalysis),a=h(e=>e.cancelAnalysis),o=h(e=>e.updateRepository),s=(0,b.useRef)(null),c=(0,b.useRef)(!1),[l,u]=(0,b.useState)(`idle`),[d,f]=(0,b.useState)(null),[p,_]=(0,b.useState)(0),v=t.find(t=>t.id===e)||null,y=v?.status;(0,b.useEffect)(()=>{c.current=!1},[e]);let S=(0,b.useCallback)(()=>{f(null),_(e=>e+1)},[]);(0,b.useEffect)(()=>{if(!e||!v){u(`empty`);return}if(v.status===`completed`){u(`success`);return}if(v.status===`error`){u(`error`),f(v.errorMessage||`Analysis failed.`);return}if(!c.current){u(`loading`),f(null);{let t=!1;async function a(){if(!(!e||t))try{let n=await m.fetchAnalysisStatus(e);if(!n||t)return;let a={dataSource:`real`,analysisStage:n.stage,analysisProgress:n.progress,errorMessage:n.error||void 0,analysedAt:n.completedAt||void 0};if(n.status===`failed`){i(e,n.error||`Analysis failed.`),u(`error`),f(n.error||`Analysis failed.`);return}if(n.status===`completed`){r(e,a),u(`success`);return}o(e,{...a,status:`analysing`})}catch(e){u(`error`),f(n(e))}}a();let s=window.setInterval(()=>void a(),1500);return()=>{t=!0,window.clearInterval(s)}}i(e,`Backend API is required for repository analysis.`),u(`error`),f(`Backend API is required for repository analysis.`)}},[r,i,p,v,e,o]);let C=(0,b.useCallback)(()=>{c.current=!0,s.current&&clearTimeout(s.current),a()},[a]);return{repository:v,stages:x,currentStageIndex:x.findIndex(e=>e.key===v?.analysisStage),status:l,loading:l===`loading`,error:d,empty:!v,success:y===`completed`,source:v?.dataSource||null,retry:S,refresh:S,cancel:C,completedRepositoryPath:y===`completed`&&v?`/repositories/${v.id}`:null}}var C=i();function w(){let{id:t}=c(),n=l(),r=S(t),i=r.repository;return i?r.completedRepositoryPath?(0,C.jsx)(d,{to:r.completedRepositoryPath,replace:!0}):(0,C.jsxs)(`div`,{className:`max-w-xl mx-auto`,children:[(0,C.jsx)(_,{title:`Analysing Repository`,description:i.name,children:(0,C.jsx)(v,{source:r.source})}),(0,C.jsxs)(`div`,{className:`rounded-xl border border-border bg-card p-6 mb-6`,children:[(0,C.jsxs)(`div`,{className:`flex items-center justify-between mb-4`,children:[(0,C.jsx)(`span`,{className:`text-xs font-medium text-muted-foreground uppercase tracking-wider`,children:`Progress`}),(0,C.jsxs)(`span`,{className:`text-sm font-semibold text-foreground`,children:[i.analysisProgress,`%`]})]}),(0,C.jsx)(`div`,{className:`h-2 rounded-full bg-muted overflow-hidden`,children:(0,C.jsx)(s.div,{className:`h-full rounded-full bg-primary`,initial:{width:0},animate:{width:`${i.analysisProgress}%`},transition:{duration:.4,ease:`easeOut`}})})]}),(0,C.jsx)(`div`,{className:`rounded-xl border border-border bg-card p-6`,children:(0,C.jsx)(`div`,{className:`space-y-0`,children:r.stages.map((t,n)=>{let i=nr.currentStageIndex,l=n===r.stages.length-1;return(0,C.jsxs)(`div`,{className:`relative`,children:[(0,C.jsxs)(`div`,{className:`flex items-center gap-3 py-2.5`,children:[(0,C.jsx)(`div`,{className:`relative z-10`,children:i?(0,C.jsx)(s.div,{initial:{scale:0},animate:{scale:1},className:`flex h-6 w-6 items-center justify-center rounded-full bg-success/20`,children:(0,C.jsx)(f,{className:`h-3.5 w-3.5 text-success`})}):a?(0,C.jsx)(`div`,{className:`flex h-6 w-6 items-center justify-center rounded-full bg-primary/20`,children:(0,C.jsx)(p,{className:`h-3.5 w-3.5 text-primary animate-spin`})}):(0,C.jsx)(`div`,{className:`flex h-6 w-6 items-center justify-center rounded-full bg-muted`,children:(0,C.jsx)(o,{className:`h-3 w-3 text-muted-foreground`})})}),(0,C.jsx)(`span`,{className:e(`text-sm transition-colors`,i&&`text-muted-foreground`,a&&`text-foreground font-medium`,c&&`text-muted-foreground/60`),children:t.label})]}),!l&&(0,C.jsx)(`div`,{className:e(`absolute left-[11px] top-[34px] w-[2px] h-[14px]`,i?`bg-success/30`:`bg-border`)})]},t.key)})})}),(i.status===`error`||r.error)&&(0,C.jsxs)(s.div,{initial:{opacity:0,y:8},animate:{opacity:1,y:0},className:`mt-4 rounded-xl border border-destructive/50 bg-destructive/5 p-4 flex items-start gap-3`,children:[(0,C.jsx)(y,{className:`h-5 w-5 text-destructive shrink-0 mt-0.5`}),(0,C.jsxs)(`div`,{children:[(0,C.jsx)(`p`,{className:`text-sm font-medium text-destructive`,children:`Analysis Failed`}),(0,C.jsx)(`p`,{className:`text-xs text-destructive/80 mt-0.5`,children:i.errorMessage||r.error||`An unexpected error occurred.`}),(0,C.jsx)(`button`,{onClick:()=>n(`/upload`),className:`mt-2 text-xs text-primary hover:underline`,children:`Try again`})]})]}),(0,C.jsx)(`div`,{className:`mt-6 flex justify-start`,children:(0,C.jsxs)(`button`,{onClick:()=>{r.cancel(),n(`/upload`)},className:`flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors`,children:[(0,C.jsx)(a,{className:`h-4 w-4`}),`Cancel Analysis`]})})]}):(0,C.jsxs)(`div`,{className:`flex flex-col items-center justify-center py-16`,children:[(0,C.jsx)(`p`,{className:`text-sm text-muted-foreground`,children:`Repository not found.`}),(0,C.jsx)(`button`,{onClick:()=>n(`/upload`),className:`mt-4 text-sm text-primary hover:underline`,children:`Go to Upload`})]})}export{w as AnalysisPipelinePage}; \ No newline at end of file diff --git a/dist/assets/ArchitecturePage-7B6CwUGU.js b/dist/assets/ArchitecturePage-7B6CwUGU.js deleted file mode 100644 index 32db554b..00000000 --- a/dist/assets/ArchitecturePage-7B6CwUGU.js +++ /dev/null @@ -1,9 +0,0 @@ -import{a as e,c as t,i as n,p as r,s as i}from"./client-S4ekmpXx.js";import{t as a}from"./activity-BtAw6juF.js";import{t as o}from"./arrow-right-DpUoK8Nu.js";import{i as s,n as c,r as l,t as u}from"./zap-8B5FG3kN.js";import{a as d,c as f,i as p,n as m,o as h,r as g,s as _,t as v}from"./panel-left-open-DLIMLp_X.js";import{t as y}from"./circle-Ds7OrsG4.js";import{t as b}from"./download-BM44DUO2.js";import{t as x}from"./file-code-o79iDh0J.js";import{t as S}from"./hard-drive-B0dUssIC.js";import{t as C}from"./x-Bf4UPE8b.js";import{C as w,E as T,O as E,S as D,a as O,d as k,g as A,i as j,l as M,n as N,o as P,t as F,u as I,w as L,y as R}from"./index-QB2QUwKm.js";import{t as z}from"./EmptyState-BeMA8Ikt.js";import{t as B}from"./DataSourceBadge-D7dhrb9n.js";var V=D(`ArrowDown`,[[`path`,{d:`M12 5v14`,key:`s699le`}],[`path`,{d:`m19 12-7 7-7-7`,key:`1idqje`}]]),H=D(`Bookmark`,[[`path`,{d:`m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z`,key:`1fy3hk`}]]),ee=D(`ChevronUp`,[[`path`,{d:`m18 15-6-6-6 6`,key:`153udz`}]]),te=D(`CloudCog`,[[`circle`,{cx:`12`,cy:`17`,r:`3`,key:`1spfwm`}],[`path`,{d:`M4.2 15.1A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.2`,key:`zaobp`}],[`path`,{d:`m15.7 18.4-.9-.3`,key:`4qxpbn`}],[`path`,{d:`m9.2 15.9-.9-.3`,key:`17q7o2`}],[`path`,{d:`m10.6 20.7.3-.9`,key:`1pf4s2`}],[`path`,{d:`m13.1 14.2.3-.9`,key:`1mnuqm`}],[`path`,{d:`m13.6 20.7-.4-1`,key:`1jpd1m`}],[`path`,{d:`m10.8 14.3-.4-1`,key:`17ugyy`}],[`path`,{d:`m8.3 18.6 1-.4`,key:`s42vdx`}],[`path`,{d:`m14.7 15.8 1-.4`,key:`2wizun`}]]),U=D(`Cog`,[[`path`,{d:`M12 20a8 8 0 1 0 0-16 8 8 0 0 0 0 16Z`,key:`sobvz5`}],[`path`,{d:`M12 14a2 2 0 1 0 0-4 2 2 0 0 0 0 4Z`,key:`11i496`}],[`path`,{d:`M12 2v2`,key:`tus03m`}],[`path`,{d:`M12 22v-2`,key:`1osdcq`}],[`path`,{d:`m17 20.66-1-1.73`,key:`eq3orb`}],[`path`,{d:`M11 10.27 7 3.34`,key:`16pf9h`}],[`path`,{d:`m20.66 17-1.73-1`,key:`sg0v6f`}],[`path`,{d:`m3.34 7 1.73 1`,key:`1ulond`}],[`path`,{d:`M14 12h8`,key:`4f43i9`}],[`path`,{d:`M2 12h2`,key:`1t8f8n`}],[`path`,{d:`m20.66 7-1.73 1`,key:`1ow05n`}],[`path`,{d:`m3.34 17 1.73-1`,key:`nuk764`}],[`path`,{d:`m17 3.34-1 1.73`,key:`2wel8s`}],[`path`,{d:`m11 13.73-4 6.93`,key:`794ttg`}]]),ne=D(`Cpu`,[[`rect`,{width:`16`,height:`16`,x:`4`,y:`4`,rx:`2`,key:`14l7u7`}],[`rect`,{width:`6`,height:`6`,x:`9`,y:`9`,rx:`1`,key:`5aljv4`}],[`path`,{d:`M15 2v2`,key:`13l42r`}],[`path`,{d:`M15 20v2`,key:`15mkzm`}],[`path`,{d:`M2 15h2`,key:`1gxd5l`}],[`path`,{d:`M2 9h2`,key:`1bbxkp`}],[`path`,{d:`M20 15h2`,key:`19e6y8`}],[`path`,{d:`M20 9h2`,key:`19tzq7`}],[`path`,{d:`M9 2v2`,key:`165o2o`}],[`path`,{d:`M9 20v2`,key:`i2bqo8`}]]),re=D(`Expand`,[[`path`,{d:`m21 21-6-6m6 6v-4.8m0 4.8h-4.8`,key:`1c15vz`}],[`path`,{d:`M3 16.2V21m0 0h4.8M3 21l6-6`,key:`1fsnz2`}],[`path`,{d:`M21 7.8V3m0 0h-4.8M21 3l-6 6`,key:`hawz9i`}],[`path`,{d:`M3 7.8V3m0 0h4.8M3 3l6 6`,key:`u9ee12`}]]),ie=D(`EyeOff`,[[`path`,{d:`M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49`,key:`ct8e1f`}],[`path`,{d:`M14.084 14.158a3 3 0 0 1-4.242-4.242`,key:`151rxh`}],[`path`,{d:`M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143`,key:`13bj9a`}],[`path`,{d:`m2 2 20 20`,key:`1ooewy`}]]),ae=D(`Flame`,[[`path`,{d:`M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z`,key:`96xj49`}]]),oe=D(`Focus`,[[`circle`,{cx:`12`,cy:`12`,r:`3`,key:`1v7zrd`}],[`path`,{d:`M3 7V5a2 2 0 0 1 2-2h2`,key:`aa7l1z`}],[`path`,{d:`M17 3h2a2 2 0 0 1 2 2v2`,key:`4qcy5o`}],[`path`,{d:`M21 17v2a2 2 0 0 1-2 2h-2`,key:`6vwrx8`}],[`path`,{d:`M7 21H5a2 2 0 0 1-2-2v-2`,key:`ioqczr`}]]),se=D(`Grid3x3`,[[`rect`,{width:`18`,height:`18`,x:`3`,y:`3`,rx:`2`,key:`afitv7`}],[`path`,{d:`M3 9h18`,key:`1pudct`}],[`path`,{d:`M3 15h18`,key:`5xshup`}],[`path`,{d:`M9 3v18`,key:`fh3hqa`}],[`path`,{d:`M15 3v18`,key:`14nvp0`}]]),ce=D(`Library`,[[`path`,{d:`m16 6 4 14`,key:`ji33uf`}],[`path`,{d:`M12 6v14`,key:`1n7gus`}],[`path`,{d:`M8 8v12`,key:`1gg7y9`}],[`path`,{d:`M4 4v16`,key:`6qkkli`}]]),le=D(`ListTodo`,[[`rect`,{x:`3`,y:`5`,width:`6`,height:`6`,rx:`1`,key:`1defrl`}],[`path`,{d:`m3 17 2 2 4-4`,key:`1jhpwq`}],[`path`,{d:`M13 6h8`,key:`15sg57`}],[`path`,{d:`M13 12h8`,key:`h98zly`}],[`path`,{d:`M13 18h8`,key:`oe0vm4`}]]),ue=D(`Map`,[[`path`,{d:`M14.106 5.553a2 2 0 0 0 1.788 0l3.659-1.83A1 1 0 0 1 21 4.619v12.764a1 1 0 0 1-.553.894l-4.553 2.277a2 2 0 0 1-1.788 0l-4.212-2.106a2 2 0 0 0-1.788 0l-3.659 1.83A1 1 0 0 1 3 19.381V6.618a1 1 0 0 1 .553-.894l4.553-2.277a2 2 0 0 1 1.788 0z`,key:`169xi5`}],[`path`,{d:`M15 5.764v15`,key:`1pn4in`}],[`path`,{d:`M9 3.236v15`,key:`1uimfh`}]]),de=D(`Maximize2`,[[`polyline`,{points:`15 3 21 3 21 9`,key:`mznyad`}],[`polyline`,{points:`9 21 3 21 3 15`,key:`1avn1i`}],[`line`,{x1:`21`,x2:`14`,y1:`3`,y2:`10`,key:`ota7mn`}],[`line`,{x1:`3`,x2:`10`,y1:`21`,y2:`14`,key:`1atl0r`}]]),fe=D(`Minimize2`,[[`polyline`,{points:`4 14 10 14 10 20`,key:`11kfnr`}],[`polyline`,{points:`20 10 14 10 14 4`,key:`rlmsce`}],[`line`,{x1:`14`,x2:`21`,y1:`10`,y2:`3`,key:`o5lafz`}],[`line`,{x1:`3`,x2:`10`,y1:`21`,y2:`14`,key:`1atl0r`}]]),pe=D(`Monitor`,[[`rect`,{width:`20`,height:`14`,x:`2`,y:`3`,rx:`2`,key:`48i651`}],[`line`,{x1:`8`,x2:`16`,y1:`21`,y2:`21`,key:`1svkeh`}],[`line`,{x1:`12`,x2:`12`,y1:`17`,y2:`21`,key:`vw1qmm`}]]),me=D(`RotateCcw`,[[`path`,{d:`M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8`,key:`1357e3`}],[`path`,{d:`M3 3v5h5`,key:`1xhq8a`}]]),he=D(`Route`,[[`circle`,{cx:`6`,cy:`19`,r:`3`,key:`1kj8tv`}],[`path`,{d:`M9 19h8.5a3.5 3.5 0 0 0 0-7h-11a3.5 3.5 0 0 1 0-7H15`,key:`1d8sl`}],[`circle`,{cx:`18`,cy:`5`,r:`3`,key:`gq8acd`}]]),ge=D(`Server`,[[`rect`,{width:`20`,height:`8`,x:`2`,y:`2`,rx:`2`,ry:`2`,key:`ngkwjq`}],[`rect`,{width:`20`,height:`8`,x:`2`,y:`14`,rx:`2`,ry:`2`,key:`iecqi9`}],[`line`,{x1:`6`,x2:`6.01`,y1:`6`,y2:`6`,key:`16zg32`}],[`line`,{x1:`6`,x2:`6.01`,y1:`18`,y2:`18`,key:`nzw8ys`}]]),_e=D(`Shield`,[[`path`,{d:`M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z`,key:`oel41y`}]]),ve=D(`Waypoints`,[[`circle`,{cx:`12`,cy:`4.5`,r:`2.5`,key:`r5ysbb`}],[`path`,{d:`m10.2 6.3-3.9 3.9`,key:`1nzqf6`}],[`circle`,{cx:`4.5`,cy:`12`,r:`2.5`,key:`jydg6v`}],[`path`,{d:`M7 12h10`,key:`b7w52i`}],[`circle`,{cx:`19.5`,cy:`12`,r:`2.5`,key:`1piiel`}],[`path`,{d:`m13.8 17.7 3.9-3.9`,key:`1wyg1y`}],[`circle`,{cx:`12`,cy:`19.5`,r:`2.5`,key:`13o1pw`}]]),ye=D(`Wrench`,[[`path`,{d:`M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z`,key:`cbrjhi`}]]),be=D(`ZoomIn`,[[`circle`,{cx:`11`,cy:`11`,r:`8`,key:`4ej97u`}],[`line`,{x1:`21`,x2:`16.65`,y1:`21`,y2:`16.65`,key:`13gj7c`}],[`line`,{x1:`11`,x2:`11`,y1:`8`,y2:`14`,key:`1vmskp`}],[`line`,{x1:`8`,x2:`14`,y1:`11`,y2:`11`,key:`durymu`}]]),xe=D(`ZoomOut`,[[`circle`,{cx:`11`,cy:`11`,r:`8`,key:`4ej97u`}],[`line`,{x1:`21`,x2:`16.65`,y1:`21`,y2:`16.65`,key:`13gj7c`}],[`line`,{x1:`8`,x2:`14`,y1:`11`,y2:`11`,key:`durymu`}]]),W=r(t(),1),G=i();function K(e){if(typeof e==`string`||typeof e==`number`)return``+e;let t=``;if(Array.isArray(e))for(let n=0,r;n{}};function Ce(){for(var e=0,t=arguments.length,n={},r;e=0&&(n=e.slice(r+1),e=e.slice(0,r)),e&&!t.hasOwnProperty(e))throw Error(`unknown type: `+e);return{type:e,name:n}})}we.prototype=Ce.prototype={constructor:we,on:function(e,t){var n=this._,r=Te(e+``,n),i,a=-1,o=r.length;if(arguments.length<2){for(;++a0)for(var n=Array(i),r=0,i,a;r=0&&(t=e.slice(0,n))!==`xmlns`&&(e=e.slice(n+1)),Oe.hasOwnProperty(t)?{space:Oe[t],local:e}:e}function Ae(e){return function(){var t=this.ownerDocument,n=this.namespaceURI;return n===`http://www.w3.org/1999/xhtml`&&t.documentElement.namespaceURI===`http://www.w3.org/1999/xhtml`?t.createElement(e):t.createElementNS(n,e)}}function je(e){return function(){return this.ownerDocument.createElementNS(e.space,e.local)}}function Me(e){var t=ke(e);return(t.local?je:Ae)(t)}function Ne(){}function Pe(e){return e==null?Ne:function(){return this.querySelector(e)}}function Fe(e){typeof e!=`function`&&(e=Pe(e));for(var t=this._groups,n=t.length,r=Array(n),i=0;i=v&&(v=_+1);!(b=g[v])&&++v=0;)(o=r[i])&&(a&&o.compareDocumentPosition(a)^4&&a.parentNode.insertBefore(o,a),a=o);return this}function dt(e){e||=ft;function t(t,n){return t&&n?e(t.__data__,n.__data__):!t-!n}for(var n=this._groups,r=n.length,i=Array(r),a=0;at?1:e>=t?0:NaN}function pt(){var e=arguments[0];return arguments[0]=this,e.apply(null,arguments),this}function mt(){return Array.from(this)}function ht(){for(var e=this._groups,t=0,n=e.length;t1?this.each((t==null?Dt:typeof t==`function`?kt:Ot)(e,t,n??``)):jt(this.node(),e)}function jt(e,t){return e.style.getPropertyValue(t)||Et(e).getComputedStyle(e,null).getPropertyValue(t)}function Mt(e){return function(){delete this[e]}}function Nt(e,t){return function(){this[e]=t}}function Pt(e,t){return function(){var n=t.apply(this,arguments);n==null?delete this[e]:this[e]=n}}function Ft(e,t){return arguments.length>1?this.each((t==null?Mt:typeof t==`function`?Pt:Nt)(e,t)):this.node()[e]}function It(e){return e.trim().split(/^|\s+/)}function Lt(e){return e.classList||new Rt(e)}function Rt(e){this._node=e,this._names=It(e.getAttribute(`class`)||``)}Rt.prototype={add:function(e){this._names.indexOf(e)<0&&(this._names.push(e),this._node.setAttribute(`class`,this._names.join(` `)))},remove:function(e){var t=this._names.indexOf(e);t>=0&&(this._names.splice(t,1),this._node.setAttribute(`class`,this._names.join(` `)))},contains:function(e){return this._names.indexOf(e)>=0}};function zt(e,t){for(var n=Lt(e),r=-1,i=t.length;++r=0&&(t=e.slice(n+1),e=e.slice(0,n)),{type:e,name:t}})}function hn(e){return function(){var t=this.__on;if(t){for(var n=0,r=-1,i=t.length,a;n()=>e;function Ln(e,{sourceEvent:t,subject:n,target:r,identifier:i,active:a,x:o,y:s,dx:c,dy:l,dispatch:u}){Object.defineProperties(this,{type:{value:e,enumerable:!0,configurable:!0},sourceEvent:{value:t,enumerable:!0,configurable:!0},subject:{value:n,enumerable:!0,configurable:!0},target:{value:r,enumerable:!0,configurable:!0},identifier:{value:i,enumerable:!0,configurable:!0},active:{value:a,enumerable:!0,configurable:!0},x:{value:o,enumerable:!0,configurable:!0},y:{value:s,enumerable:!0,configurable:!0},dx:{value:c,enumerable:!0,configurable:!0},dy:{value:l,enumerable:!0,configurable:!0},_:{value:u}})}Ln.prototype.on=function(){var e=this._.on.apply(this._,arguments);return e===this._?this:e};function Rn(e){return!e.ctrlKey&&!e.button}function zn(){return this.parentNode}function Bn(e,t){return t??{x:e.x,y:e.y}}function Vn(){return navigator.maxTouchPoints||`ontouchstart`in this}function Hn(){var e=Rn,t=zn,n=Bn,r=Vn,i={},a=Ce(`start`,`drag`,`end`),o=0,s,c,l,u,d=0;function f(e){e.on(`mousedown.drag`,p).filter(r).on(`touchstart.drag`,g).on(`touchmove.drag`,_,An).on(`touchend.drag touchcancel.drag`,v).style(`touch-action`,`none`).style(`-webkit-tap-highlight-color`,`rgba(0,0,0,0)`)}function p(n,r){if(!(u||!e.call(this,n,r))){var i=y(this,t.call(this,n,r),n,r,`mouse`);i&&(Dn(n.view).on(`mousemove.drag`,m,jn).on(`mouseup.drag`,h,jn),Pn(n.view),Mn(n),l=!1,s=n.clientX,c=n.clientY,i(`start`,n))}}function m(e){if(Nn(e),!l){var t=e.clientX-s,n=e.clientY-c;l=t*t+n*n>d}i.mouse(`drag`,e)}function h(e){Dn(e.view).on(`mousemove.drag mouseup.drag`,null),Fn(e.view,l),Nn(e),i.mouse(`end`,e)}function g(n,r){if(e.call(this,n,r)){var i=n.changedTouches,a=t.call(this,n,r),o=i.length,s,c;for(s=0;s>8&15|t>>4&240,t>>4&15|t&240,(t&15)<<4|t&15,1):n===8?dr(t>>24&255,t>>16&255,t>>8&255,(t&255)/255):n===4?dr(t>>12&15|t>>8&240,t>>8&15|t>>4&240,t>>4&15|t&240,((t&15)<<4|t&15)/255):null):(t=Qn.exec(e))?new q(t[1],t[2],t[3],1):(t=$n.exec(e))?new q(t[1]*255/100,t[2]*255/100,t[3]*255/100,1):(t=er.exec(e))?dr(t[1],t[2],t[3],t[4]):(t=tr.exec(e))?dr(t[1]*255/100,t[2]*255/100,t[3]*255/100,t[4]):(t=nr.exec(e))?br(t[1],t[2]/100,t[3]/100,1):(t=rr.exec(e))?br(t[1],t[2]/100,t[3]/100,t[4]):ir.hasOwnProperty(e)?ur(ir[e]):e===`transparent`?new q(NaN,NaN,NaN,0):null}function ur(e){return new q(e>>16&255,e>>8&255,e&255,1)}function dr(e,t,n,r){return r<=0&&(e=t=n=NaN),new q(e,t,n,r)}function fr(e){return e instanceof Gn||(e=lr(e)),e?(e=e.rgb(),new q(e.r,e.g,e.b,e.opacity)):new q}function pr(e,t,n,r){return arguments.length===1?fr(e):new q(e,t,n,r??1)}function q(e,t,n,r){this.r=+e,this.g=+t,this.b=+n,this.opacity=+r}Un(q,pr,Wn(Gn,{brighter(e){return e=e==null?qn:qn**+e,new q(this.r*e,this.g*e,this.b*e,this.opacity)},darker(e){return e=e==null?Kn:Kn**+e,new q(this.r*e,this.g*e,this.b*e,this.opacity)},rgb(){return this},clamp(){return new q(vr(this.r),vr(this.g),vr(this.b),_r(this.opacity))},displayable(){return-.5<=this.r&&this.r<255.5&&-.5<=this.g&&this.g<255.5&&-.5<=this.b&&this.b<255.5&&0<=this.opacity&&this.opacity<=1},hex:mr,formatHex:mr,formatHex8:hr,formatRgb:gr,toString:gr}));function mr(){return`#${yr(this.r)}${yr(this.g)}${yr(this.b)}`}function hr(){return`#${yr(this.r)}${yr(this.g)}${yr(this.b)}${yr((isNaN(this.opacity)?1:this.opacity)*255)}`}function gr(){let e=_r(this.opacity);return`${e===1?`rgb(`:`rgba(`}${vr(this.r)}, ${vr(this.g)}, ${vr(this.b)}${e===1?`)`:`, ${e})`}`}function _r(e){return isNaN(e)?1:Math.max(0,Math.min(1,e))}function vr(e){return Math.max(0,Math.min(255,Math.round(e)||0))}function yr(e){return e=vr(e),(e<16?`0`:``)+e.toString(16)}function br(e,t,n,r){return r<=0?e=t=n=NaN:n<=0||n>=1?e=t=NaN:t<=0&&(e=NaN),new Cr(e,t,n,r)}function xr(e){if(e instanceof Cr)return new Cr(e.h,e.s,e.l,e.opacity);if(e instanceof Gn||(e=lr(e)),!e)return new Cr;if(e instanceof Cr)return e;e=e.rgb();var t=e.r/255,n=e.g/255,r=e.b/255,i=Math.min(t,n,r),a=Math.max(t,n,r),o=NaN,s=a-i,c=(a+i)/2;return s?(o=t===a?(n-r)/s+(n0&&c<1?0:o,new Cr(o,s,c,e.opacity)}function Sr(e,t,n,r){return arguments.length===1?xr(e):new Cr(e,t,n,r??1)}function Cr(e,t,n,r){this.h=+e,this.s=+t,this.l=+n,this.opacity=+r}Un(Cr,Sr,Wn(Gn,{brighter(e){return e=e==null?qn:qn**+e,new Cr(this.h,this.s,this.l*e,this.opacity)},darker(e){return e=e==null?Kn:Kn**+e,new Cr(this.h,this.s,this.l*e,this.opacity)},rgb(){var e=this.h%360+(this.h<0)*360,t=isNaN(e)||isNaN(this.s)?0:this.s,n=this.l,r=n+(n<.5?n:1-n)*t,i=2*n-r;return new q(Er(e>=240?e-240:e+120,i,r),Er(e,i,r),Er(e<120?e+240:e-120,i,r),this.opacity)},clamp(){return new Cr(wr(this.h),Tr(this.s),Tr(this.l),_r(this.opacity))},displayable(){return(0<=this.s&&this.s<=1||isNaN(this.s))&&0<=this.l&&this.l<=1&&0<=this.opacity&&this.opacity<=1},formatHsl(){let e=_r(this.opacity);return`${e===1?`hsl(`:`hsla(`}${wr(this.h)}, ${Tr(this.s)*100}%, ${Tr(this.l)*100}%${e===1?`)`:`, ${e})`}`}}));function wr(e){return e=(e||0)%360,e<0?e+360:e}function Tr(e){return Math.max(0,Math.min(1,e||0))}function Er(e,t,n){return(e<60?t+(n-t)*e/60:e<180?n:e<240?t+(n-t)*(240-e)/60:t)*255}var Dr=e=>()=>e;function Or(e,t){return function(n){return e+n*t}}function kr(e,t,n){return e**=+n,t=t**+n-e,n=1/n,function(r){return(e+r*t)**+n}}function Ar(e){return(e=+e)==1?jr:function(t,n){return n-t?kr(t,n,e):Dr(isNaN(t)?n:t)}}function jr(e,t){var n=t-e;return n?Or(e,n):Dr(isNaN(e)?t:e)}var Mr=(function e(t){var n=Ar(t);function r(e,t){var r=n((e=pr(e)).r,(t=pr(t)).r),i=n(e.g,t.g),a=n(e.b,t.b),o=jr(e.opacity,t.opacity);return function(t){return e.r=r(t),e.g=i(t),e.b=a(t),e.opacity=o(t),e+``}}return r.gamma=e,r})(1);function Nr(e,t){t||=[];var n=e?Math.min(t.length,e.length):0,r=t.slice(),i;return function(a){for(i=0;in&&(a=t.slice(n,a),s[o]?s[o]+=a:s[++o]=a),(r=r[0])===(i=i[0])?s[o]?s[o]+=i:s[++o]=i:(s[++o]=null,c.push({i:o,x:Lr(r,i)})),n=Br.lastIndex;return n180?t+=360:t-e>180&&(e+=360),a.push({i:n.push(i(n)+`rotate(`,null,r)-2,x:Lr(e,t)}))}function s(e,t,n,a){e===t?t&&n.push(i(n)+`skewX(`+t+r):a.push({i:n.push(i(n)+`skewX(`,null,r)-2,x:Lr(e,t)})}function c(e,t,n,r,a,o){if(e!==n||t!==r){var s=a.push(i(a)+`scale(`,null,`,`,null,`)`);o.push({i:s-4,x:Lr(e,n)},{i:s-2,x:Lr(t,r)})}else(n!==1||r!==1)&&a.push(i(a)+`scale(`+n+`,`+r+`)`)}return function(t,n){var r=[],i=[];return t=e(t),n=e(n),a(t.translateX,t.translateY,n.translateX,n.translateY,r,i),o(t.rotate,n.rotate,r,i),s(t.skewX,n.skewX,r,i),c(t.scaleX,t.scaleY,n.scaleX,n.scaleY,r,i),t=n=null,function(e){for(var t=-1,n=i.length,a;++t=0&&e._call.call(void 0,t),e=e._next;--ai}function xi(){fi=(di=mi.now())+pi,ai=oi=0;try{bi()}finally{ai=0,Ci(),fi=0}}function Si(){var e=mi.now(),t=e-di;t>ci&&(pi-=t,di=e)}function Ci(){for(var e,t=li,n,r=1/0;t;)t._call?(r>t._time&&(r=t._time),e=t,t=t._next):(n=t._next,t._next=null,t=e?e._next=n:li=n);ui=e,wi(r)}function wi(e){ai||(oi&&=clearTimeout(oi),e-fi>24?(e<1/0&&(oi=setTimeout(xi,e-mi.now()-pi)),si&&=clearInterval(si)):(si||=(di=mi.now(),setInterval(Si,ci)),ai=1,hi(xi)))}function Ti(e,t,n){var r=new vi;return t=t==null?0:+t,r.restart(n=>{r.stop(),e(n+t)},t,n),r}var Ei=Ce(`start`,`end`,`cancel`,`interrupt`),Di=[];function Oi(e,t,n,r,i,a){var o=e.__transition;if(!o)e.__transition={};else if(n in o)return;Mi(e,n,{name:t,index:r,group:i,on:Ei,tween:Di,time:a.time,delay:a.delay,duration:a.duration,ease:a.ease,timer:null,state:0})}function ki(e,t){var n=ji(e,t);if(n.state>0)throw Error(`too late; already scheduled`);return n}function Ai(e,t){var n=ji(e,t);if(n.state>3)throw Error(`too late; already running`);return n}function ji(e,t){var n=e.__transition;if(!n||!(n=n[t]))throw Error(`transition not found`);return n}function Mi(e,t,n){var r=e.__transition,i;r[t]=n,n.timer=yi(a,0,n.time);function a(e){n.state=1,n.timer.restart(o,n.delay,n.time),n.delay<=e&&o(e-n.delay)}function o(a){var l,u,d,f;if(n.state!==1)return c();for(l in r)if(f=r[l],f.name===n.name){if(f.state===3)return Ti(o);f.state===4?(f.state=6,f.timer.stop(),f.on.call(`interrupt`,e,e.__data__,f.index,f.group),delete r[l]):+l2&&r.state<5,r.state=6,r.timer.stop(),r.on.call(i?`interrupt`:`cancel`,e,e.__data__,r.index,r.group),delete n[o]}a&&delete e.__transition}}function Pi(e){return this.each(function(){Ni(this,e)})}function Fi(e,t){var n,r;return function(){var i=Ai(this,e),a=i.tween;if(a!==n){r=n=a;for(var o=0,s=r.length;o=0&&(e=e.slice(0,t)),!e||e===`start`})}function da(e,t,n){var r,i,a=ua(t)?ki:Ai;return function(){var o=a(this,e),s=o.on;s!==r&&(i=(r=s).copy()).on(t,n),o.on=i}}function fa(e,t){var n=this._id;return arguments.length<2?ji(this.node(),n).on.on(e):this.each(da(n,e,t))}function pa(e){return function(){var t=this.parentNode;for(var n in this.__transition)if(+n!==e)return;t&&t.removeChild(this)}}function ma(){return this.on(`end.remove`,pa(this._id))}function ha(e){var t=this._name,n=this._id;typeof e!=`function`&&(e=Pe(e));for(var r=this._groups,i=r.length,a=Array(i),o=0;o()=>e;function Ka(e,{sourceEvent:t,target:n,transform:r,dispatch:i}){Object.defineProperties(this,{type:{value:e,enumerable:!0,configurable:!0},sourceEvent:{value:t,enumerable:!0,configurable:!0},target:{value:n,enumerable:!0,configurable:!0},transform:{value:r,enumerable:!0,configurable:!0},_:{value:i}})}function qa(e,t,n){this.k=e,this.x=t,this.y=n}qa.prototype={constructor:qa,scale:function(e){return e===1?this:new qa(this.k*e,this.x,this.y)},translate:function(e,t){return e===0&t===0?this:new qa(this.k,this.x+this.k*e,this.y+this.k*t)},apply:function(e){return[e[0]*this.k+this.x,e[1]*this.k+this.y]},applyX:function(e){return e*this.k+this.x},applyY:function(e){return e*this.k+this.y},invert:function(e){return[(e[0]-this.x)/this.k,(e[1]-this.y)/this.k]},invertX:function(e){return(e-this.x)/this.k},invertY:function(e){return(e-this.y)/this.k},rescaleX:function(e){return e.copy().domain(e.range().map(this.invertX,this).map(e.invert,e))},rescaleY:function(e){return e.copy().domain(e.range().map(this.invertY,this).map(e.invert,e))},toString:function(){return`translate(`+this.x+`,`+this.y+`) scale(`+this.k+`)`}};var Ja=new qa(1,0,0);Ya.prototype=qa.prototype;function Ya(e){for(;!e.__zoom;)if(!(e=e.parentNode))return Ja;return e.__zoom}function Xa(e){e.stopImmediatePropagation()}function Za(e){e.preventDefault(),e.stopImmediatePropagation()}function Qa(e){return(!e.ctrlKey||e.type===`wheel`)&&!e.button}function $a(){var e=this;return e instanceof SVGElement?(e=e.ownerSVGElement||e,e.hasAttribute(`viewBox`)?(e=e.viewBox.baseVal,[[e.x,e.y],[e.x+e.width,e.y+e.height]]):[[0,0],[e.width.baseVal.value,e.height.baseVal.value]]):[[0,0],[e.clientWidth,e.clientHeight]]}function eo(){return this.__zoom||Ja}function to(e){return-e.deltaY*(e.deltaMode===1?.05:e.deltaMode?1:.002)*(e.ctrlKey?10:1)}function no(){return navigator.maxTouchPoints||`ontouchstart`in this}function ro(e,t,n){var r=e.invertX(t[0][0])-n[0][0],i=e.invertX(t[1][0])-n[1][0],a=e.invertY(t[0][1])-n[0][1],o=e.invertY(t[1][1])-n[1][1];return e.translate(i>r?(r+i)/2:Math.min(0,r)||Math.max(0,i),o>a?(a+o)/2:Math.min(0,a)||Math.max(0,o))}function io(){var e=Qa,t=$a,n=ro,r=to,i=no,a=[0,1/0],o=[[-1/0,-1/0],[1/0,1/0]],s=250,c=ii,l=Ce(`start`,`zoom`,`end`),u,d,f,p=500,m=150,h=0,g=10;function _(e){e.property(`__zoom`,eo).on(`wheel.zoom`,w,{passive:!1}).on(`mousedown.zoom`,T).on(`dblclick.zoom`,E).filter(i).on(`touchstart.zoom`,D).on(`touchmove.zoom`,O).on(`touchend.zoom touchcancel.zoom`,k).style(`-webkit-tap-highlight-color`,`rgba(0,0,0,0)`)}_.transform=function(e,t,n,r){var i=e.selection?e.selection():e;i.property(`__zoom`,eo),e===i?i.interrupt().each(function(){S(this,arguments).event(r).start().zoom(null,typeof t==`function`?t.apply(this,arguments):t).end()}):x(e,t,n,r)},_.scaleBy=function(e,t,n,r){_.scaleTo(e,function(){return this.__zoom.k*(typeof t==`function`?t.apply(this,arguments):t)},n,r)},_.scaleTo=function(e,r,i,a){_.transform(e,function(){var e=t.apply(this,arguments),a=this.__zoom,s=i==null?b(e):typeof i==`function`?i.apply(this,arguments):i,c=a.invert(s),l=typeof r==`function`?r.apply(this,arguments):r;return n(y(v(a,l),s,c),e,o)},i,a)},_.translateBy=function(e,r,i,a){_.transform(e,function(){return n(this.__zoom.translate(typeof r==`function`?r.apply(this,arguments):r,typeof i==`function`?i.apply(this,arguments):i),t.apply(this,arguments),o)},null,a)},_.translateTo=function(e,r,i,a,s){_.transform(e,function(){var e=t.apply(this,arguments),s=this.__zoom,c=a==null?b(e):typeof a==`function`?a.apply(this,arguments):a;return n(Ja.translate(c[0],c[1]).scale(s.k).translate(typeof r==`function`?-r.apply(this,arguments):-r,typeof i==`function`?-i.apply(this,arguments):-i),e,o)},a,s)};function v(e,t){return t=Math.max(a[0],Math.min(a[1],t)),t===e.k?e:new qa(t,e.x,e.y)}function y(e,t,n){var r=t[0]-n[0]*e.k,i=t[1]-n[1]*e.k;return r===e.x&&i===e.y?e:new qa(e.k,r,i)}function b(e){return[(+e[0][0]+ +e[1][0])/2,(+e[0][1]+ +e[1][1])/2]}function x(e,n,r,i){e.on(`start.zoom`,function(){S(this,arguments).event(i).start()}).on(`interrupt.zoom end.zoom`,function(){S(this,arguments).event(i).end()}).tween(`zoom`,function(){var e=this,a=arguments,o=S(e,a).event(i),s=t.apply(e,a),l=r==null?b(s):typeof r==`function`?r.apply(e,a):r,u=Math.max(s[1][0]-s[0][0],s[1][1]-s[0][1]),d=e.__zoom,f=typeof n==`function`?n.apply(e,a):n,p=c(d.invert(l).concat(u/d.k),f.invert(l).concat(u/f.k));return function(e){if(e===1)e=f;else{var t=p(e),n=u/t[2];e=new qa(n,l[0]-t[0]*n,l[1]-t[1]*n)}o.zoom(null,e)}})}function S(e,t,n){return!n&&e.__zooming||new C(e,t)}function C(e,n){this.that=e,this.args=n,this.active=0,this.sourceEvent=null,this.extent=t.apply(e,n),this.taps=0}C.prototype={event:function(e){return e&&(this.sourceEvent=e),this},start:function(){return++this.active===1&&(this.that.__zooming=this,this.emit(`start`)),this},zoom:function(e,t){return this.mouse&&e!==`mouse`&&(this.mouse[1]=t.invert(this.mouse[0])),this.touch0&&e!==`touch`&&(this.touch0[1]=t.invert(this.touch0[0])),this.touch1&&e!==`touch`&&(this.touch1[1]=t.invert(this.touch1[0])),this.that.__zoom=t,this.emit(`zoom`),this},end:function(){return--this.active===0&&(delete this.that.__zooming,this.emit(`end`)),this},emit:function(e){var t=Dn(this.that).datum();l.call(e,this.that,new Ka(e,{sourceEvent:this.sourceEvent,target:_,type:e,transform:this.that.__zoom,dispatch:l}),t)}};function w(t,...i){if(!e.apply(this,arguments))return;var s=S(this,i).event(t),c=this.__zoom,l=Math.max(a[0],Math.min(a[1],c.k*2**r.apply(this,arguments))),u=kn(t);if(s.wheel)(s.mouse[0][0]!==u[0]||s.mouse[0][1]!==u[1])&&(s.mouse[1]=c.invert(s.mouse[0]=u)),clearTimeout(s.wheel);else if(c.k===l)return;else s.mouse=[u,c.invert(u)],Ni(this),s.start();Za(t),s.wheel=setTimeout(d,m),s.zoom(`mouse`,n(y(v(c,l),s.mouse[0],s.mouse[1]),s.extent,o));function d(){s.wheel=null,s.end()}}function T(t,...r){if(f||!e.apply(this,arguments))return;var i=t.currentTarget,a=S(this,r,!0).event(t),s=Dn(t.view).on(`mousemove.zoom`,d,!0).on(`mouseup.zoom`,p,!0),c=kn(t,i),l=t.clientX,u=t.clientY;Pn(t.view),Xa(t),a.mouse=[c,this.__zoom.invert(c)],Ni(this),a.start();function d(e){if(Za(e),!a.moved){var t=e.clientX-l,r=e.clientY-u;a.moved=t*t+r*r>h}a.event(e).zoom(`mouse`,n(y(a.that.__zoom,a.mouse[0]=kn(e,i),a.mouse[1]),a.extent,o))}function p(e){s.on(`mousemove.zoom mouseup.zoom`,null),Fn(e.view,a.moved),Za(e),a.event(e).end()}}function E(r,...i){if(e.apply(this,arguments)){var a=this.__zoom,c=kn(r.changedTouches?r.changedTouches[0]:r,this),l=a.invert(c),u=a.k*(r.shiftKey?.5:2),d=n(y(v(a,u),c,l),t.apply(this,i),o);Za(r),s>0?Dn(this).transition().duration(s).call(x,d,c,r):Dn(this).call(_.transform,d,c,r)}}function D(t,...n){if(e.apply(this,arguments)){var r=t.touches,i=r.length,a=S(this,n,t.changedTouches.length===i).event(t),o,s,c,l;for(Xa(t),s=0;s`Seems like you have not used ${e===`svelte`?`SvelteFlowProvider`:`ReactFlowProvider`} as an ancestor. Help: https://${e}flow.dev/error#001`,error002:()=>`It looks like you've created a new nodeTypes or edgeTypes object. If this wasn't on purpose please define the nodeTypes/edgeTypes outside of the component or memoize them.`,error003:e=>`Node type "${e}" not found. Using fallback type "default".`,error004:()=>`The parent container needs a width and a height to render the graph.`,error005:()=>`Only child nodes can use a parent extent.`,error006:()=>`Can't create edge. An edge needs a source and a target.`,error007:e=>`The old edge with id=${e} does not exist.`,error009:e=>`Marker type "${e}" doesn't exist.`,error008:(e,{id:t,sourceHandle:n,targetHandle:r})=>`Couldn't create edge for ${e} handle id: "${e===`source`?n:r}", edge id: ${t}.`,error010:()=>`Handle: No node id found. Make sure to only use a Handle inside a custom Node.`,error011:e=>`Edge type "${e}" not found. Using fallback type "default".`,error012:e=>`Node with id "${e}" does not exist, it may have been removed. This can happen when a node is deleted before the "onNodeClick" handler is called.`,error013:(e=`react`)=>`It seems that you haven't loaded the styles. Please import '@xyflow/${e}/dist/style.css' or base.css to make sure everything is working properly.`,error014:()=>`useNodeConnections: No node ID found. Call useNodeConnections inside a custom Node or provide a node ID.`,error015:()=>`It seems that you are trying to drag a node that is not initialized. Please use onNodesChange as explained in the docs.`,error016:e=>`Edge with id "${e}" does not exist, it may have been removed. This can happen when an edge is deleted before the "onEdgeClick" handler is called.`},oo=[[-1/0,-1/0],[1/0,1/0]],so=[`Enter`,` `,`Escape`],co={"node.a11yDescription.default":`Press enter or space to select a node. Press delete to remove it and escape to cancel.`,"node.a11yDescription.keyboardDisabled":`Press enter or space to select a node. You can then use the arrow keys to move the node around. Press delete to remove it and escape to cancel.`,"node.a11yDescription.ariaLiveMessage":({direction:e,x:t,y:n})=>`Moved selected node ${e}. New position, x: ${t}, y: ${n}`,"edge.a11yDescription.default":`Press enter or space to select an edge. You can then press delete to remove it or escape to cancel.`,"controls.ariaLabel":`Control Panel`,"controls.zoomIn.ariaLabel":`Zoom In`,"controls.zoomOut.ariaLabel":`Zoom Out`,"controls.fitView.ariaLabel":`Fit View`,"controls.interactive.ariaLabel":`Toggle Interactivity`,"minimap.ariaLabel":`Mini Map`,"handle.ariaLabel":`Handle`},lo;(function(e){e.Strict=`strict`,e.Loose=`loose`})(lo||={});var uo;(function(e){e.Free=`free`,e.Vertical=`vertical`,e.Horizontal=`horizontal`})(uo||={});var fo;(function(e){e.Partial=`partial`,e.Full=`full`})(fo||={});var po={inProgress:!1,isValid:null,from:null,fromHandle:null,fromPosition:null,fromNode:null,to:null,toHandle:null,toPosition:null,toNode:null,pointer:null},mo;(function(e){e.Bezier=`default`,e.Straight=`straight`,e.Step=`step`,e.SmoothStep=`smoothstep`,e.SimpleBezier=`simplebezier`})(mo||={});var ho;(function(e){e.Arrow=`arrow`,e.ArrowClosed=`arrowclosed`})(ho||={});var J;(function(e){e.Left=`left`,e.Top=`top`,e.Right=`right`,e.Bottom=`bottom`})(J||={});var go={[J.Left]:J.Right,[J.Right]:J.Left,[J.Top]:J.Bottom,[J.Bottom]:J.Top};function _o(e){return e===null?null:e?`valid`:`invalid`}var vo=e=>!!e&&typeof e==`object`&&`id`in e&&`source`in e&&`target`in e,yo=e=>!!e&&typeof e==`object`&&`id`in e&&`position`in e&&!(`source`in e)&&!(`target`in e),bo=e=>!!e&&typeof e==`object`&&`id`in e&&`internals`in e&&!(`source`in e)&&!(`target`in e),xo=(e,t=[0,0])=>{let{width:n,height:r}=ts(e),i=e.origin??t,a=n*i[0],o=r*i[1];return{x:e.position.x-a,y:e.position.y-o}},So=(e,t={nodeOrigin:[0,0]})=>e.length===0?{x:0,y:0,width:0,height:0}:Lo(e.reduce((e,n)=>{let r=typeof n==`string`,i=!t.nodeLookup&&!r?n:void 0;return t.nodeLookup&&(i=r?t.nodeLookup.get(n):bo(n)?n:t.nodeLookup.get(n.id)),Fo(e,i?zo(i,t.nodeOrigin):{x:0,y:0,x2:0,y2:0})},{x:1/0,y:1/0,x2:-1/0,y2:-1/0})),Co=(e,t={})=>{let n={x:1/0,y:1/0,x2:-1/0,y2:-1/0},r=!1;return e.forEach(e=>{(t.filter===void 0||t.filter(e))&&(n=Fo(n,zo(e)),r=!0)}),r?Lo(n):{x:0,y:0,width:0,height:0}},wo=(e,t,[n,r,i]=[0,0,1],a=!1,o=!1)=>{let s=(t.x-n)/i,c=(t.y-r)/i,l=t.width/i,u=t.height/i,d=[];for(let t of e.values()){let{measured:e,selectable:n=!0,hidden:r=!1}=t;if(o&&!n||r)continue;let i=e.width??t.width??t.initialWidth??0,f=e.height??t.height??t.initialHeight??0,{x:p,y:m}=t.internals.positionAbsolute,h=Vo(s,c,l,u,p,m,i,f),g=i*f,_=a&&h>0;(!t.internals.handleBounds||_||h>=g||t.dragging)&&d.push(t)}return d},To=(e,t)=>{let n=new Set;return e.forEach(e=>{n.add(e.id)}),t.filter(e=>n.has(e.source)||n.has(e.target))};function Eo(e,t){let n=new Map,r=t?.nodes?new Set(t.nodes.map(e=>e.id)):null;return e.forEach(e=>{let i;if(t?.includeHiddenNodes){let{width:t,height:n}=ts(e);i=t>0&&n>0}else i=!!(e.measured.width&&e.measured.height&&!e.hidden);i&&(!r||r.has(e.id))&&n.set(e.id,e)}),n}async function Do({nodes:e,width:t,height:n,panZoom:r,minZoom:i,maxZoom:a},o){if(e.size===0)return!0;let s=Qo(Co(Eo(e,o)),t,n,o?.minZoom??i,o?.maxZoom??a,o?.padding??.1);return await r.setViewport(s,{duration:o?.duration,ease:o?.ease,interpolate:o?.interpolate}),!0}function Oo({nodeId:e,nextPosition:t,nodeLookup:n,nodeOrigin:r=[0,0],nodeExtent:i,onError:a}){let o=n.get(e),s=o.parentId?n.get(o.parentId):void 0,{x:c,y:l}=s?s.internals.positionAbsolute:{x:0,y:0},u=o.origin??r,d=o.extent||i;if(o.extent===`parent`&&!o.expandParent)if(!s)a?.(`005`,ao.error005());else{let e=s.measured.width,t=s.measured.height;e&&t&&(d=[[c,l],[c+e,l+t]])}else s&&es(o.extent)&&(d=[[o.extent[0][0]+c,o.extent[0][1]+l],[o.extent[1][0]+c,o.extent[1][1]+l]]);let f=es(d)?jo(t,d,o.measured):t;return(o.measured.width===void 0||o.measured.height===void 0)&&a?.(`015`,ao.error015()),{position:{x:f.x-c+(o.measured.width??0)*u[0],y:f.y-l+(o.measured.height??0)*u[1]},positionAbsolute:f}}async function ko({nodesToRemove:e=[],edgesToRemove:t=[],nodes:n,edges:r,onBeforeDelete:i}){let a=new Set(e.map(e=>e.id)),o=[];for(let e of n){if(e.deletable===!1)continue;let t=a.has(e.id),n=!t&&e.parentId&&o.find(t=>t.id===e.parentId);(t||n)&&o.push(e)}let s=new Set(t.map(e=>e.id)),c=r.filter(e=>e.deletable!==!1),l=To(o,c);for(let e of c)s.has(e.id)&&!l.find(t=>t.id===e.id)&&l.push(e);if(!i)return{edges:l,nodes:o};let u=await i({nodes:o,edges:l});return typeof u==`boolean`?u?{edges:l,nodes:o}:{edges:[],nodes:[]}:u}var Ao=(e,t=0,n=1)=>Math.min(Math.max(e,t),n),jo=(e={x:0,y:0},t,n)=>({x:Ao(e.x,t[0][0],t[1][0]-(n?.width??0)),y:Ao(e.y,t[0][1],t[1][1]-(n?.height??0))});function Mo(e,t,n){let{width:r,height:i}=ts(n),{x:a,y:o}=n.internals.positionAbsolute;return jo(e,[[a,o],[a+r,o+i]],t)}var No=(e,t,n)=>en?-Ao(Math.abs(e-n),1,t)/t:0,Po=(e,t,n=15,r=40)=>[No(e.x,r,t.width-r)*n,No(e.y,r,t.height-r)*n],Fo=(e,t)=>({x:Math.min(e.x,t.x),y:Math.min(e.y,t.y),x2:Math.max(e.x2,t.x2),y2:Math.max(e.y2,t.y2)}),Io=({x:e,y:t,width:n,height:r})=>({x:e,y:t,x2:e+n,y2:t+r}),Lo=({x:e,y:t,x2:n,y2:r})=>({x:e,y:t,width:n-e,height:r-t}),Ro=(e,t=[0,0])=>{let{x:n,y:r}=bo(e)?e.internals.positionAbsolute:xo(e,t);return{x:n,y:r,width:e.measured?.width??e.width??e.initialWidth??0,height:e.measured?.height??e.height??e.initialHeight??0}},zo=(e,t=[0,0])=>{let{x:n,y:r}=bo(e)?e.internals.positionAbsolute:xo(e,t);return{x:n,y:r,x2:n+(e.measured?.width??e.width??e.initialWidth??0),y2:r+(e.measured?.height??e.height??e.initialHeight??0)}},Bo=(e,t)=>Lo(Fo(Io(e),Io(t))),Vo=(e,t,n,r,i,a,o,s)=>{let c=Math.max(0,Math.min(e+n,i+o)-Math.max(e,i)),l=Math.max(0,Math.min(t+r,a+s)-Math.max(t,a));return Math.ceil(c*l)},Ho=(e,t)=>Vo(e.x,e.y,e.width,e.height,t.x,t.y,t.width,t.height),Uo=e=>Wo(e.width)&&Wo(e.height)&&Wo(e.x)&&Wo(e.y),Wo=e=>!isNaN(e)&&isFinite(e),Go=(e,t)=>(e,t)=>{},Ko=(e,t=[1,1])=>({x:t[0]*Math.round(e.x/t[0]),y:t[1]*Math.round(e.y/t[1])}),qo=({x:e,y:t},[n,r,i],a=!1,o=[1,1])=>{let s={x:(e-n)/i,y:(t-r)/i};return a?Ko(s,o):s},Jo=({x:e,y:t},[n,r,i])=>({x:e*i+n,y:t*i+r});function Yo(e,t){if(typeof e==`number`)return Math.floor((t-t/(1+e))*.5);if(typeof e==`string`&&e.endsWith(`px`)){let t=parseFloat(e);if(!Number.isNaN(t))return Math.floor(t)}if(typeof e==`string`&&e.endsWith(`%`)){let n=parseFloat(e);if(!Number.isNaN(n))return Math.floor(t*n*.01)}return console.error(`The padding value "${e}" is invalid. Please provide a number or a string with a valid unit (px or %).`),0}function Xo(e,t,n){if(typeof e==`string`||typeof e==`number`){let r=Yo(e,n),i=Yo(e,t);return{top:r,right:i,bottom:r,left:i,x:i*2,y:r*2}}if(typeof e==`object`){let r=Yo(e.top??e.y??0,n),i=Yo(e.bottom??e.y??0,n),a=Yo(e.left??e.x??0,t),o=Yo(e.right??e.x??0,t);return{top:r,right:o,bottom:i,left:a,x:a+o,y:r+i}}return{top:0,right:0,bottom:0,left:0,x:0,y:0}}function Zo(e,t,n,r,i,a){let{x:o,y:s}=Jo(e,[t,n,r]),{x:c,y:l}=Jo({x:e.x+e.width,y:e.y+e.height},[t,n,r]),u=i-c,d=a-l;return{left:Math.floor(o),top:Math.floor(s),right:Math.floor(u),bottom:Math.floor(d)}}var Qo=(e,t,n,r,i,a)=>{let o=Xo(a,t,n),s=(t-o.x)/e.width,c=(n-o.y)/e.height,l=Ao(Math.min(s,c),r,i),u=e.x+e.width/2,d=e.y+e.height/2,f=t/2-u*l,p=n/2-d*l,m=Zo(e,f,p,l,t,n),h={left:Math.min(m.left-o.left,0),top:Math.min(m.top-o.top,0),right:Math.min(m.right-o.right,0),bottom:Math.min(m.bottom-o.bottom,0)};return{x:f-h.left+h.right,y:p-h.top+h.bottom,zoom:l}},$o=()=>typeof navigator<`u`&&navigator?.userAgent?.indexOf(`Mac`)>=0;function es(e){return e!=null&&e!==`parent`}function ts(e){return{width:e.measured?.width??e.width??e.initialWidth??0,height:e.measured?.height??e.height??e.initialHeight??0}}function ns(e){return(e.measured?.width??e.width??e.initialWidth)!==void 0&&(e.measured?.height??e.height??e.initialHeight)!==void 0}function rs(e,t={width:0,height:0},n,r,i){let a={...e},o=r.get(n);if(o){let e=o.origin||i;a.x+=o.internals.positionAbsolute.x-(t.width??0)*e[0],a.y+=o.internals.positionAbsolute.y-(t.height??0)*e[1]}return a}function is(e,t){if(e.size!==t.size)return!1;for(let n of e)if(!t.has(n))return!1;return!0}function as(){let e,t;return{promise:new Promise((n,r)=>{e=n,t=r}),resolve:e,reject:t}}function os(e){return{...co,...e||{}}}function ss(e,{snapGrid:t=[0,0],snapToGrid:n=!1,transform:r,containerBounds:i}){let{x:a,y:o}=ps(e),s=qo({x:a-(i?.left??0),y:o-(i?.top??0)},r),{x:c,y:l}=n?Ko(s,t):s;return{xSnapped:c,ySnapped:l,...s}}var cs=e=>({width:e.offsetWidth,height:e.offsetHeight}),ls=e=>e?.getRootNode?.()||window?.document,us=[`INPUT`,`SELECT`,`TEXTAREA`];function ds(e){let t=e.composedPath?.()?.[0]||e.target;return t?.nodeType===1?us.includes(t.nodeName)||t.hasAttribute(`contenteditable`)||!!t.closest(`.nokey`):!1}var fs=e=>`clientX`in e,ps=(e,t)=>{let n=fs(e),r=n?e.clientX:e.touches?.[0].clientX,i=n?e.clientY:e.touches?.[0].clientY;return{x:r-(t?.left??0),y:i-(t?.top??0)}},ms=(e,t,n,r,i)=>{let a=t.querySelectorAll(`.${e}`);return!a||!a.length?null:Array.from(a).map(t=>{let a=t.getBoundingClientRect();return{id:t.getAttribute(`data-handleid`),type:e,nodeId:i,position:t.getAttribute(`data-handlepos`),x:(a.left-n.left)/r,y:(a.top-n.top)/r,...cs(t)}})};function hs({sourceX:e,sourceY:t,targetX:n,targetY:r,sourceControlX:i,sourceControlY:a,targetControlX:o,targetControlY:s}){let c=e*.125+i*.375+o*.375+n*.125,l=t*.125+a*.375+s*.375+r*.125;return[c,l,Math.abs(c-e),Math.abs(l-t)]}function gs(e,t){return e>=0?.5*e:t*25*Math.sqrt(-e)}function _s({pos:e,x1:t,y1:n,x2:r,y2:i,c:a}){switch(e){case J.Left:return[t-gs(t-r,a),n];case J.Right:return[t+gs(r-t,a),n];case J.Top:return[t,n-gs(n-i,a)];case J.Bottom:return[t,n+gs(i-n,a)]}}function vs({sourceX:e,sourceY:t,sourcePosition:n=J.Bottom,targetX:r,targetY:i,targetPosition:a=J.Top,curvature:o=.25}){let[s,c]=_s({pos:n,x1:e,y1:t,x2:r,y2:i,c:o}),[l,u]=_s({pos:a,x1:r,y1:i,x2:e,y2:t,c:o}),[d,f,p,m]=hs({sourceX:e,sourceY:t,targetX:r,targetY:i,sourceControlX:s,sourceControlY:c,targetControlX:l,targetControlY:u});return[`M${e},${t} C${s},${c} ${l},${u} ${r},${i}`,d,f,p,m]}function ys({sourceX:e,sourceY:t,targetX:n,targetY:r}){let i=Math.abs(n-e)/2,a=n0}var Ss=({source:e,sourceHandle:t,target:n,targetHandle:r})=>`xy-edge__${e}${t||``}-${n}${r||``}`,Cs=(e,t)=>t.some(t=>t.source===e.source&&t.target===e.target&&(t.sourceHandle===e.sourceHandle||!t.sourceHandle&&!e.sourceHandle)&&(t.targetHandle===e.targetHandle||!t.targetHandle&&!e.targetHandle)),ws=(e,t,n={})=>{if(!e.source||!e.target)return n.onError?.(`006`,ao.error006()),t;let r=n.getEdgeId||Ss,i;return i=vo(e)?{...e}:{...e,id:r(e)},Cs(i,t)?t:(i.sourceHandle===null&&delete i.sourceHandle,i.targetHandle===null&&delete i.targetHandle,t.concat(i))};function Ts({sourceX:e,sourceY:t,targetX:n,targetY:r}){let[i,a,o,s]=ys({sourceX:e,sourceY:t,targetX:n,targetY:r});return[`M ${e},${t}L ${n},${r}`,i,a,o,s]}var Es={[J.Left]:{x:-1,y:0},[J.Right]:{x:1,y:0},[J.Top]:{x:0,y:-1},[J.Bottom]:{x:0,y:1}},Ds=({source:e,sourcePosition:t=J.Bottom,target:n})=>t===J.Left||t===J.Right?e.xMath.sqrt((t.x-e.x)**2+(t.y-e.y)**2);function ks({source:e,sourcePosition:t=J.Bottom,target:n,targetPosition:r=J.Top,center:i,offset:a,stepPosition:o}){let s=Es[t],c=Es[r],l={x:e.x+s.x*a,y:e.y+s.y*a},u={x:n.x+c.x*a,y:n.y+c.y*a},d=Ds({source:l,sourcePosition:t,target:u}),f=d.x===0?`y`:`x`,p=d[f],m=[],h,g,_={x:0,y:0},v={x:0,y:0},[,,y,b]=ys({sourceX:e.x,sourceY:e.y,targetX:n.x,targetY:n.y});if(s[f]*c[f]===-1){f===`x`?(h=i.x??l.x+(u.x-l.x)*o,g=i.y??(l.y+u.y)/2):(h=i.x??(l.x+u.x)/2,g=i.y??l.y+(u.y-l.y)*o);let e=[{x:h,y:l.y},{x:h,y:u.y}],t=[{x:l.x,y:g},{x:u.x,y:g}];m=s[f]===p?f===`x`?e:t:f===`x`?t:e}else{let i=[{x:l.x,y:u.y}],o=[{x:u.x,y:l.y}];if(m=f===`x`?s.x===p?o:i:s.y===p?i:o,t===r){let t=Math.abs(e[f]-n[f]);if(t<=a){let r=Math.min(a-1,a-t);s[f]===p?_[f]=(l[f]>e[f]?-1:1)*r:v[f]=(u[f]>n[f]?-1:1)*r}}if(t!==r){let e=f===`x`?`y`:`x`,t=s[f]===c[e],n=l[e]>u[e],r=l[e]=Math.max(Math.abs(d.y-m[0].y),Math.abs(y.y-m[0].y))?(h=(d.x+y.x)/2,g=m[0].y):(h=m[0].x,g=(d.y+y.y)/2)}let x={x:l.x+_.x,y:l.y+_.y},S={x:u.x+v.x,y:u.y+v.y};return[[e,...x.x!==m[0].x||x.y!==m[0].y?[x]:[],...m,...S.x!==m[m.length-1].x||S.y!==m[m.length-1].y?[S]:[],n],h,g,y,b]}function As(e,t,n,r){let i=Math.min(Os(e,t)/2,Os(t,n)/2,r),{x:a,y:o}=t;if(e.x===a&&a===n.x||e.y===o&&o===n.y)return`L${a} ${o}`;if(e.y===o){let t=e.xe.id===t):e[0])||null}function Ls(e,t){return e?typeof e==`string`?e:`${t?`${t}__`:``}${Object.keys(e).sort().map(t=>`${t}=${e[t]}`).join(`&`)}`:``}function Rs(e,{id:t,defaultColor:n,defaultMarkerStart:r,defaultMarkerEnd:i}){let a=new Set;return e.reduce((e,o)=>([o.markerStart||r,o.markerEnd||i].forEach(r=>{if(r&&typeof r==`object`){let i=Ls(r,t);a.has(i)||(e.push({id:i,color:r.color||n,...r}),a.add(i))}}),e),[]).sort((e,t)=>e.id.localeCompare(t.id))}var zs=1e3,Bs=10,Vs={nodeOrigin:[0,0],nodeExtent:oo,elevateNodesOnSelect:!0,zIndexMode:`basic`,defaults:{}},Hs={...Vs,checkEquality:!0};function Us(e,t){let n={...e};for(let e in t)t[e]!==void 0&&(n[e]=t[e]);return n}function Ws(e,t,n){let r=Us(Vs,n);for(let n of e.values())if(n.parentId)Ys(n,e,t,r);else{let e=jo(xo(n,r.nodeOrigin),es(n.extent)?n.extent:r.nodeExtent,ts(n));n.internals.positionAbsolute=e}}function Gs(e,t){if(!e.handles)return e.measured?t?.internals.handleBounds:void 0;let n=[],r=[];for(let t of e.handles){let i={id:t.id,width:t.width??1,height:t.height??1,nodeId:e.id,x:t.x,y:t.y,position:t.position,type:t.type};t.type===`source`?n.push(i):t.type===`target`&&r.push(i)}return{source:n,target:r}}function Ks(e){return e===`manual`}function qs(e,t,n,r={}){let i=Us(Hs,r),a={i:0},o=new Map(t),s=i?.elevateNodesOnSelect&&!Ks(i.zIndexMode)?zs:0,c=e.length>0,l=!1;t.clear(),n.clear();for(let u of e){let e=o.get(u.id);if(i.checkEquality&&u===e?.internals.userNode)t.set(u.id,e);else{let n=jo(xo(u,i.nodeOrigin),es(u.extent)?u.extent:i.nodeExtent,ts(u));e={...i.defaults,...u,measured:{width:u.measured?.width,height:u.measured?.height},internals:{positionAbsolute:n,handleBounds:Gs(u,e),z:Xs(u,s,i.zIndexMode),userNode:u}},t.set(u.id,e)}(e.measured===void 0||e.measured.width===void 0||e.measured.height===void 0)&&!e.hidden&&(c=!1),u.parentId&&Ys(e,t,n,r,a),l||=u.selected??!1}return{nodesInitialized:c,hasSelectedNodes:l}}function Js(e,t){if(!e.parentId)return;let n=t.get(e.parentId);n?n.set(e.id,e):t.set(e.parentId,new Map([[e.id,e]]))}function Ys(e,t,n,r,i){let{elevateNodesOnSelect:a,nodeOrigin:o,nodeExtent:s,zIndexMode:c}=Us(Vs,r),l=e.parentId,u=t.get(l);if(!u){console.warn(`Parent node ${l} not found. Please make sure that parent nodes are in front of their child nodes in the nodes array.`);return}Js(e,n),i&&!u.parentId&&u.internals.rootParentIndex===void 0&&c===`auto`&&(u.internals.rootParentIndex=++i.i,u.internals.z=u.internals.z+i.i*Bs),i&&u.internals.rootParentIndex!==void 0&&(i.i=u.internals.rootParentIndex);let{x:d,y:f,z:p}=Zs(e,u,o,s,a&&!Ks(c)?zs:0,c),{positionAbsolute:m}=e.internals,h=d!==m.x||f!==m.y;(h||p!==e.internals.z)&&t.set(e.id,{...e,internals:{...e.internals,positionAbsolute:h?{x:d,y:f}:m,z:p}})}function Xs(e,t,n){let r=Wo(e.zIndex)?e.zIndex:0;return Ks(n)?r:r+(e.selected?t:0)}function Zs(e,t,n,r,i,a){let{x:o,y:s}=t.internals.positionAbsolute,c=ts(e),l=xo(e,n),u=es(e.extent)?jo(l,e.extent,c):l,d=jo({x:o+u.x,y:s+u.y},r,c);e.extent===`parent`&&(d=Mo(d,c,t));let f=Xs(e,i,a),p=t.internals.z??0;return{x:d.x,y:d.y,z:p>=f?p+1:f}}function Qs(e,t,n,r=[0,0]){let i=[],a=new Map;for(let n of e){let e=t.get(n.parentId);if(!e)continue;let r=Bo(a.get(n.parentId)?.expandedRect??Ro(e),n.rect);a.set(n.parentId,{expandedRect:r,parent:e})}return a.size>0&&a.forEach(({expandedRect:t,parent:a},o)=>{let s=a.internals.positionAbsolute,c=ts(a),l=a.origin??r,u=t.x0||d>0||m||h)&&(i.push({id:o,type:`position`,position:{x:a.position.x-u+m,y:a.position.y-d+h}}),n.get(o)?.forEach(t=>{e.some(e=>e.id===t.id)||i.push({id:t.id,type:`position`,position:{x:t.position.x+u,y:t.position.y+d}})})),(c.width0){let e=Qs(f,t,n,i);l.push(...e)}return{changes:l,updatedInternals:c}}async function ec({delta:e,panZoom:t,transform:n,translateExtent:r,width:i,height:a}){if(!t||!e.x&&!e.y)return!1;let o=await t.setViewportConstrained({x:n[0]+e.x,y:n[1]+e.y,zoom:n[2]},[[0,0],[i,a]],r);return!!o&&(o.x!==n[0]||o.y!==n[1]||o.k!==n[2])}function tc(e,t,n,r,i,a){let o=i,s=r.get(o)||new Map;r.set(o,s.set(n,t)),o=`${i}-${e}`;let c=r.get(o)||new Map;if(r.set(o,c.set(n,t)),a){o=`${i}-${e}-${a}`;let s=r.get(o)||new Map;r.set(o,s.set(n,t))}}function nc(e,t,n){e.clear(),t.clear();for(let r of n){let{source:n,target:i,sourceHandle:a=null,targetHandle:o=null}=r,s={edgeId:r.id,source:n,target:i,sourceHandle:a,targetHandle:o},c=`${n}-${a}--${i}-${o}`;tc(`source`,s,`${i}-${o}--${n}-${a}`,e,n,a),tc(`target`,s,c,e,i,o),t.set(r.id,r)}}function rc(e,t){if(!e.parentId)return!1;let n=t.get(e.parentId);return n?n.selected?!0:rc(n,t):!1}function ic(e,t,n){let r=e;do{if(r?.matches?.(t))return!0;if(r===n)return!1;r=r?.parentElement}while(r);return!1}function ac(e,t,n,r){let i=new Map;for(let[a,o]of e)if((o.selected||o.id===r)&&(!o.parentId||!rc(o,e))&&(o.draggable||t&&o.draggable===void 0)){let t=e.get(a);t&&i.set(a,{id:a,position:t.position||{x:0,y:0},distance:{x:n.x-t.internals.positionAbsolute.x,y:n.y-t.internals.positionAbsolute.y},extent:t.extent,parentId:t.parentId,origin:t.origin,expandParent:t.expandParent,internals:{positionAbsolute:t.internals.positionAbsolute||{x:0,y:0}},measured:{width:t.measured.width??0,height:t.measured.height??0}})}return i}function oc({nodeId:e,dragItems:t,nodeLookup:n,dragging:r=!0}){let i=[];for(let[e,a]of t){let t=n.get(e)?.internals.userNode;t&&i.push({...t,position:a.position,dragging:r})}if(!e)return[i[0],i];let a=n.get(e)?.internals.userNode;return[a?{...a,position:t.get(e)?.position||a.position,dragging:r}:i[0],i]}function sc({dragItems:e,snapGrid:t,x:n,y:r}){let i=e.values().next().value;if(!i)return null;let a={x:n-i.distance.x,y:r-i.distance.y},o=Ko(a,t);return{x:o.x-a.x,y:o.y-a.y}}function cc({onNodeMouseDown:e,getStoreItems:t,onDragStart:n,onDrag:r,onDragStop:i}){let a={x:null,y:null},o=0,s=new Map,c=!1,l={x:0,y:0},u=null,d=!1,f=null,p=!1,m=!1,h=null;function g({noDragClassName:g,handleSelector:_,domNode:v,isSelectable:y,nodeId:b,nodeClickDistance:x=0}){f=Dn(v);function S({x:e,y:n}){let{nodeLookup:i,nodeExtent:o,snapGrid:c,snapToGrid:l,nodeOrigin:u,onNodeDrag:d,onSelectionDrag:f,onError:p,updateNodePositions:g}=t();a={x:e,y:n};let _=!1,v=s.size>1,y=v&&o?Io(Co(s)):null,x=v&&l?sc({dragItems:s,snapGrid:c,x:e,y:n}):null;for(let[t,r]of s){if(!i.has(t))continue;let a={x:e-r.distance.x,y:n-r.distance.y};l&&(a=x?{x:Math.round(a.x+x.x),y:Math.round(a.y+x.y)}:Ko(a,c));let s=null;if(v&&o&&!r.extent&&y){let{positionAbsolute:e}=r.internals,t=e.x-y.x+o[0][0],n=e.x+r.measured.width-y.x2+o[1][0],i=e.y-y.y+o[0][1],a=e.y+r.measured.height-y.y2+o[1][1];s=[[t,i],[n,a]]}let{position:d,positionAbsolute:f}=Oo({nodeId:t,nextPosition:a,nodeLookup:i,nodeExtent:s||o,nodeOrigin:u,onError:p});_=_||r.position.x!==d.x||r.position.y!==d.y,r.position=d,r.internals.positionAbsolute=f}if(m||=_,_&&(g(s,!0),h&&(r||d||!b&&f))){let[e,t]=oc({nodeId:b,dragItems:s,nodeLookup:i});r?.(h,s,e,t),d?.(h,e,t),b||f?.(h,t)}}async function C(){if(!u)return;let{transform:e,panBy:n,autoPanSpeed:r,autoPanOnNodeDrag:i}=t();if(!i){c=!1,cancelAnimationFrame(o);return}let[s,d]=Po(l,u,r);(s!==0||d!==0)&&(a.x=(a.x??0)-s/e[2],a.y=(a.y??0)-d/e[2],await n({x:s,y:d})&&S(a)),o=requestAnimationFrame(C)}function w(r){let{nodeLookup:i,multiSelectionActive:o,nodesDraggable:c,transform:l,snapGrid:f,snapToGrid:p,selectNodesOnDrag:m,onNodeDragStart:h,onSelectionDragStart:g,unselectNodesAndEdges:_}=t();d=!0,(!m||!y)&&!o&&b&&(i.get(b)?.selected||_()),y&&m&&b&&e?.(b);let v=ss(r.sourceEvent,{transform:l,snapGrid:f,snapToGrid:p,containerBounds:u});if(a=v,s=ac(i,c,v,b),s.size>0&&(n||h||!b&&g)){let[e,t]=oc({nodeId:b,dragItems:s,nodeLookup:i});n?.(r.sourceEvent,s,e,t),h?.(r.sourceEvent,e,t),b||g?.(r.sourceEvent,t)}}let T=Hn().clickDistance(x).on(`start`,e=>{let{domNode:n,nodeDragThreshold:r,transform:i,snapGrid:o,snapToGrid:s}=t();u=n?.getBoundingClientRect()||null,p=!1,m=!1,h=e.sourceEvent,r===0&&w(e),a=ss(e.sourceEvent,{transform:i,snapGrid:o,snapToGrid:s,containerBounds:u}),l=ps(e.sourceEvent,u)}).on(`drag`,e=>{let{autoPanOnNodeDrag:n,transform:r,snapGrid:i,snapToGrid:o,nodeDragThreshold:f,nodeLookup:m}=t(),g=ss(e.sourceEvent,{transform:r,snapGrid:i,snapToGrid:o,containerBounds:u});if(h=e.sourceEvent,(e.sourceEvent.type===`touchmove`&&e.sourceEvent.touches.length>1||b&&!m.has(b))&&(p=!0),!p){if(!c&&n&&d&&(c=!0,C()),!d){let t=ps(e.sourceEvent,u),n=t.x-l.x,r=t.y-l.y;Math.sqrt(n*n+r*r)>f&&w(e)}(a.x!==g.xSnapped||a.y!==g.ySnapped)&&s&&d&&(l=ps(e.sourceEvent,u),S(g))}}).on(`end`,e=>{if(!d||p){p&&s.size>0&&t().updateNodePositions(s,!1);return}if(c=!1,d=!1,cancelAnimationFrame(o),s.size>0){let{nodeLookup:n,updateNodePositions:r,onNodeDragStop:a,onSelectionDragStop:o}=t();if(m&&=(r(s,!1),!1),i||a||!b&&o){let[t,r]=oc({nodeId:b,dragItems:s,nodeLookup:n,dragging:!1});i?.(e.sourceEvent,s,t,r),a?.(e.sourceEvent,t,r),b||o?.(e.sourceEvent,r)}}}).filter(e=>{let t=e.target;return!e.button&&(!g||!ic(t,`.${g}`,v))&&(!_||ic(t,_,v))});f.call(T)}function _(){f?.on(`.drag`,null)}return{update:g,destroy:_}}function lc(e,t,n){let r=[],i={x:e.x-n,y:e.y-n,width:n*2,height:n*2};for(let e of t.values())Ho(i,Ro(e))>0&&r.push(e);return r}var uc=250;function dc(e,t,n,r){let i=[],a=1/0,o=lc(e,n,t+uc);for(let n of o){let o=[...n.internals.handleBounds?.source??[],...n.internals.handleBounds?.target??[]];for(let s of o){if(r.nodeId===s.nodeId&&r.type===s.type&&r.id===s.id)continue;let{x:o,y:c}=Fs(n,s,s.position,!0),l=Math.sqrt((o-e.x)**2+(c-e.y)**2);l>t||(l1){let e=r.type===`source`?`target`:`source`;return i.find(t=>t.type===e)??i[0]}return i[0]}function fc(e,t,n,r,i,a=!1){let o=r.get(e);if(!o)return null;let s=i===`strict`?o.internals.handleBounds?.[t]:[...o.internals.handleBounds?.source??[],...o.internals.handleBounds?.target??[]],c=(n?s?.find(e=>e.id===n):s?.[0])??null;return c&&a?{...c,...Fs(o,c,c.position,!0)}:c}function pc(e,t){return e||(t?.classList.contains(`target`)?`target`:t?.classList.contains(`source`)?`source`:null)}function mc(e,t){let n=null;return t?n=!0:e&&!t&&(n=!1),n}var hc=()=>!0;function gc(e,{connectionMode:t,connectionRadius:n,handleId:r,nodeId:i,edgeUpdaterType:a,isTarget:o,domNode:s,nodeLookup:c,lib:l,autoPanOnConnect:u,flowId:d,panBy:f,cancelConnection:p,onConnectStart:m,onConnect:h,onConnectEnd:g,isValidConnection:_=hc,onReconnectEnd:v,updateConnection:y,getTransform:b,getFromHandle:x,autoPanSpeed:S,dragThreshold:C=1,handleDomNode:w}){let T=ls(e.target),E=0,D,{x:O,y:k}=ps(e),A=pc(a,w),j=s?.getBoundingClientRect(),M=!1;if(!j||!A)return;let N=fc(i,A,r,c,t);if(!N)return;let P=ps(e,j),F=!1,I=null,L=!1,R=null;function z(){if(!u||!j)return;let[e,t]=Po(P,j,S);f({x:e,y:t}),E=requestAnimationFrame(z)}let B={...N,nodeId:i,type:A,position:N.position},V=c.get(i),H={inProgress:!0,isValid:null,from:Fs(V,B,J.Left,!0),fromHandle:B,fromPosition:B.position,fromNode:V,to:P,toHandle:null,toPosition:go[B.position],toNode:null,pointer:P};function ee(){M=!0,y(H),m?.(e,{nodeId:i,handleId:r,handleType:A})}C===0&&ee();function te(e){if(!M){let{x:t,y:n}=ps(e),r=t-O,i=n-k;if(!(r*r+i*i>C*C))return;ee()}if(!x()||!B){U(e);return}let a=b();P=ps(e,j),D=dc(qo(P,a,!1,[1,1]),n,c,B),F||=(z(),!0);let s=_c(e,{handle:D,connectionMode:t,fromNodeId:i,fromHandleId:r,fromType:o?`target`:`source`,isValidConnection:_,doc:T,lib:l,flowId:d,nodeLookup:c});R=s.handleDomNode,I=s.connection,L=mc(!!D,s.isValid);let u=c.get(i),f=u?Fs(u,B,J.Left,!0):H.from,p={...H,from:f,isValid:L,to:s.toHandle&&L?Jo({x:s.toHandle.x,y:s.toHandle.y},a):P,toHandle:s.toHandle,toPosition:L&&s.toHandle?s.toHandle.position:go[B.position],toNode:s.toHandle?c.get(s.toHandle.nodeId):null,pointer:P};y(p),H=p}function U(e){if(!(`touches`in e&&e.touches.length>0)){if(M){(D||R)&&I&&L&&h?.(I);let{inProgress:t,...n}=H,r={...n,toPosition:H.toHandle?H.toPosition:null};g?.(e,r),a&&v?.(e,r)}p(),cancelAnimationFrame(E),F=!1,L=!1,I=null,R=null,T.removeEventListener(`mousemove`,te),T.removeEventListener(`mouseup`,U),T.removeEventListener(`touchmove`,te),T.removeEventListener(`touchend`,U)}}T.addEventListener(`mousemove`,te),T.addEventListener(`mouseup`,U),T.addEventListener(`touchmove`,te),T.addEventListener(`touchend`,U)}function _c(e,{handle:t,connectionMode:n,fromNodeId:r,fromHandleId:i,fromType:a,doc:o,lib:s,flowId:c,isValidConnection:l=hc,nodeLookup:u}){let d=a===`target`,f=t?o.querySelector(`.${s}-flow__handle[data-id="${c}-${t?.nodeId}-${t?.id}-${t?.type}"]`):null,{x:p,y:m}=ps(e),h=o.elementFromPoint(p,m),g=h?.classList.contains(`${s}-flow__handle`)?h:f,_={handleDomNode:g,isValid:!1,connection:null,toHandle:null};if(g){let e=pc(void 0,g),t=g.getAttribute(`data-nodeid`),a=g.getAttribute(`data-handleid`),o=g.classList.contains(`connectable`),s=g.classList.contains(`connectableend`);if(!t||!e)return _;let c={source:d?t:r,sourceHandle:d?a:i,target:d?r:t,targetHandle:d?i:a};_.connection=c,_.isValid=o&&s&&(n===lo.Strict?d&&e===`source`||!d&&e===`target`:t!==r||a!==i)&&l(c),_.toHandle=fc(t,e,a,u,n,!0)}return _}var vc={onPointerDown:gc,isValid:_c};function yc({domNode:e,panZoom:t,getTransform:n,getViewScale:r}){let i=Dn(e);function a({translateExtent:e,width:a,height:o,zoomStep:s=1,pannable:c=!0,zoomable:l=!0,inversePan:u=!1}){let d=e=>{if(e.sourceEvent.type!==`wheel`||!t)return;let r=n(),i=e.sourceEvent.ctrlKey&&$o()?10:1,a=-e.sourceEvent.deltaY*(e.sourceEvent.deltaMode===1?.05:e.sourceEvent.deltaMode?1:.002)*s,o=r[2]*2**(a*i);t.scaleTo(o)},f=[0,0],p=io().on(`start`,e=>{(e.sourceEvent.type===`mousedown`||e.sourceEvent.type===`touchstart`)&&(f=[e.sourceEvent.clientX??e.sourceEvent.touches[0].clientX,e.sourceEvent.clientY??e.sourceEvent.touches[0].clientY])}).on(`zoom`,c?i=>{let s=n();if(i.sourceEvent.type!==`mousemove`&&i.sourceEvent.type!==`touchmove`||!t)return;let c=[i.sourceEvent.clientX??i.sourceEvent.touches[0].clientX,i.sourceEvent.clientY??i.sourceEvent.touches[0].clientY],l=[c[0]-f[0],c[1]-f[1]];f=c;let d=r()*Math.max(s[2],Math.log(s[2]))*(u?-1:1),p={x:s[0]-l[0]*d,y:s[1]-l[1]*d},m=[[0,0],[a,o]];t.setViewportConstrained({x:p.x,y:p.y,zoom:s[2]},m,e)}:null).on(`zoom.wheel`,l?d:null);i.call(p,{})}function o(){i.on(`zoom`,null)}return{update:a,destroy:o,pointer:kn}}var bc=e=>({x:e.x,y:e.y,zoom:e.k}),xc=({x:e,y:t,zoom:n})=>Ja.translate(e,t).scale(n),Sc=(e,t)=>e.target.closest(`.${t}`),Cc=(e,t)=>t===2&&Array.isArray(e)&&e.includes(2),wc=e=>((e*=2)<=1?e*e*e:(e-=2)*e*e+2)/2,Tc=(e,t=0,n=wc,r=()=>{})=>{let i=typeof t==`number`&&t>0;return i||r(),i?e.transition().duration(t).ease(n).on(`end`,r):e},Ec=e=>{let t=e.ctrlKey&&$o()?10:1;return-e.deltaY*(e.deltaMode===1?.05:e.deltaMode?1:.002)*t};function Dc({zoomPanValues:e,noWheelClassName:t,d3Selection:n,d3Zoom:r,panOnScrollMode:i,panOnScrollSpeed:a,zoomOnPinch:o,onPanZoomStart:s,onPanZoom:c,onPanZoomEnd:l}){return u=>{if(Sc(u,t))return u.ctrlKey&&u.preventDefault(),!1;u.preventDefault(),u.stopImmediatePropagation();let d=n.property(`__zoom`).k||1;if(u.ctrlKey&&o){let e=kn(u),t=d*2**Ec(u);r.scaleTo(n,t,e,u);return}let f=u.deltaMode===1?20:1,p=i===uo.Vertical?0:u.deltaX*f,m=i===uo.Horizontal?0:u.deltaY*f;!$o()&&u.shiftKey&&i!==uo.Vertical&&(p=u.deltaY*f,m=0),r.translateBy(n,-(p/d)*a,-(m/d)*a,{internal:!0});let h=bc(n.property(`__zoom`));clearTimeout(e.panScrollTimeout),e.isPanScrolling?c?.(u,h):(e.isPanScrolling=!0,s?.(u,h)),e.panScrollTimeout=setTimeout(()=>{l?.(u,h),e.isPanScrolling=!1},150)}}function Oc({noWheelClassName:e,preventScrolling:t,d3ZoomHandler:n}){return function(r,i){let a=r.type===`wheel`,o=!t&&a&&!r.ctrlKey,s=Sc(r,e);if(r.ctrlKey&&a&&s&&r.preventDefault(),o||s)return null;r.preventDefault(),n.call(this,r,i)}}function kc({zoomPanValues:e,onDraggingChange:t,onPanZoomStart:n}){return r=>{if(r.sourceEvent?.internal)return;let i=bc(r.transform);e.mouseButton=r.sourceEvent?.button||0,e.isZoomingOrPanning=!0,e.prevViewport=i,r.sourceEvent?.type===`mousedown`&&t(!0),n&&n?.(r.sourceEvent,i)}}function Ac({zoomPanValues:e,panOnDrag:t,onPaneContextMenu:n,onTransformChange:r,onPanZoom:i}){return a=>{e.usedRightMouseButton=!!(n&&Cc(t,e.mouseButton??0)),a.sourceEvent?.sync||r([a.transform.x,a.transform.y,a.transform.k]),i&&!a.sourceEvent?.internal&&i?.(a.sourceEvent,bc(a.transform))}}function jc({zoomPanValues:e,panOnDrag:t,panOnScroll:n,onDraggingChange:r,onPanZoomEnd:i,onPaneContextMenu:a}){return o=>{if(!o.sourceEvent?.internal&&(e.isZoomingOrPanning=!1,a&&Cc(t,e.mouseButton??0)&&!e.usedRightMouseButton&&o.sourceEvent&&a(o.sourceEvent),e.usedRightMouseButton=!1,r(!1),i)){let t=bc(o.transform);e.prevViewport=t,clearTimeout(e.timerId),e.timerId=setTimeout(()=>{i?.(o.sourceEvent,t)},n?150:0)}}}function Mc({zoomActivationKeyPressed:e,zoomOnScroll:t,zoomOnPinch:n,panOnDrag:r,panOnScroll:i,zoomOnDoubleClick:a,userSelectionActive:o,noWheelClassName:s,noPanClassName:c,lib:l,connectionInProgress:u}){return d=>{let f=e||t,p=n&&d.ctrlKey,m=d.type===`wheel`;if(d.button===1&&d.type===`mousedown`&&(Sc(d,`${l}-flow__node`)||Sc(d,`${l}-flow__edge`)))return!0;if(!r&&!f&&!i&&!a&&!n||o||u&&!m||Sc(d,s)&&m||Sc(d,c)&&(!m||i&&m&&!e)||!n&&d.ctrlKey&&m)return!1;if(!n&&d.type===`touchstart`&&d.touches?.length>1)return d.preventDefault(),!1;if(!f&&!i&&!p&&m||!r&&(d.type===`mousedown`||d.type===`touchstart`)||Array.isArray(r)&&!r.includes(d.button)&&d.type===`mousedown`)return!1;let h=Array.isArray(r)&&r.includes(d.button)||!d.button||d.button<=1;return(!d.ctrlKey||m)&&h}}function Nc({domNode:e,minZoom:t,maxZoom:n,translateExtent:r,viewport:i,onPanZoom:a,onPanZoomStart:o,onPanZoomEnd:s,onDraggingChange:c}){let l={isZoomingOrPanning:!1,usedRightMouseButton:!1,prevViewport:{},mouseButton:0,timerId:void 0,panScrollTimeout:void 0,isPanScrolling:!1},u=e.getBoundingClientRect(),d=[[0,0],[u.width,u.height]];(typeof ResizeObserver<`u`?new ResizeObserver(e=>{let t=e[0];t&&(d=[[0,0],[t.contentRect.width,t.contentRect.height]])}):null)?.observe(e);let f=io().extent(()=>d).scaleExtent([t,n]).translateExtent(r),p=Dn(e).call(f);y({x:i.x,y:i.y,zoom:Ao(i.zoom,t,n)},[[0,0],[u.width,u.height]],r);let m=p.on(`wheel.zoom`),h=p.on(`dblclick.zoom`);f.wheelDelta(Ec);async function g(e,t){return p?new Promise(n=>{f?.interpolate(t?.interpolate===`linear`?Wr:ii).transform(Tc(p,t?.duration,t?.ease,()=>n(!0)),e)}):!1}function _({noWheelClassName:e,noPanClassName:t,onPaneContextMenu:n,userSelectionActive:r,panOnScroll:i,panOnDrag:u,panOnScrollMode:d,panOnScrollSpeed:g,preventScrolling:_,zoomOnPinch:y,zoomOnScroll:b,zoomOnDoubleClick:x,zoomActivationKeyPressed:S,lib:C,onTransformChange:w,connectionInProgress:T,paneClickDistance:E,selectionOnDrag:D}){r&&!l.isZoomingOrPanning&&v();let O=i&&!S&&!r;f.clickDistance(D?1/0:!Wo(E)||E<0?0:E);let k=O?Dc({zoomPanValues:l,noWheelClassName:e,d3Selection:p,d3Zoom:f,panOnScrollMode:d,panOnScrollSpeed:g,zoomOnPinch:y,onPanZoomStart:o,onPanZoom:a,onPanZoomEnd:s}):Oc({noWheelClassName:e,preventScrolling:_,d3ZoomHandler:m});p.on(`wheel.zoom`,k,{passive:!1});let A=kc({zoomPanValues:l,onDraggingChange:c,onPanZoomStart:o});f.on(`start`,A);let j=Ac({zoomPanValues:l,panOnDrag:u,onPaneContextMenu:!!n,onPanZoom:a,onTransformChange:w});f.on(`zoom`,j);let M=jc({zoomPanValues:l,panOnDrag:u,panOnScroll:i,onPaneContextMenu:n,onPanZoomEnd:s,onDraggingChange:c});f.on(`end`,M);let N=Mc({zoomActivationKeyPressed:S,panOnDrag:u,zoomOnScroll:b,panOnScroll:i,zoomOnDoubleClick:x,zoomOnPinch:y,userSelectionActive:r,noPanClassName:t,noWheelClassName:e,lib:C,connectionInProgress:T});f.filter(N),x?p.on(`dblclick.zoom`,h):p.on(`dblclick.zoom`,null)}function v(){f.on(`zoom`,null)}async function y(e,t,n){let r=xc(e),i=f?.constrain()(r,t,n);return i&&await g(i),i}async function b(e,t){let n=xc(e);return await g(n,t),n}function x(e){if(p){let t=xc(e),n=p.property(`__zoom`);(n.k!==e.zoom||n.x!==e.x||n.y!==e.y)&&f?.transform(p,t,null,{sync:!0})}}function S(){let e=p?Ya(p.node()):{x:0,y:0,k:1};return{x:e.x,y:e.y,zoom:e.k}}async function C(e,t){return p?new Promise(n=>{f?.interpolate(t?.interpolate===`linear`?Wr:ii).scaleTo(Tc(p,t?.duration,t?.ease,()=>n(!0)),e)}):!1}async function w(e,t){return p?new Promise(n=>{f?.interpolate(t?.interpolate===`linear`?Wr:ii).scaleBy(Tc(p,t?.duration,t?.ease,()=>n(!0)),e)}):!1}function T(e){f?.scaleExtent(e)}function E(e){f?.translateExtent(e)}function D(e){let t=!Wo(e)||e<0?0:e;f?.clickDistance(t)}return{update:_,destroy:v,setViewport:b,setViewportConstrained:y,getViewport:S,scaleTo:C,scaleBy:w,setScaleExtent:T,setTranslateExtent:E,syncViewport:x,setClickDistance:D}}var Pc;(function(e){e.Line=`line`,e.Handle=`handle`})(Pc||={});function Fc({width:e,prevWidth:t,height:n,prevHeight:r,affectsX:i,affectsY:a}){let o=e-t,s=n-r,c=[o>0?1:o<0?-1:0,s>0?1:s<0?-1:0];return o&&i&&(c[0]*=-1),s&&a&&(c[1]*=-1),c}function Ic(e){return{isHorizontal:e.includes(`right`)||e.includes(`left`),isVertical:e.includes(`bottom`)||e.includes(`top`),affectsX:e.includes(`left`),affectsY:e.includes(`top`)}}function Lc(e,t){return Math.max(0,t-e)}function Rc(e,t){return Math.max(0,e-t)}function zc(e,t,n){return Math.max(0,t-e,e-n)}function Bc(e,t){return e?!t:t}function Vc(e,t,n,r,i,a,o,s){let{affectsX:c,affectsY:l}=t,{isHorizontal:u,isVertical:d}=t,f=u&&d,{xSnapped:p,ySnapped:m}=n,{minWidth:h,maxWidth:g,minHeight:_,maxHeight:v}=r,{x:y,y:b,width:x,height:S,aspectRatio:C}=e,w=Math.floor(u?p-e.pointerX:0),T=Math.floor(d?m-e.pointerY:0),E=x+(c?-w:w),D=S+(l?-T:T),O=-a[0]*x,k=-a[1]*S,A=zc(E,h,g),j=zc(D,_,v);if(o){let e=0,t=0;c&&w<0?e=Lc(y+w+O,o[0][0]):!c&&w>0&&(e=Rc(y+E+O,o[1][0])),l&&T<0?t=Lc(b+T+k,o[0][1]):!l&&T>0&&(t=Rc(b+D+k,o[1][1])),A=Math.max(A,e),j=Math.max(j,t)}if(s){let e=0,t=0;c&&w>0?e=Rc(y+w,s[0][0]):!c&&w<0&&(e=Lc(y+E,s[1][0])),l&&T>0?t=Rc(b+T,s[0][1]):!l&&T<0&&(t=Lc(b+D,s[1][1])),A=Math.max(A,e),j=Math.max(j,t)}if(i){if(u){let e=zc(E/C,_,v)*C;if(A=Math.max(A,e),o){let e=0;e=!c&&!l||c&&!l&&f?Rc(b+k+E/C,o[1][1])*C:Lc(b+k+(c?w:-w)/C,o[0][1])*C,A=Math.max(A,e)}if(s){let e=0;e=!c&&!l||c&&!l&&f?Lc(b+E/C,s[1][1])*C:Rc(b+(c?w:-w)/C,s[0][1])*C,A=Math.max(A,e)}}if(d){let e=zc(D*C,h,g)/C;if(j=Math.max(j,e),o){let e=0;e=!c&&!l||l&&!c&&f?Rc(y+D*C+O,o[1][0])/C:Lc(y+(l?T:-T)*C+O,o[0][0])/C,j=Math.max(j,e)}if(s){let e=0;e=!c&&!l||l&&!c&&f?Lc(y+D*C,s[1][0])/C:Rc(y+(l?T:-T)*C,s[0][0])/C,j=Math.max(j,e)}}}T+=T<0?j:-j,w+=w<0?A:-A,i&&(f?E>D*C?T=(Bc(c,l)?-w:w)/C:w=(Bc(c,l)?-T:T)*C:u?(T=w/C,l=c):(w=T*C,c=l));let M=c?y+w:y,N=l?b+T:b;return{width:x+(c?-w:w),height:S+(l?-T:T),x:a[0]*w*(c?-1:1)+M,y:a[1]*T*(l?-1:1)+N}}var Hc={width:0,height:0,x:0,y:0},Uc={...Hc,pointerX:0,pointerY:0,aspectRatio:1};function Wc(e,t,n){let r=t.position.x+e.position.x,i=t.position.y+e.position.y,a=e.measured.width??0,o=e.measured.height??0,s=n[0]*a,c=n[1]*o;return[[r-s,i-c],[r+a-s,i+o-c]]}function Gc({domNode:e,nodeId:t,getStoreItems:n,onChange:r,onEnd:i}){let a=Dn(e),o={controlDirection:Ic(`bottom-right`),boundaries:{minWidth:0,minHeight:0,maxWidth:Number.MAX_VALUE,maxHeight:Number.MAX_VALUE},resizeDirection:void 0,keepAspectRatio:!1};function s({controlPosition:e,boundaries:s,keepAspectRatio:c,resizeDirection:l,onResizeStart:u,onResize:d,onResizeEnd:f,shouldResize:p}){let m={...Hc},h={...Uc};o={boundaries:s,resizeDirection:l,keepAspectRatio:c,controlDirection:Ic(e)};let g,_=null,v=[],y,b,x,S=!1,C=Hn().on(`start`,e=>{let{nodeLookup:r,transform:i,snapGrid:a,snapToGrid:o,nodeOrigin:s,paneDomNode:c}=n();if(g=r.get(t),!g)return;_=c?.getBoundingClientRect()??null;let{xSnapped:l,ySnapped:d}=ss(e.sourceEvent,{transform:i,snapGrid:a,snapToGrid:o,containerBounds:_});m={width:g.measured.width??0,height:g.measured.height??0,x:g.position.x??0,y:g.position.y??0},h={...m,pointerX:l,pointerY:d,aspectRatio:m.width/m.height},y=void 0,b=es(g.extent)?g.extent:void 0,g.parentId&&(g.extent===`parent`||g.expandParent)&&(y=r.get(g.parentId)),y&&g.extent===`parent`&&(b=[[0,0],[y.measured.width,y.measured.height]]),v=[],x=void 0;for(let[e,n]of r)if(n.parentId===t&&(v.push({id:e,position:{...n.position},extent:n.extent}),n.extent===`parent`||n.expandParent)){let e=Wc(n,g,n.origin??s);x=x?[[Math.min(e[0][0],x[0][0]),Math.min(e[0][1],x[0][1])],[Math.max(e[1][0],x[1][0]),Math.max(e[1][1],x[1][1])]]:e}u?.(e,{...m})}).on(`drag`,e=>{let{transform:t,snapGrid:i,snapToGrid:a,nodeOrigin:s}=n(),c=ss(e.sourceEvent,{transform:t,snapGrid:i,snapToGrid:a,containerBounds:_}),l=[];if(!g)return;let{x:u,y:f,width:C,height:w}=m,T={},E=g.origin??s,{width:D,height:O,x:k,y:A}=Vc(h,o.controlDirection,c,o.boundaries,o.keepAspectRatio,E,b,x),j=D!==C,M=O!==w,N=k!==u&&j,P=A!==f&&M;if(!N&&!P&&!j&&!M)return;if((N||P||E[0]===1||E[1]===1)&&(T.x=N?k:m.x,T.y=P?A:m.y,m.x=T.x,m.y=T.y,v.length>0)){let e=k-u,t=A-f;for(let n of v)n.position={x:n.position.x-e+E[0]*(D-C),y:n.position.y-t+E[1]*(O-w)},l.push(n)}if((j||M)&&(T.width=j&&(!o.resizeDirection||o.resizeDirection===`horizontal`)?D:m.width,T.height=M&&(!o.resizeDirection||o.resizeDirection===`vertical`)?O:m.height,m.width=T.width,m.height=T.height),y&&g.expandParent){let e=E[0]*(T.width??0);T.x&&T.x{S&&=(f?.(e,{...m}),i?.({...m}),!1)});a.call(C)}function c(){a.on(`.drag`,null)}return{update:s,destroy:c}}var Kc=r(O(),1),{useDebugValue:qc}=W.default,{useSyncExternalStoreWithSelector:Jc}=Kc.default,Yc=e=>e;function Xc(e,t=Yc,n){let r=Jc(e.subscribe,e.getState,e.getServerState||e.getInitialState,t,n);return qc(r),r}var Zc=(e,t)=>{let n=P(e),r=(e,r=t)=>Xc(n,e,r);return Object.assign(r,n),r},Qc=(e,t)=>e?Zc(e,t):Zc;function Y(e,t){if(Object.is(e,t))return!0;if(typeof e!=`object`||!e||typeof t!=`object`||!t)return!1;if(e instanceof Map&&t instanceof Map){if(e.size!==t.size)return!1;for(let[n,r]of e)if(!Object.is(r,t.get(n)))return!1;return!0}if(e instanceof Set&&t instanceof Set){if(e.size!==t.size)return!1;for(let n of e)if(!t.has(n))return!1;return!0}let n=Object.keys(e);if(n.length!==Object.keys(t).length)return!1;for(let r of n)if(!Object.prototype.hasOwnProperty.call(t,r)||!Object.is(e[r],t[r]))return!1;return!0}E();var $c=(0,W.createContext)(null),el=$c.Provider,tl=ao.error001(`react`);function X(e,t){let n=(0,W.useContext)($c);if(n===null)throw Error(tl);return Xc(n,e,t)}function Z(){let e=(0,W.useContext)($c);if(e===null)throw Error(tl);return(0,W.useMemo)(()=>({getState:e.getState,setState:e.setState,subscribe:e.subscribe}),[e])}var nl={display:`none`},rl={position:`absolute`,width:1,height:1,margin:-1,border:0,padding:0,overflow:`hidden`,clip:`rect(0px, 0px, 0px, 0px)`,clipPath:`inset(100%)`},il=`react-flow__node-desc`,al=`react-flow__edge-desc`,ol=`react-flow__aria-live`,sl=e=>e.ariaLiveMessage,cl=e=>e.ariaLabelConfig;function ll({rfId:e}){let t=X(sl);return(0,G.jsx)(`div`,{id:`${ol}-${e}`,"aria-live":`assertive`,"aria-atomic":`true`,style:rl,children:t})}function ul({rfId:e,disableKeyboardA11y:t}){let n=X(cl);return(0,G.jsxs)(G.Fragment,{children:[(0,G.jsx)(`div`,{id:`${il}-${e}`,style:nl,children:t?n[`node.a11yDescription.default`]:n[`node.a11yDescription.keyboardDisabled`]}),(0,G.jsx)(`div`,{id:`${al}-${e}`,style:nl,children:n[`edge.a11yDescription.default`]}),!t&&(0,G.jsx)(ll,{rfId:e})]})}var dl=(0,W.forwardRef)(({position:e=`top-left`,children:t,className:n,style:r,...i},a)=>(0,G.jsx)(`div`,{className:K([`react-flow__panel`,n,...`${e}`.split(`-`)]),style:r,ref:a,...i,children:t}));dl.displayName=`Panel`;var fl=`https://reactflow.dev?utm_source=attribution`;function pl({proOptions:e,position:t=`bottom-right`}){return e?.hideAttribution?null:(0,G.jsx)(dl,{position:t,className:`react-flow__attribution`,"data-message":`Please only hide this attribution when you are subscribed to React Flow Pro: ${fl}`,children:(0,G.jsx)(`a`,{href:fl,target:`_blank`,rel:`noopener noreferrer`,"aria-label":`React Flow attribution`,children:`React Flow`})})}var ml=e=>{let t=[],n=[];for(let[,n]of e.nodeLookup)n.selected&&t.push(n.internals.userNode);for(let[,t]of e.edgeLookup)t.selected&&n.push(t);return{selectedNodes:t,selectedEdges:n}},hl=e=>e.id;function gl(e,t){return Y(e.selectedNodes.map(hl),t.selectedNodes.map(hl))&&Y(e.selectedEdges.map(hl),t.selectedEdges.map(hl))}function _l({onSelectionChange:e}){let t=Z(),{selectedNodes:n,selectedEdges:r}=X(ml,gl);return(0,W.useEffect)(()=>{let i={nodes:n,edges:r};e?.(i),t.getState().onSelectionChangeHandlers.forEach(e=>e(i))},[n,r,e]),null}var vl=e=>!!e.onSelectionChangeHandlers;function yl({onSelectionChange:e}){let t=X(vl);return e||t?(0,G.jsx)(_l,{onSelectionChange:e}):null}var bl=[0,0],xl={x:0,y:0,zoom:1},Sl=[...`nodes.edges.defaultNodes.defaultEdges.onConnect.onConnectStart.onConnectEnd.onClickConnectStart.onClickConnectEnd.nodesDraggable.autoPanOnNodeFocus.nodesConnectable.nodesFocusable.edgesFocusable.edgesReconnectable.elevateNodesOnSelect.elevateEdgesOnSelect.minZoom.maxZoom.nodeExtent.onNodesChange.onEdgesChange.elementsSelectable.connectionMode.snapGrid.snapToGrid.translateExtent.connectOnClick.defaultEdgeOptions.fitView.fitViewOptions.onNodesDelete.onEdgesDelete.onDelete.onNodeDrag.onNodeDragStart.onNodeDragStop.onSelectionDrag.onSelectionDragStart.onSelectionDragStop.onMoveStart.onMove.onMoveEnd.noPanClassName.nodeOrigin.autoPanOnConnect.autoPanOnNodeDrag.onError.connectionRadius.isValidConnection.selectNodesOnDrag.nodeDragThreshold.connectionDragThreshold.onBeforeDelete.debug.autoPanSpeed.ariaLabelConfig.zIndexMode`.split(`.`),`rfId`],Cl=e=>({setNodes:e.setNodes,setEdges:e.setEdges,setMinZoom:e.setMinZoom,setMaxZoom:e.setMaxZoom,setTranslateExtent:e.setTranslateExtent,setNodeExtent:e.setNodeExtent,reset:e.reset,setDefaultNodesAndEdges:e.setDefaultNodesAndEdges}),wl={translateExtent:oo,nodeOrigin:bl,minZoom:.5,maxZoom:2,elementsSelectable:!0,noPanClassName:`nopan`,rfId:`1`};function Tl(e){let{setNodes:t,setEdges:n,setMinZoom:r,setMaxZoom:i,setTranslateExtent:a,setNodeExtent:o,reset:s,setDefaultNodesAndEdges:c}=X(Cl,Y),l=Z();(0,W.useEffect)(()=>(c(e.defaultNodes,e.defaultEdges),()=>{u.current=wl,s()}),[]);let u=(0,W.useRef)(wl);return(0,W.useEffect)(()=>{for(let s of Sl){let c=e[s];c!==u.current[s]&&e[s]!==void 0&&(s===`nodes`?t(c):s===`edges`?n(c):s===`minZoom`?r(c):s===`maxZoom`?i(c):s===`translateExtent`?a(c):s===`nodeExtent`?o(c):s===`ariaLabelConfig`?l.setState({ariaLabelConfig:os(c)}):s===`fitView`?l.setState({fitViewQueued:c}):s===`fitViewOptions`?l.setState({fitViewOptions:c}):l.setState({[s]:c}))}u.current=e},Sl.map(t=>e[t])),null}function El(){return typeof window>`u`||!window.matchMedia?null:window.matchMedia(`(prefers-color-scheme: dark)`)}function Dl(e){let[t,n]=(0,W.useState)(e===`system`?null:e);return(0,W.useEffect)(()=>{if(e!==`system`){n(e);return}let t=El(),r=()=>n(t?.matches?`dark`:`light`);return r(),t?.addEventListener(`change`,r),()=>{t?.removeEventListener(`change`,r)}},[e]),t===null?El()?.matches?`dark`:`light`:t}var Ol=typeof document<`u`?document:null;function kl(e=null,t={target:Ol,actInsideInputWithModifier:!0}){let[n,r]=(0,W.useState)(!1),i=(0,W.useRef)(!1),a=(0,W.useRef)(new Set([])),[o,s]=(0,W.useMemo)(()=>{if(e!==null){let t=(Array.isArray(e)?e:[e]).filter(e=>typeof e==`string`).map(e=>e.replace(`+`,` -`).replace(` - -`,` -+`).split(` -`));return[t,t.reduce((e,t)=>e.concat(...t),[])]}return[[],[]]},[e]);return(0,W.useEffect)(()=>{let n=t?.target??Ol,c=t?.actInsideInputWithModifier??!0;if(e!==null){let e=e=>{if(i.current=e.ctrlKey||e.metaKey||e.shiftKey||e.altKey,(!i.current||i.current&&!c)&&ds(e))return!1;let n=jl(e.code,s);if(a.current.add(e[n]),Al(o,a.current,!1)){let n=e.composedPath?.()?.[0]||e.target,a=n?.nodeName===`BUTTON`||n?.nodeName===`A`;t.preventDefault!==!1&&(i.current||!a)&&e.preventDefault(),r(!0)}},l=e=>{let t=jl(e.code,s);Al(o,a.current,!0)?(r(!1),a.current.clear()):a.current.delete(e[t]),e.key===`Meta`&&a.current.clear(),i.current=!1},u=()=>{a.current.clear(),r(!1)};return n?.addEventListener(`keydown`,e),n?.addEventListener(`keyup`,l),window.addEventListener(`blur`,u),window.addEventListener(`contextmenu`,u),()=>{n?.removeEventListener(`keydown`,e),n?.removeEventListener(`keyup`,l),window.removeEventListener(`blur`,u),window.removeEventListener(`contextmenu`,u)}}},[e,r]),n}function Al(e,t,n){return e.filter(e=>n||e.length===t.size).some(e=>e.every(e=>t.has(e)))}function jl(e,t){return t.includes(e)?`code`:`key`}var Ml=()=>{let e=Z();return(0,W.useMemo)(()=>({zoomIn:async t=>{let{panZoom:n}=e.getState();return n?n.scaleBy(1.2,t):!1},zoomOut:async t=>{let{panZoom:n}=e.getState();return n?n.scaleBy(1/1.2,t):!1},zoomTo:async(t,n)=>{let{panZoom:r}=e.getState();return r?r.scaleTo(t,n):!1},getZoom:()=>e.getState().transform[2],setViewport:async(t,n)=>{let{transform:[r,i,a],panZoom:o}=e.getState();return o?(await o.setViewport({x:t.x??r,y:t.y??i,zoom:t.zoom??a},n),!0):!1},getViewport:()=>{let[t,n,r]=e.getState().transform;return{x:t,y:n,zoom:r}},setCenter:async(t,n,r)=>e.getState().setCenter(t,n,r),fitBounds:async(t,n)=>{let{width:r,height:i,minZoom:a,maxZoom:o,panZoom:s}=e.getState(),c=Qo(t,r,i,a,o,n?.padding??.1);return s?(await s.setViewport(c,{duration:n?.duration,ease:n?.ease,interpolate:n?.interpolate}),!0):!1},screenToFlowPosition:(t,n={})=>{let{transform:r,snapGrid:i,snapToGrid:a,domNode:o}=e.getState();if(!o)return t;let{x:s,y:c}=o.getBoundingClientRect(),l={x:t.x-s,y:t.y-c},u=n.snapGrid??i;return qo(l,r,n.snapToGrid??a,u)},flowToScreenPosition:t=>{let{transform:n,domNode:r}=e.getState();if(!r)return t;let{x:i,y:a}=r.getBoundingClientRect(),o=Jo(t,n);return{x:o.x+i,y:o.y+a}}}),[])};function Nl(e,t){let n=[],r=new Map,i=[];for(let t of e)if(t.type===`add`){i.push(t);continue}else if(t.type===`remove`||t.type===`replace`)r.set(t.id,[t]);else{let e=r.get(t.id);e?e.push(t):r.set(t.id,[t])}for(let e of t){let t=r.get(e.id);if(!t){n.push(e);continue}if(t[0].type===`remove`)continue;if(t[0].type===`replace`){n.push({...t[0].item});continue}let i={...e};for(let e of t)Pl(e,i);n.push(i)}return i.length&&i.forEach(e=>{e.index===void 0?n.push({...e.item}):n.splice(e.index,0,{...e.item})}),n}function Pl(e,t){switch(e.type){case`select`:t.selected=e.selected;break;case`position`:e.position!==void 0&&(t.position=e.position),e.dragging!==void 0&&(t.dragging=e.dragging);break;case`dimensions`:e.dimensions!==void 0&&(t.measured={...e.dimensions},e.setAttributes&&((e.setAttributes===!0||e.setAttributes===`width`)&&(t.width=e.dimensions.width),(e.setAttributes===!0||e.setAttributes===`height`)&&(t.height=e.dimensions.height))),typeof e.resizing==`boolean`&&(t.resizing=e.resizing);break}}function Fl(e,t){return Nl(e,t)}function Il(e,t){return Nl(e,t)}function Ll(e,t){return{id:e,type:`select`,selected:t}}function Rl(e,t=new Set,n=!1){let r=[];for(let[i,a]of e){let e=t.has(i);!(a.selected===void 0&&!e)&&a.selected!==e&&(n&&(a.selected=e),r.push(Ll(a.id,e)))}return r}function zl({items:e=[],lookup:t}){let n=[],r=new Map(e.map(e=>[e.id,e]));for(let[r,i]of e.entries()){let e=t.get(i.id),a=e?.internals?.userNode??e;a!==void 0&&a!==i&&n.push({id:i.id,item:i,type:`replace`}),a===void 0&&n.push({item:i,type:`add`,index:r})}for(let[e]of t)r.get(e)===void 0&&n.push({id:e,type:`remove`});return n}function Bl(e){return{id:e.id,type:`remove`}}var Vl=Go(`React Flow`,`https://reactflow.dev/`);function Hl(e,t,n={}){return ws(e,t,{...n,onError:n.onError??Vl})}var Ul=e=>yo(e),Wl=e=>vo(e);function Gl(e){return(0,W.forwardRef)(e)}var Kl=typeof window<`u`?W.useLayoutEffect:W.useEffect;function ql(e){let[t,n]=(0,W.useState)(BigInt(0)),[r]=(0,W.useState)(()=>Jl(()=>n(e=>e+BigInt(1))));return Kl(()=>{let t=r.get();t.length&&(e(t),r.reset())},[t]),r}function Jl(e){let t=[];return{get:()=>t,reset:()=>{t=[]},push:n=>{t.push(n),e()}}}var Yl=(0,W.createContext)(null);function Xl({children:e}){let t=Z(),n=ql((0,W.useCallback)(e=>{let{nodes:n=[],setNodes:r,hasDefaultNodes:i,onNodesChange:a,nodeLookup:o,fitViewQueued:s,onNodesChangeMiddlewareMap:c}=t.getState(),l=n;for(let t of e)l=typeof t==`function`?t(l):t;let u=zl({items:l,lookup:o});for(let e of c.values())u=e(u);i&&r(l),u.length>0?a?.(u):s&&window.requestAnimationFrame(()=>{let{fitViewQueued:e,nodes:n,setNodes:r}=t.getState();e&&r(n)})},[])),r=ql((0,W.useCallback)(e=>{let{edges:n=[],setEdges:r,hasDefaultEdges:i,onEdgesChange:a,edgeLookup:o}=t.getState(),s=n;for(let t of e)s=typeof t==`function`?t(s):t;i?r(s):a&&a(zl({items:s,lookup:o}))},[])),i=(0,W.useMemo)(()=>({nodeQueue:n,edgeQueue:r}),[]);return(0,G.jsx)(Yl.Provider,{value:i,children:e})}function Zl(){let e=(0,W.useContext)(Yl);if(!e)throw Error(`useBatchContext must be used within a BatchProvider`);return e}var Ql=e=>!!e.panZoom;function $l(){let e=Ml(),t=Z(),n=Zl(),r=X(Ql),i=(0,W.useMemo)(()=>{let e=e=>t.getState().nodeLookup.get(e),r=e=>{n.nodeQueue.push(e)},i=e=>{n.edgeQueue.push(e)},a=e=>{let{nodeLookup:n,nodeOrigin:r}=t.getState(),i=Ul(e)?e:n.get(e.id),a=i.parentId?rs(i.position,i.measured,i.parentId,n,r):i.position;return Ro({...i,position:a,width:i.measured?.width??i.width,height:i.measured?.height??i.height})},o=(e,t,n={replace:!1})=>{r(r=>r.map(r=>{if(r.id===e){let e=typeof t==`function`?t(r):t;return n.replace&&Ul(e)?e:{...r,...e}}return r}))},s=(e,t,n={replace:!1})=>{i(r=>r.map(r=>{if(r.id===e){let e=typeof t==`function`?t(r):t;return n.replace&&Wl(e)?e:{...r,...e}}return r}))};return{getNodes:()=>t.getState().nodes.map(e=>({...e})),getNode:t=>e(t)?.internals.userNode,getInternalNode:e,getEdges:()=>{let{edges:e=[]}=t.getState();return e.map(e=>({...e}))},getEdge:e=>t.getState().edgeLookup.get(e),setNodes:r,setEdges:i,addNodes:e=>{let t=Array.isArray(e)?e:[e];n.nodeQueue.push(e=>[...e,...t])},addEdges:e=>{let t=Array.isArray(e)?e:[e];n.edgeQueue.push(e=>[...e,...t])},toObject:()=>{let{nodes:e=[],edges:n=[],transform:r}=t.getState(),[i,a,o]=r;return{nodes:e.map(e=>({...e})),edges:n.map(e=>({...e})),viewport:{x:i,y:a,zoom:o}}},deleteElements:async({nodes:e=[],edges:n=[]})=>{let{nodes:r,edges:i,onNodesDelete:a,onEdgesDelete:o,triggerNodeChanges:s,triggerEdgeChanges:c,onDelete:l,onBeforeDelete:u}=t.getState(),{nodes:d,edges:f}=await ko({nodesToRemove:e,edgesToRemove:n,nodes:r,edges:i,onBeforeDelete:u}),p=f.length>0,m=d.length>0;if(p){let e=f.map(Bl);o?.(f),c(e)}if(m){let e=d.map(Bl);a?.(d),s(e)}return(m||p)&&l?.({nodes:d,edges:f}),{deletedNodes:d,deletedEdges:f}},getIntersectingNodes:(e,n=!0,r)=>{let i=Uo(e),o=i?e:a(e),s=r!==void 0;return o?(r||t.getState().nodes).filter(r=>{let a=t.getState().nodeLookup.get(r.id);if(a&&!i&&(r.id===e.id||!a.internals.positionAbsolute))return!1;let c=Ro(s?r:a),l=Ho(c,o);return n&&l>0||l>=c.width*c.height||l>=o.width*o.height}):[]},isNodeIntersecting:(e,t,n=!0)=>{let r=Uo(e)?e:a(e);if(!r)return!1;let i=Ho(r,t);return n&&i>0||i>=t.width*t.height||i>=r.width*r.height},updateNode:o,updateNodeData:(e,t,n={replace:!1})=>{o(e,e=>{let r=typeof t==`function`?t(e):t;return n.replace?{...e,data:r}:{...e,data:{...e.data,...r}}},n)},updateEdge:s,updateEdgeData:(e,t,n={replace:!1})=>{s(e,e=>{let r=typeof t==`function`?t(e):t;return n.replace?{...e,data:r}:{...e,data:{...e.data,...r}}},n)},getNodesBounds:e=>{let{nodeLookup:n,nodeOrigin:r}=t.getState();return So(e,{nodeLookup:n,nodeOrigin:r})},getHandleConnections:({type:e,id:n,nodeId:r})=>Array.from(t.getState().connectionLookup.get(`${r}-${e}${n?`-${n}`:``}`)?.values()??[]),getNodeConnections:({type:e,handleId:n,nodeId:r})=>Array.from(t.getState().connectionLookup.get(`${r}${e?n?`-${e}-${n}`:`-${e}`:``}`)?.values()??[]),fitView:async e=>{let r=t.getState().fitViewResolver??as();return t.setState({fitViewQueued:!0,fitViewOptions:e,fitViewResolver:r}),n.nodeQueue.push(e=>[...e]),r.promise}}},[]);return(0,W.useMemo)(()=>({...i,...e,viewportInitialized:r}),[r])}var eu=e=>e.selected,tu=typeof window<`u`?window:void 0;function nu({deleteKeyCode:e,multiSelectionKeyCode:t}){let n=Z(),{deleteElements:r}=$l(),i=kl(e,{actInsideInputWithModifier:!1}),a=kl(t,{target:tu});(0,W.useEffect)(()=>{if(i){let{edges:e,nodes:t}=n.getState();r({nodes:t.filter(eu),edges:e.filter(eu)}),n.setState({nodesSelectionActive:!1})}},[i]),(0,W.useEffect)(()=>{n.setState({multiSelectionActive:a})},[a])}function ru(e){let t=Z();(0,W.useEffect)(()=>{let n=()=>{if(!e.current||!(e.current.checkVisibility?.()??!0))return!1;let n=cs(e.current);(n.height===0||n.width===0)&&t.getState().onError?.(`004`,ao.error004()),t.setState({width:n.width||500,height:n.height||500})};if(e.current){n(),window.addEventListener(`resize`,n);let t=new ResizeObserver(()=>n());return t.observe(e.current),()=>{window.removeEventListener(`resize`,n),t&&e.current&&t.unobserve(e.current)}}},[])}var iu={position:`absolute`,width:`100%`,height:`100%`,top:0,left:0},au=e=>({userSelectionActive:e.userSelectionActive,lib:e.lib,connectionInProgress:e.connection.inProgress});function ou({onPaneContextMenu:e,zoomOnScroll:t=!0,zoomOnPinch:n=!0,panOnScroll:r=!1,panOnScrollSpeed:i=.5,panOnScrollMode:a=uo.Free,zoomOnDoubleClick:o=!0,panOnDrag:s=!0,defaultViewport:c,translateExtent:l,minZoom:u,maxZoom:d,zoomActivationKeyCode:f,preventScrolling:p=!0,children:m,noWheelClassName:h,noPanClassName:g,onViewportChange:_,isControlledViewport:v,paneClickDistance:y,selectionOnDrag:b}){let x=Z(),S=(0,W.useRef)(null),{userSelectionActive:C,lib:w,connectionInProgress:T}=X(au,Y),E=kl(f),D=(0,W.useRef)();ru(S);let O=(0,W.useCallback)(e=>{_?.({x:e[0],y:e[1],zoom:e[2]}),v||x.setState({transform:e})},[_,v]);return(0,W.useEffect)(()=>{if(S.current){D.current=Nc({domNode:S.current,minZoom:u,maxZoom:d,translateExtent:l,viewport:c,onDraggingChange:e=>x.setState(t=>t.paneDragging===e?t:{paneDragging:e}),onPanZoomStart:(e,t)=>{let{onViewportChangeStart:n,onMoveStart:r}=x.getState();r?.(e,t),n?.(t)},onPanZoom:(e,t)=>{let{onViewportChange:n,onMove:r}=x.getState();r?.(e,t),n?.(t)},onPanZoomEnd:(e,t)=>{let{onViewportChangeEnd:n,onMoveEnd:r}=x.getState();r?.(e,t),n?.(t)}});let{x:e,y:t,zoom:n}=D.current.getViewport();return x.setState({panZoom:D.current,transform:[e,t,n],domNode:S.current.closest(`.react-flow`)}),()=>{D.current?.destroy()}}},[]),(0,W.useEffect)(()=>{D.current?.update({onPaneContextMenu:e,zoomOnScroll:t,zoomOnPinch:n,panOnScroll:r,panOnScrollSpeed:i,panOnScrollMode:a,zoomOnDoubleClick:o,panOnDrag:s,zoomActivationKeyPressed:E,preventScrolling:p,noPanClassName:g,userSelectionActive:C,noWheelClassName:h,lib:w,onTransformChange:O,connectionInProgress:T,selectionOnDrag:b,paneClickDistance:y})},[e,t,n,r,i,a,o,s,E,p,g,C,h,w,O,T,b,y]),(0,G.jsx)(`div`,{className:`react-flow__renderer`,ref:S,style:iu,children:m})}var su=e=>({userSelectionActive:e.userSelectionActive,userSelectionRect:e.userSelectionRect});function cu(){let{userSelectionActive:e,userSelectionRect:t}=X(su,Y);return e&&t?(0,G.jsx)(`div`,{className:`react-flow__selection react-flow__container`,style:{width:t.width,height:t.height,transform:`translate(${t.x}px, ${t.y}px)`}}):null}var lu=(e,t)=>n=>{n.target===t.current&&e?.(n)},uu=e=>({userSelectionActive:e.userSelectionActive,elementsSelectable:e.elementsSelectable,dragging:e.paneDragging,panBy:e.panBy,autoPanSpeed:e.autoPanSpeed});function du({isSelecting:e,selectionKeyPressed:t,selectionMode:n=fo.Full,panOnDrag:r,autoPanOnSelection:i,paneClickDistance:a,selectionOnDrag:o,onSelectionStart:s,onSelectionEnd:c,onPaneClick:l,onPaneContextMenu:u,onPaneScroll:d,onPaneMouseEnter:f,onPaneMouseMove:p,onPaneMouseLeave:m,children:h}){let g=(0,W.useRef)(0),_=Z(),{userSelectionActive:v,elementsSelectable:y,dragging:b,panBy:x,autoPanSpeed:S}=X(uu,Y),C=y&&(e||v),w=(0,W.useRef)(null),T=(0,W.useRef)(),E=(0,W.useRef)(new Set),D=(0,W.useRef)(new Set),O=(0,W.useRef)(!1),k=(0,W.useRef)(!1),A=(0,W.useRef)({x:0,y:0}),j=(0,W.useRef)(!1),M=e=>{if(k.current||O.current||_.getState().connection.inProgress){k.current=!1,O.current=!1;return}l?.(e),_.getState().resetSelectedElements(),_.setState({nodesSelectionActive:!1})},N=e=>{if(Array.isArray(r)&&r?.includes(2)){e.preventDefault();return}u?.(e)},P=d?e=>d(e):void 0,F=e=>{k.current&&=(e.stopPropagation(),!1)},I=n=>{let{domNode:r,transform:i}=_.getState();if(T.current=r?.getBoundingClientRect(),!T.current)return;let a=n.target===w.current;if(!a&&n.target.closest(`.nokey`)||!e||!(o&&a||t)||n.button!==0||!n.isPrimary)return;n.target?.setPointerCapture?.(n.pointerId),k.current=!1;let{x:s,y:c}=ps(n.nativeEvent,T.current),l=qo({x:s,y:c},i);_.setState({userSelectionRect:{width:0,height:0,startX:l.x,startY:l.y,x:s,y:c}}),a||(n.stopPropagation(),n.preventDefault())};function L(e,t){let{userSelectionRect:r}=_.getState();if(!r)return;let{transform:i,nodeLookup:a,edgeLookup:o,connectionLookup:s,triggerNodeChanges:c,triggerEdgeChanges:l,defaultEdgeOptions:u}=_.getState(),d={x:r.startX,y:r.startY},{x:f,y:p}=Jo(d,i),m={startX:d.x,startY:d.y,x:ee.id)),D.current=new Set;let v=u?.selectable??!0;for(let e of E.current){let t=s.get(e);if(t)for(let{edgeId:e}of t.values()){let t=o.get(e);t&&(t.selectable??v)&&D.current.add(e)}}is(h,E.current)||c(Rl(a,E.current,!0)),is(g,D.current)||l(Rl(o,D.current)),_.setState({userSelectionRect:m,userSelectionActive:!0,nodesSelectionActive:!1})}function R(){if(!i||!T.current)return;let[e,t]=Po(A.current,T.current,S);x({x:e,y:t}).then(e=>{if(!k.current||!e){g.current=requestAnimationFrame(R);return}let{x:t,y:n}=A.current;L(t,n),g.current=requestAnimationFrame(R)})}let z=()=>{cancelAnimationFrame(g.current),g.current=0,j.current=!1};return(0,W.useEffect)(()=>()=>z(),[]),(0,G.jsxs)(`div`,{className:K([`react-flow__pane`,{draggable:r===!0||Array.isArray(r)&&r.includes(0),dragging:b,selection:e}]),onClick:C?void 0:lu(M,w),onContextMenu:lu(N,w),onWheel:lu(P,w),onPointerEnter:C?void 0:f,onPointerMove:C?e=>{let{userSelectionRect:n,transform:r,resetSelectedElements:i}=_.getState();if(!T.current||!n)return;let{x:o,y:c}=ps(e.nativeEvent,T.current);A.current={x:o,y:c};let l=Jo({x:n.startX,y:n.startY},r);if(!k.current){let n=t?0:a;if(Math.hypot(o-l.x,c-l.y)<=n)return;i(),s?.(e)}k.current=!0,j.current||=(R(),!0),L(o,c)}:p,onPointerUp:e=>{if(!C){e.target===w.current&&_.getState().connection.inProgress&&(O.current=!0);return}e.button===0&&(e.target?.releasePointerCapture?.(e.pointerId),!v&&e.target===w.current&&_.getState().userSelectionRect&&M?.(e),_.setState({userSelectionActive:!1,userSelectionRect:null}),k.current&&(c?.(e),_.setState({nodesSelectionActive:E.current.size>0})),z())},onPointerCancel:C?e=>{e.target?.releasePointerCapture?.(e.pointerId),z()}:void 0,onPointerDownCapture:C?I:void 0,onClickCapture:C?F:void 0,onPointerLeave:m,ref:w,style:iu,children:[h,(0,G.jsx)(cu,{})]})}function fu({id:e,store:t,unselect:n=!1,nodeRef:r}){let{addSelectedNodes:i,unselectNodesAndEdges:a,multiSelectionActive:o,nodeLookup:s,onError:c}=t.getState(),l=s.get(e);if(!l){c?.(`012`,ao.error012(e));return}t.setState({nodesSelectionActive:!1}),l.selected?(n||l.selected&&o)&&(a({nodes:[l],edges:[]}),requestAnimationFrame(()=>r?.current?.blur())):i([e])}function pu({nodeRef:e,disabled:t=!1,noDragClassName:n,handleSelector:r,nodeId:i,isSelectable:a,nodeClickDistance:o}){let s=Z(),[c,l]=(0,W.useState)(!1),u=(0,W.useRef)();return(0,W.useEffect)(()=>{if(!t)return u.current=cc({getStoreItems:()=>s.getState(),onNodeMouseDown:t=>{fu({id:t,store:s,nodeRef:e})},onDragStart:()=>{l(!0)},onDragStop:()=>{l(!1)}}),()=>{u.current?.destroy(),u.current=void 0}},[t,s,e]),(0,W.useEffect)(()=>{t||!e.current||!u.current||u.current.update({noDragClassName:n,handleSelector:r,domNode:e.current,isSelectable:a,nodeId:i,nodeClickDistance:o})},[n,r,t,a,e,i,o]),c}var mu=e=>t=>t.selected&&(t.draggable||e&&t.draggable===void 0);function hu(){let e=Z();return(0,W.useCallback)(t=>{let{nodeExtent:n,snapToGrid:r,snapGrid:i,nodesDraggable:a,onError:o,updateNodePositions:s,nodeLookup:c,nodeOrigin:l}=e.getState(),u=new Map,d=mu(a),f=r?i[0]:5,p=r?i[1]:5,m=t.direction.x*f*t.factor,h=t.direction.y*p*t.factor;for(let[,e]of c){if(!d(e))continue;let t={x:e.internals.positionAbsolute.x+m,y:e.internals.positionAbsolute.y+h};r&&(t=Ko(t,i));let{position:a,positionAbsolute:s}=Oo({nodeId:e.id,nextPosition:t,nodeLookup:c,nodeExtent:n,nodeOrigin:l,onError:o});e.position=a,e.internals.positionAbsolute=s,u.set(e.id,e)}s(u)},[])}var gu=(0,W.createContext)(null),_u=gu.Provider;gu.Consumer;var vu=()=>(0,W.useContext)(gu),yu=e=>({connectOnClick:e.connectOnClick,noPanClassName:e.noPanClassName,rfId:e.rfId}),bu=(0,W.createContext)(null);function xu({children:e}){let t=X(yu,Y);return(0,G.jsx)(bu.Provider,{value:t,children:e})}function Su(){let e=(0,W.useContext)(bu);if(!e)throw Error(`useHandleConfig must be used within a HandleConfigProvider`);return e}var Cu={connectingFrom:!1,connectingTo:!1,clickConnecting:!1,isPossibleEndHandle:!0,connectionInProcess:!1,clickConnectionInProcess:!1,valid:!1},wu=(e,t,n)=>r=>{let{connectionClickStartHandle:i,connectionMode:a,connection:o}=r,{fromHandle:s,toHandle:c,isValid:l}=o;if(!s&&!i)return Cu;let u=c?.nodeId===e&&c?.id===t&&c?.type===n;return{connectingFrom:s?.nodeId===e&&s?.id===t&&s?.type===n,connectingTo:u,clickConnecting:i?.nodeId===e&&i?.id===t&&i?.type===n,isPossibleEndHandle:a===lo.Strict?s?.type!==n:e!==s?.nodeId||t!==s?.id,connectionInProcess:!!s,clickConnectionInProcess:!!i,valid:u&&l}};function Tu({type:e=`source`,position:t=J.Top,isValidConnection:n,isConnectable:r=!0,isConnectableStart:i=!0,isConnectableEnd:a=!0,id:o,onConnect:s,children:c,className:l,onMouseDown:u,onTouchStart:d,...f},p){let m=o||null,h=e===`target`,g=Z(),_=vu(),{connectOnClick:v,noPanClassName:y,rfId:b}=Su(),{connectingFrom:x,connectingTo:S,clickConnecting:C,isPossibleEndHandle:w,connectionInProcess:T,clickConnectionInProcess:E,valid:D}=X(wu(_,m,e),Y);_||g.getState().onError?.(`010`,ao.error010());let O=e=>{let{defaultEdgeOptions:t,onConnect:n,hasDefaultEdges:r}=g.getState(),i={...t,...e};if(r){let{edges:e,setEdges:t,onError:n}=g.getState();t(Hl(i,e,{onError:n}))}n?.(i),s?.(i)},k=e=>{if(!_)return;let t=fs(e.nativeEvent);if(i&&(t&&e.button===0||!t)){let t=g.getState();vc.onPointerDown(e.nativeEvent,{handleDomNode:e.currentTarget,autoPanOnConnect:t.autoPanOnConnect,connectionMode:t.connectionMode,connectionRadius:t.connectionRadius,domNode:t.domNode,nodeLookup:t.nodeLookup,lib:t.lib,isTarget:h,handleId:m,nodeId:_,flowId:t.rfId,panBy:t.panBy,cancelConnection:t.cancelConnection,onConnectStart:t.onConnectStart,onConnectEnd:(...e)=>g.getState().onConnectEnd?.(...e),updateConnection:t.updateConnection,onConnect:O,isValidConnection:n||((...e)=>g.getState().isValidConnection?.(...e)??!0),getTransform:()=>g.getState().transform,getFromHandle:()=>g.getState().connection.fromHandle,autoPanSpeed:t.autoPanSpeed,dragThreshold:t.connectionDragThreshold})}t?u?.(e):d?.(e)};return(0,G.jsx)(`div`,{"data-handleid":m,"data-nodeid":_,"data-handlepos":t,"data-id":`${b}-${_}-${m}-${e}`,className:K([`react-flow__handle`,`react-flow__handle-${t}`,`nodrag`,y,l,{source:!h,target:h,connectable:r,connectablestart:i,connectableend:a,clickconnecting:C,connectingfrom:x,connectingto:S,valid:D,connectionindicator:r&&(!T||w)&&(T||E?a:i)}]),onMouseDown:k,onTouchStart:k,onClick:v?t=>{let{onClickConnectStart:r,onClickConnectEnd:a,connectionClickStartHandle:o,connectionMode:s,isValidConnection:c,lib:l,rfId:u,nodeLookup:d,connection:f}=g.getState();if(!_||!o&&!i)return;if(!o){r?.(t.nativeEvent,{nodeId:_,handleId:m,handleType:e}),g.setState({connectionClickStartHandle:{nodeId:_,type:e,id:m}});return}let p=ls(t.target),h=n||c,{connection:v,isValid:y}=vc.isValid(t.nativeEvent,{handle:{nodeId:_,id:m,type:e},connectionMode:s,fromNodeId:o.nodeId,fromHandleId:o.id||null,fromType:o.type,isValidConnection:h,flowId:u,doc:p,lib:l,nodeLookup:d});y&&v&&O(v);let b=structuredClone(f);delete b.inProgress,b.toPosition=b.toHandle?b.toHandle.position:null,a?.(t,b),g.setState({connectionClickStartHandle:null})}:void 0,ref:p,...f,children:c})}var Eu=(0,W.memo)(Gl(Tu));function Du({data:e,isConnectable:t,sourcePosition:n=J.Bottom}){return(0,G.jsxs)(G.Fragment,{children:[e?.label,(0,G.jsx)(Eu,{type:`source`,position:n,isConnectable:t})]})}function Ou({data:e,isConnectable:t,targetPosition:n=J.Top,sourcePosition:r=J.Bottom}){return(0,G.jsxs)(G.Fragment,{children:[(0,G.jsx)(Eu,{type:`target`,position:n,isConnectable:t}),e?.label,(0,G.jsx)(Eu,{type:`source`,position:r,isConnectable:t})]})}function ku(){return null}function Au({data:e,isConnectable:t,targetPosition:n=J.Top}){return(0,G.jsxs)(G.Fragment,{children:[(0,G.jsx)(Eu,{type:`target`,position:n,isConnectable:t}),e?.label]})}var ju={ArrowUp:{x:0,y:-1},ArrowDown:{x:0,y:1},ArrowLeft:{x:-1,y:0},ArrowRight:{x:1,y:0}},Mu={input:Du,default:Ou,output:Au,group:ku};function Nu(e){return e.internals.handleBounds===void 0?{width:e.width??e.initialWidth??e.style?.width,height:e.height??e.initialHeight??e.style?.height}:{width:e.width??e.style?.width,height:e.height??e.style?.height}}var Pu=e=>{let{width:t,height:n,x:r,y:i}=Co(e.nodeLookup,{filter:e=>!!e.selected});return{width:Wo(t)?t:null,height:Wo(n)?n:null,userSelectionActive:e.userSelectionActive,transformString:`translate(${e.transform[0]}px,${e.transform[1]}px) scale(${e.transform[2]}) translate(${r}px,${i}px)`}};function Fu({onSelectionContextMenu:e,noPanClassName:t,disableKeyboardA11y:n}){let r=Z(),{width:i,height:a,transformString:o,userSelectionActive:s}=X(Pu,Y),c=hu(),l=(0,W.useRef)(null);(0,W.useEffect)(()=>{n||l.current?.focus({preventScroll:!0})},[n]);let u=!s&&i!==null&&a!==null;if(pu({nodeRef:l,disabled:!u}),!u)return null;let d=e?t=>{e(t,r.getState().nodes.filter(e=>e.selected))}:void 0;return(0,G.jsx)(`div`,{className:K([`react-flow__nodesselection`,`react-flow__container`,t]),style:{transform:o},children:(0,G.jsx)(`div`,{ref:l,className:`react-flow__nodesselection-rect`,onContextMenu:d,tabIndex:n?void 0:-1,onKeyDown:n?void 0:e=>{Object.prototype.hasOwnProperty.call(ju,e.key)&&(e.preventDefault(),c({direction:ju[e.key],factor:e.shiftKey?4:1}))},style:{width:i,height:a}})})}var Iu=typeof window<`u`?window:void 0,Lu=e=>({nodesSelectionActive:e.nodesSelectionActive,userSelectionActive:e.userSelectionActive});function Ru({children:e,onPaneClick:t,onPaneMouseEnter:n,onPaneMouseMove:r,onPaneMouseLeave:i,onPaneContextMenu:a,onPaneScroll:o,paneClickDistance:s,deleteKeyCode:c,selectionKeyCode:l,selectionOnDrag:u,selectionMode:d,onSelectionStart:f,onSelectionEnd:p,multiSelectionKeyCode:m,panActivationKeyCode:h,zoomActivationKeyCode:g,elementsSelectable:_,zoomOnScroll:v,zoomOnPinch:y,panOnScroll:b,panOnScrollSpeed:x,panOnScrollMode:S,zoomOnDoubleClick:C,panOnDrag:w,autoPanOnSelection:T,defaultViewport:E,translateExtent:D,minZoom:O,maxZoom:k,preventScrolling:A,onSelectionContextMenu:j,noWheelClassName:M,noPanClassName:N,disableKeyboardA11y:P,onViewportChange:F,isControlledViewport:I}){let{nodesSelectionActive:L,userSelectionActive:R}=X(Lu,Y),z=kl(l,{target:Iu}),B=kl(h,{target:Iu}),V=B||w,H=B||b,ee=u&&V!==!0,te=z||R||ee;return nu({deleteKeyCode:c,multiSelectionKeyCode:m}),(0,G.jsx)(ou,{onPaneContextMenu:a,elementsSelectable:_,zoomOnScroll:v,zoomOnPinch:y,panOnScroll:H,panOnScrollSpeed:x,panOnScrollMode:S,zoomOnDoubleClick:C,panOnDrag:!z&&V,defaultViewport:E,translateExtent:D,minZoom:O,maxZoom:k,zoomActivationKeyCode:g,preventScrolling:A,noWheelClassName:M,noPanClassName:N,onViewportChange:F,isControlledViewport:I,paneClickDistance:s,selectionOnDrag:ee,children:(0,G.jsxs)(du,{onSelectionStart:f,onSelectionEnd:p,onPaneClick:t,onPaneMouseEnter:n,onPaneMouseMove:r,onPaneMouseLeave:i,onPaneContextMenu:a,onPaneScroll:o,panOnDrag:V,autoPanOnSelection:T,isSelecting:!!te,selectionMode:d,selectionKeyPressed:z,paneClickDistance:s,selectionOnDrag:ee,children:[e,L&&(0,G.jsx)(Fu,{onSelectionContextMenu:j,noPanClassName:N,disableKeyboardA11y:P})]})})}Ru.displayName=`FlowRenderer`;var zu=(0,W.memo)(Ru),Bu=e=>t=>e?wo(t.nodeLookup,{x:0,y:0,width:t.width,height:t.height},t.transform,!0).map(e=>e.id):Array.from(t.nodeLookup.keys());function Vu(e){return X((0,W.useCallback)(Bu(e),[e]),Y)}var Hu=e=>e.updateNodeInternals;function Uu(){let e=X(Hu),[t]=(0,W.useState)(()=>typeof ResizeObserver>`u`?null:new ResizeObserver(t=>{let n=new Map;t.forEach(e=>{let t=e.target.getAttribute(`data-id`);n.set(t,{id:t,nodeElement:e.target,force:!0})}),e(n)}));return(0,W.useEffect)(()=>()=>{t?.disconnect()},[t]),t}function Wu({node:e,nodeType:t,hasDimensions:n,resizeObserver:r}){let i=Z(),a=(0,W.useRef)(null),o=(0,W.useRef)(null),s=(0,W.useRef)(e.sourcePosition),c=(0,W.useRef)(e.targetPosition),l=(0,W.useRef)(t),u=n&&!!e.internals.handleBounds;return(0,W.useEffect)(()=>{a.current&&!e.hidden&&(!u||o.current!==a.current)&&(o.current&&r?.unobserve(o.current),r?.observe(a.current),o.current=a.current)},[u,e.hidden]),(0,W.useEffect)(()=>()=>{o.current&&=(r?.unobserve(o.current),null)},[]),(0,W.useEffect)(()=>{if(a.current){let n=l.current!==t,r=s.current!==e.sourcePosition,o=c.current!==e.targetPosition;(n||r||o)&&(l.current=t,s.current=e.sourcePosition,c.current=e.targetPosition,i.getState().updateNodeInternals(new Map([[e.id,{id:e.id,nodeElement:a.current,force:!0}]])))}},[e.id,t,e.sourcePosition,e.targetPosition]),a}function Gu({id:e,onClick:t,onMouseEnter:n,onMouseMove:r,onMouseLeave:i,onContextMenu:a,onDoubleClick:o,nodesDraggable:s,elementsSelectable:c,nodesConnectable:l,nodesFocusable:u,resizeObserver:d,noDragClassName:f,noPanClassName:p,disableKeyboardA11y:m,rfId:h,nodeTypes:g,nodeClickDistance:_,onError:v}){let{node:y,internals:b,isParent:x}=X(t=>{let n=t.nodeLookup.get(e),r=t.parentLookup.has(e);return{node:n,internals:n.internals,isParent:r}},Y),S=y.type||`default`,C=g?.[S]||Mu[S];C===void 0&&(v?.(`003`,ao.error003(S)),S=`default`,C=g?.default||Mu.default);let w=!!(y.draggable||s&&y.draggable===void 0),T=!!(y.selectable||c&&y.selectable===void 0),E=!!(y.connectable||l&&y.connectable===void 0),D=!!(y.focusable||u&&y.focusable===void 0),O=Z(),k=ns(y),A=Wu({node:y,nodeType:S,hasDimensions:k,resizeObserver:d}),j=pu({nodeRef:A,disabled:y.hidden||!w,noDragClassName:f,handleSelector:y.dragHandle,nodeId:e,isSelectable:T,nodeClickDistance:_}),M=hu();if(y.hidden)return null;let N=ts(y),P=Nu(y),F=T||w||t||n||r||i,I=n?e=>n(e,{...b.userNode}):void 0,L=r?e=>r(e,{...b.userNode}):void 0,R=i?e=>i(e,{...b.userNode}):void 0,z=a?e=>a(e,{...b.userNode}):void 0,B=o?e=>o(e,{...b.userNode}):void 0,V=n=>{let{selectNodesOnDrag:r,nodeDragThreshold:i}=O.getState();T&&(!r||!w||i>0)&&fu({id:e,store:O,nodeRef:A}),t&&t(n,{...b.userNode})},H=t=>{if(!(ds(t.nativeEvent)||m)){if(so.includes(t.key)&&T){let n=t.key===`Escape`;fu({id:e,store:O,unselect:n,nodeRef:A})}else if(w&&y.selected&&Object.prototype.hasOwnProperty.call(ju,t.key)){t.preventDefault();let{ariaLabelConfig:e}=O.getState();O.setState({ariaLiveMessage:e[`node.a11yDescription.ariaLiveMessage`]({direction:t.key.replace(`Arrow`,``).toLowerCase(),x:~~b.positionAbsolute.x,y:~~b.positionAbsolute.y})}),M({direction:ju[t.key],factor:t.shiftKey?4:1})}}},ee=()=>{if(m||!A.current?.matches(`:focus-visible`))return;let{transform:t,width:n,height:r,autoPanOnNodeFocus:i,setCenter:a}=O.getState();i&&(wo(new Map([[e,y]]),{x:0,y:0,width:n,height:r},t,!0).length>0||a(y.position.x+N.width/2,y.position.y+N.height/2,{zoom:t[2]}))};return(0,G.jsx)(`div`,{className:K([`react-flow__node`,`react-flow__node-${S}`,{[p]:w},y.className,{selected:y.selected,selectable:T,parent:x,draggable:w,dragging:j}]),ref:A,style:{zIndex:b.z,transform:`translate(${b.positionAbsolute.x}px,${b.positionAbsolute.y}px)`,pointerEvents:F?`all`:`none`,visibility:k?`visible`:`hidden`,...y.style,...P},"data-id":e,"data-testid":`rf__node-${e}`,onMouseEnter:I,onMouseMove:L,onMouseLeave:R,onContextMenu:z,onClick:V,onDoubleClick:B,onKeyDown:D?H:void 0,tabIndex:D?0:void 0,onFocus:D?ee:void 0,role:y.ariaRole??(D?`group`:void 0),"aria-roledescription":`node`,"aria-describedby":m?void 0:`${il}-${h}`,"aria-label":y.ariaLabel,...y.domAttributes,children:(0,G.jsx)(_u,{value:e,children:(0,G.jsx)(C,{id:e,data:y.data,type:S,positionAbsoluteX:b.positionAbsolute.x,positionAbsoluteY:b.positionAbsolute.y,selected:y.selected??!1,selectable:T,draggable:w,deletable:y.deletable??!0,isConnectable:E,sourcePosition:y.sourcePosition,targetPosition:y.targetPosition,dragging:j,dragHandle:y.dragHandle,zIndex:b.z,parentId:y.parentId,...N})})})}var Ku=(0,W.memo)(Gu),qu=e=>({nodesConnectable:e.nodesConnectable,nodesFocusable:e.nodesFocusable,elementsSelectable:e.elementsSelectable,onError:e.onError});function Ju(e){let{nodesConnectable:t,nodesFocusable:n,elementsSelectable:r,onError:i}=X(qu,Y),a=Vu(e.onlyRenderVisibleElements),o=Uu();return(0,G.jsx)(`div`,{className:`react-flow__nodes`,style:iu,children:a.map(a=>(0,G.jsx)(Ku,{id:a,nodeTypes:e.nodeTypes,nodeExtent:e.nodeExtent,onClick:e.onNodeClick,onMouseEnter:e.onNodeMouseEnter,onMouseMove:e.onNodeMouseMove,onMouseLeave:e.onNodeMouseLeave,onContextMenu:e.onNodeContextMenu,onDoubleClick:e.onNodeDoubleClick,noDragClassName:e.noDragClassName,noPanClassName:e.noPanClassName,rfId:e.rfId,disableKeyboardA11y:e.disableKeyboardA11y,resizeObserver:o,nodesDraggable:e.nodesDraggable??!0,nodesConnectable:t,nodesFocusable:n,elementsSelectable:r,nodeClickDistance:e.nodeClickDistance,onError:i},a))})}Ju.displayName=`NodeRenderer`;var Yu=(0,W.memo)(Ju);function Xu(e){return X((0,W.useCallback)(t=>{if(!e)return t.edges.map(e=>e.id);let n=[];if(t.width&&t.height)for(let e of t.edges){let r=t.nodeLookup.get(e.source),i=t.nodeLookup.get(e.target);r&&i&&xs({sourceNode:r,targetNode:i,width:t.width,height:t.height,transform:t.transform})&&n.push(e.id)}return n},[e]),Y)}var Zu=({color:e=`none`,strokeWidth:t=1})=>(0,G.jsx)(`polyline`,{className:`arrow`,style:{strokeWidth:t,...e&&{stroke:e}},strokeLinecap:`round`,fill:`none`,strokeLinejoin:`round`,points:`-5,-4 0,0 -5,4`}),Qu=({color:e=`none`,strokeWidth:t=1})=>(0,G.jsx)(`polyline`,{className:`arrowclosed`,style:{strokeWidth:t,...e&&{stroke:e,fill:e}},strokeLinecap:`round`,strokeLinejoin:`round`,points:`-5,-4 0,0 -5,4 -5,-4`}),$u={[ho.Arrow]:Zu,[ho.ArrowClosed]:Qu};function ed(e){let t=Z();return(0,W.useMemo)(()=>Object.prototype.hasOwnProperty.call($u,e)?$u[e]:(t.getState().onError?.(`009`,ao.error009(e)),null),[e])}var td=({id:e,type:t,color:n,width:r=12.5,height:i=12.5,markerUnits:a=`strokeWidth`,strokeWidth:o,orient:s=`auto-start-reverse`})=>{let c=ed(t);return c?(0,G.jsx)(`marker`,{className:`react-flow__arrowhead`,id:e,markerWidth:`${r}`,markerHeight:`${i}`,viewBox:`-10 -10 20 20`,markerUnits:a,orient:s,refX:`0`,refY:`0`,children:(0,G.jsx)(c,{color:n,strokeWidth:o})}):null},nd=({defaultColor:e,rfId:t})=>{let n=X(e=>e.edges),r=X(e=>e.defaultEdgeOptions),i=(0,W.useMemo)(()=>Rs(n,{id:t,defaultColor:e,defaultMarkerStart:r?.markerStart,defaultMarkerEnd:r?.markerEnd}),[n,r,t,e]);return i.length?(0,G.jsx)(`svg`,{className:`react-flow__marker`,"aria-hidden":`true`,children:(0,G.jsx)(`defs`,{children:i.map(e=>(0,G.jsx)(td,{id:e.id,type:e.type,color:e.color,width:e.width,height:e.height,markerUnits:e.markerUnits,strokeWidth:e.strokeWidth,orient:e.orient},e.id))})}):null};nd.displayName=`MarkerDefinitions`;var rd=(0,W.memo)(nd);function id({x:e,y:t,label:n,labelStyle:r,labelShowBg:i=!0,labelBgStyle:a,labelBgPadding:o=[2,4],labelBgBorderRadius:s=2,children:c,className:l,...u}){let[d,f]=(0,W.useState)({x:1,y:0,width:0,height:0}),p=K([`react-flow__edge-textwrapper`,l]),m=(0,W.useRef)(null);return(0,W.useEffect)(()=>{if(m.current){let e=m.current.getBBox();f({x:e.x,y:e.y,width:e.width,height:e.height})}},[n]),n?(0,G.jsxs)(`g`,{transform:`translate(${e-d.width/2} ${t-d.height/2})`,className:p,visibility:d.width?`visible`:`hidden`,...u,children:[i&&(0,G.jsx)(`rect`,{width:d.width+2*o[0],x:-o[0],y:-o[1],height:d.height+2*o[1],className:`react-flow__edge-textbg`,style:a,rx:s,ry:s}),(0,G.jsx)(`text`,{className:`react-flow__edge-text`,y:d.height/2,dy:`0.3em`,ref:m,style:r,children:n}),c]}):null}id.displayName=`EdgeText`;var ad=(0,W.memo)(id);function od({path:e,labelX:t,labelY:n,label:r,labelStyle:i,labelShowBg:a,labelBgStyle:o,labelBgPadding:s,labelBgBorderRadius:c,interactionWidth:l=20,...u}){return(0,G.jsxs)(G.Fragment,{children:[(0,G.jsx)(`path`,{...u,d:e,fill:`none`,className:K([`react-flow__edge-path`,u.className])}),l?(0,G.jsx)(`path`,{d:e,fill:`none`,strokeOpacity:0,strokeWidth:l,className:`react-flow__edge-interaction`}):null,r&&Wo(t)&&Wo(n)?(0,G.jsx)(ad,{x:t,y:n,label:r,labelStyle:i,labelShowBg:a,labelBgStyle:o,labelBgPadding:s,labelBgBorderRadius:c}):null]})}function sd({pos:e,x1:t,y1:n,x2:r,y2:i}){return e===J.Left||e===J.Right?[.5*(t+r),n]:[t,.5*(n+i)]}function cd({sourceX:e,sourceY:t,sourcePosition:n=J.Bottom,targetX:r,targetY:i,targetPosition:a=J.Top}){let[o,s]=sd({pos:n,x1:e,y1:t,x2:r,y2:i}),[c,l]=sd({pos:a,x1:r,y1:i,x2:e,y2:t}),[u,d,f,p]=hs({sourceX:e,sourceY:t,targetX:r,targetY:i,sourceControlX:o,sourceControlY:s,targetControlX:c,targetControlY:l});return[`M${e},${t} C${o},${s} ${c},${l} ${r},${i}`,u,d,f,p]}function ld(e){return(0,W.memo)(({id:t,sourceX:n,sourceY:r,targetX:i,targetY:a,sourcePosition:o,targetPosition:s,label:c,labelStyle:l,labelShowBg:u,labelBgStyle:d,labelBgPadding:f,labelBgBorderRadius:p,style:m,markerEnd:h,markerStart:g,interactionWidth:_})=>{let[v,y,b]=cd({sourceX:n,sourceY:r,sourcePosition:o,targetX:i,targetY:a,targetPosition:s});return(0,G.jsx)(od,{id:e.isInternal?void 0:t,path:v,labelX:y,labelY:b,label:c,labelStyle:l,labelShowBg:u,labelBgStyle:d,labelBgPadding:f,labelBgBorderRadius:p,style:m,markerEnd:h,markerStart:g,interactionWidth:_})})}var ud=ld({isInternal:!1}),dd=ld({isInternal:!0});ud.displayName=`SimpleBezierEdge`,dd.displayName=`SimpleBezierEdgeInternal`;function fd(e){return(0,W.memo)(({id:t,sourceX:n,sourceY:r,targetX:i,targetY:a,label:o,labelStyle:s,labelShowBg:c,labelBgStyle:l,labelBgPadding:u,labelBgBorderRadius:d,style:f,sourcePosition:p=J.Bottom,targetPosition:m=J.Top,markerEnd:h,markerStart:g,pathOptions:_,interactionWidth:v})=>{let[y,b,x]=js({sourceX:n,sourceY:r,sourcePosition:p,targetX:i,targetY:a,targetPosition:m,borderRadius:_?.borderRadius,offset:_?.offset,stepPosition:_?.stepPosition});return(0,G.jsx)(od,{id:e.isInternal?void 0:t,path:y,labelX:b,labelY:x,label:o,labelStyle:s,labelShowBg:c,labelBgStyle:l,labelBgPadding:u,labelBgBorderRadius:d,style:f,markerEnd:h,markerStart:g,interactionWidth:v})})}var pd=fd({isInternal:!1}),md=fd({isInternal:!0});pd.displayName=`SmoothStepEdge`,md.displayName=`SmoothStepEdgeInternal`;function hd(e){return(0,W.memo)(({id:t,...n})=>{let r=e.isInternal?void 0:t;return(0,G.jsx)(pd,{...n,id:r,pathOptions:(0,W.useMemo)(()=>({borderRadius:0,offset:n.pathOptions?.offset}),[n.pathOptions?.offset])})})}var gd=hd({isInternal:!1}),_d=hd({isInternal:!0});gd.displayName=`StepEdge`,_d.displayName=`StepEdgeInternal`;function vd(e){return(0,W.memo)(({id:t,sourceX:n,sourceY:r,targetX:i,targetY:a,label:o,labelStyle:s,labelShowBg:c,labelBgStyle:l,labelBgPadding:u,labelBgBorderRadius:d,style:f,markerEnd:p,markerStart:m,interactionWidth:h})=>{let[g,_,v]=Ts({sourceX:n,sourceY:r,targetX:i,targetY:a});return(0,G.jsx)(od,{id:e.isInternal?void 0:t,path:g,labelX:_,labelY:v,label:o,labelStyle:s,labelShowBg:c,labelBgStyle:l,labelBgPadding:u,labelBgBorderRadius:d,style:f,markerEnd:p,markerStart:m,interactionWidth:h})})}var yd=vd({isInternal:!1}),bd=vd({isInternal:!0});yd.displayName=`StraightEdge`,bd.displayName=`StraightEdgeInternal`;function xd(e){return(0,W.memo)(({id:t,sourceX:n,sourceY:r,targetX:i,targetY:a,sourcePosition:o=J.Bottom,targetPosition:s=J.Top,label:c,labelStyle:l,labelShowBg:u,labelBgStyle:d,labelBgPadding:f,labelBgBorderRadius:p,style:m,markerEnd:h,markerStart:g,pathOptions:_,interactionWidth:v})=>{let[y,b,x]=vs({sourceX:n,sourceY:r,sourcePosition:o,targetX:i,targetY:a,targetPosition:s,curvature:_?.curvature});return(0,G.jsx)(od,{id:e.isInternal?void 0:t,path:y,labelX:b,labelY:x,label:c,labelStyle:l,labelShowBg:u,labelBgStyle:d,labelBgPadding:f,labelBgBorderRadius:p,style:m,markerEnd:h,markerStart:g,interactionWidth:v})})}var Sd=xd({isInternal:!1}),Cd=xd({isInternal:!0});Sd.displayName=`BezierEdge`,Cd.displayName=`BezierEdgeInternal`;var wd={default:Cd,straight:bd,step:_d,smoothstep:md,simplebezier:dd},Td={sourceX:null,sourceY:null,targetX:null,targetY:null,sourcePosition:null,targetPosition:null,zIndex:void 0},Ed=(e,t,n)=>n===J.Left?e-t:n===J.Right?e+t:e,Dd=(e,t,n)=>n===J.Top?e-t:n===J.Bottom?e+t:e,Od=`react-flow__edgeupdater`;function kd({position:e,centerX:t,centerY:n,radius:r=10,onMouseDown:i,onMouseEnter:a,onMouseOut:o,type:s}){return(0,G.jsx)(`circle`,{onMouseDown:i,onMouseEnter:a,onMouseOut:o,className:K([Od,`${Od}-${s}`]),cx:Ed(t,r,e),cy:Dd(n,r,e),r,stroke:`transparent`,fill:`transparent`})}function Ad({isReconnectable:e,reconnectRadius:t,edge:n,sourceX:r,sourceY:i,targetX:a,targetY:o,sourcePosition:s,targetPosition:c,onReconnect:l,onReconnectStart:u,onReconnectEnd:d,setReconnecting:f,setUpdateHover:p}){let m=Z(),h=(e,t)=>{if(e.button!==0)return;let{autoPanOnConnect:r,domNode:i,connectionMode:a,connectionRadius:o,lib:s,onConnectStart:c,cancelConnection:p,nodeLookup:h,rfId:g,panBy:_,updateConnection:v}=m.getState(),y=t.type===`target`;vc.onPointerDown(e.nativeEvent,{autoPanOnConnect:r,connectionMode:a,connectionRadius:o,domNode:i,handleId:t.id,nodeId:t.nodeId,nodeLookup:h,isTarget:y,edgeUpdaterType:t.type,lib:s,flowId:g,cancelConnection:p,panBy:_,isValidConnection:(...e)=>m.getState().isValidConnection?.(...e)??!0,onConnect:e=>l?.(n,e),onConnectStart:(r,i)=>{f(!0),u?.(e,n,t.type),c?.(r,i)},onConnectEnd:(...e)=>m.getState().onConnectEnd?.(...e),onReconnectEnd:(e,r)=>{f(!1),d?.(e,n,t.type,r)},updateConnection:v,getTransform:()=>m.getState().transform,getFromHandle:()=>m.getState().connection.fromHandle,dragThreshold:m.getState().connectionDragThreshold,handleDomNode:e.currentTarget})},g=e=>h(e,{nodeId:n.target,id:n.targetHandle??null,type:`target`}),_=e=>h(e,{nodeId:n.source,id:n.sourceHandle??null,type:`source`}),v=()=>p(!0),y=()=>p(!1);return(0,G.jsxs)(G.Fragment,{children:[(e===!0||e===`source`)&&(0,G.jsx)(kd,{position:s,centerX:r,centerY:i,radius:t,onMouseDown:g,onMouseEnter:v,onMouseOut:y,type:`source`}),(e===!0||e===`target`)&&(0,G.jsx)(kd,{position:c,centerX:a,centerY:o,radius:t,onMouseDown:_,onMouseEnter:v,onMouseOut:y,type:`target`})]})}function jd({id:e,edgesFocusable:t,edgesReconnectable:n,elementsSelectable:r,onClick:i,onDoubleClick:a,onContextMenu:o,onMouseEnter:s,onMouseMove:c,onMouseLeave:l,reconnectRadius:u,onReconnect:d,onReconnectStart:f,onReconnectEnd:p,rfId:m,edgeTypes:h,noPanClassName:g,onError:_,disableKeyboardA11y:v}){let y=X(t=>t.edgeLookup.get(e)),b=X(e=>e.defaultEdgeOptions);y=b?{...b,...y}:y;let x=y.type||`default`,S=h?.[x]||wd[x];S===void 0&&(_?.(`011`,ao.error011(x)),x=`default`,S=h?.default||wd.default);let C=!!(y.focusable||t&&y.focusable===void 0),w=d!==void 0&&(y.reconnectable||n&&y.reconnectable===void 0),T=!!(y.selectable||r&&y.selectable===void 0),E=(0,W.useRef)(null),[D,O]=(0,W.useState)(!1),[k,A]=(0,W.useState)(!1),j=Z(),{zIndex:M=y.zIndex,sourceX:N,sourceY:P,targetX:F,targetY:I,sourcePosition:L,targetPosition:R}=X((0,W.useCallback)(t=>{let n=t.nodeLookup.get(y.source),r=t.nodeLookup.get(y.target);if(!n||!r)return Td;let i=Ns({id:e,sourceNode:n,targetNode:r,sourceHandle:y.sourceHandle||null,targetHandle:y.targetHandle||null,connectionMode:t.connectionMode,onError:_}),a=bs({selected:y.selected,zIndex:y.zIndex,sourceNode:n,targetNode:r,elevateOnSelect:t.elevateEdgesOnSelect,zIndexMode:t.zIndexMode});return{...i||Td,zIndex:a}},[y.source,y.target,y.sourceHandle,y.targetHandle,y.selected,y.zIndex]),Y),z=(0,W.useMemo)(()=>y.markerStart?`url('#${Ls(y.markerStart,m)}')`:void 0,[y.markerStart,m]),B=(0,W.useMemo)(()=>y.markerEnd?`url('#${Ls(y.markerEnd,m)}')`:void 0,[y.markerEnd,m]);if(y.hidden||N===null||P===null||F===null||I===null)return null;let V=t=>{let{addSelectedEdges:n,unselectNodesAndEdges:r,multiSelectionActive:a}=j.getState();T&&(j.setState({nodesSelectionActive:!1}),y.selected&&a?(r({nodes:[],edges:[y]}),E.current?.blur()):n([e])),i&&i(t,y)},H=a?e=>{a(e,{...y})}:void 0,ee=o?e=>{o(e,{...y})}:void 0,te=s?e=>{s(e,{...y})}:void 0,U=c?e=>{c(e,{...y})}:void 0,ne=l?e=>{l(e,{...y})}:void 0;return(0,G.jsx)(`svg`,{style:{zIndex:M},children:(0,G.jsxs)(`g`,{className:K([`react-flow__edge`,`react-flow__edge-${x}`,y.className,g,{selected:y.selected,animated:y.animated,inactive:!T&&!i,updating:D,selectable:T}]),onClick:V,onDoubleClick:H,onContextMenu:ee,onMouseEnter:te,onMouseMove:U,onMouseLeave:ne,onKeyDown:C?t=>{if(!v&&so.includes(t.key)&&T){let{unselectNodesAndEdges:n,addSelectedEdges:r}=j.getState();t.key===`Escape`?(E.current?.blur(),n({edges:[y]})):r([e])}}:void 0,tabIndex:C?0:void 0,role:y.ariaRole??(C?`group`:`img`),"aria-roledescription":`edge`,"data-id":e,"data-testid":`rf__edge-${e}`,"aria-label":y.ariaLabel===null?void 0:y.ariaLabel||`Edge from ${y.source} to ${y.target}`,"aria-describedby":C?`${al}-${m}`:void 0,ref:E,...y.domAttributes,children:[!k&&(0,G.jsx)(S,{id:e,source:y.source,target:y.target,type:y.type,selected:y.selected,animated:y.animated,selectable:T,deletable:y.deletable??!0,label:y.label,labelStyle:y.labelStyle,labelShowBg:y.labelShowBg,labelBgStyle:y.labelBgStyle,labelBgPadding:y.labelBgPadding,labelBgBorderRadius:y.labelBgBorderRadius,sourceX:N,sourceY:P,targetX:F,targetY:I,sourcePosition:L,targetPosition:R,data:y.data,style:y.style,sourceHandleId:y.sourceHandle,targetHandleId:y.targetHandle,markerStart:z,markerEnd:B,pathOptions:`pathOptions`in y?y.pathOptions:void 0,interactionWidth:y.interactionWidth}),w&&(0,G.jsx)(Ad,{edge:y,isReconnectable:w,reconnectRadius:u,onReconnect:d,onReconnectStart:f,onReconnectEnd:p,sourceX:N,sourceY:P,targetX:F,targetY:I,sourcePosition:L,targetPosition:R,setUpdateHover:O,setReconnecting:A})]})})}var Md=(0,W.memo)(jd),Nd=e=>({edgesFocusable:e.edgesFocusable,edgesReconnectable:e.edgesReconnectable,elementsSelectable:e.elementsSelectable,connectionMode:e.connectionMode,onError:e.onError});function Pd({defaultMarkerColor:e,onlyRenderVisibleElements:t,rfId:n,edgeTypes:r,noPanClassName:i,onReconnect:a,onEdgeContextMenu:o,onEdgeMouseEnter:s,onEdgeMouseMove:c,onEdgeMouseLeave:l,onEdgeClick:u,reconnectRadius:d,onEdgeDoubleClick:f,onReconnectStart:p,onReconnectEnd:m,disableKeyboardA11y:h}){let{edgesFocusable:g,edgesReconnectable:_,elementsSelectable:v,onError:y}=X(Nd,Y),b=Xu(t);return(0,G.jsxs)(`div`,{className:`react-flow__edges`,children:[(0,G.jsx)(rd,{defaultColor:e,rfId:n}),b.map(e=>(0,G.jsx)(Md,{id:e,edgesFocusable:g,edgesReconnectable:_,elementsSelectable:v,noPanClassName:i,onReconnect:a,onContextMenu:o,onMouseEnter:s,onMouseMove:c,onMouseLeave:l,onClick:u,reconnectRadius:d,onDoubleClick:f,onReconnectStart:p,onReconnectEnd:m,rfId:n,onError:y,edgeTypes:r,disableKeyboardA11y:h},e))]})}Pd.displayName=`EdgeRenderer`;var Fd=(0,W.memo)(Pd),Id=e=>`translate(${e[0]}px,${e[1]}px) scale(${e[2]})`;function Ld({children:e}){let t=Z(),n=(0,W.useRef)(null),[r]=(0,W.useState)(()=>t.getState().transform);return Kl(()=>{let e=null,r=()=>{let r=t.getState().transform;e&&r[0]===e[0]&&r[1]===e[1]&&r[2]===e[2]||(e=r,n.current&&(n.current.style.transform=Id(r)))};return r(),t.subscribe(r)},[t]),(0,G.jsx)(`div`,{ref:n,className:`react-flow__viewport xyflow__viewport react-flow__container`,style:{transform:Id(r)},children:e})}function Rd(e){let t=$l(),n=(0,W.useRef)(!1);(0,W.useEffect)(()=>{!n.current&&t.viewportInitialized&&e&&(setTimeout(()=>e(t),1),n.current=!0)},[e,t.viewportInitialized])}var zd=e=>e.panZoom?.syncViewport;function Bd(e){let t=X(zd),n=Z();return(0,W.useEffect)(()=>{e&&(t?.(e),n.setState({transform:[e.x,e.y,e.zoom]}))},[e,t]),null}function Vd(e){return e.connection.inProgress?{...e.connection,to:qo(e.connection.to,e.transform)}:{...e.connection}}function Hd(e){return e?t=>e(Vd(t)):Vd}function Ud(e){return X(Hd(e),Y)}var Wd=e=>({nodesConnectable:e.nodesConnectable,isValid:e.connection.isValid,inProgress:e.connection.inProgress,width:e.width,height:e.height});function Gd({containerStyle:e,style:t,type:n,component:r}){let{nodesConnectable:i,width:a,height:o,isValid:s,inProgress:c}=X(Wd,Y);return a&&i&&c?(0,G.jsx)(`svg`,{style:e,width:a,height:o,className:`react-flow__connectionline react-flow__container`,children:(0,G.jsx)(`g`,{className:K([`react-flow__connection`,_o(s)]),children:(0,G.jsx)(Kd,{style:t,type:n,CustomComponent:r,isValid:s})})}):null}var Kd=({style:e,type:t=mo.Bezier,CustomComponent:n,isValid:r})=>{let{inProgress:i,from:a,fromNode:o,fromHandle:s,fromPosition:c,to:l,toNode:u,toHandle:d,toPosition:f,pointer:p}=Ud();if(!i)return;if(n)return(0,G.jsx)(n,{connectionLineType:t,connectionLineStyle:e,fromNode:o,fromHandle:s,fromX:a.x,fromY:a.y,toX:l.x,toY:l.y,fromPosition:c,toPosition:f,connectionStatus:_o(r),toNode:u,toHandle:d,pointer:p});let m=``,h={sourceX:a.x,sourceY:a.y,sourcePosition:c,targetX:l.x,targetY:l.y,targetPosition:f};switch(t){case mo.Bezier:[m]=vs(h);break;case mo.SimpleBezier:[m]=cd(h);break;case mo.Step:[m]=js({...h,borderRadius:0});break;case mo.SmoothStep:[m]=js(h);break;default:[m]=Ts(h)}return(0,G.jsx)(`path`,{d:m,fill:`none`,className:`react-flow__connection-path`,style:e})};Kd.displayName=`ConnectionLine`;var qd={};function Jd(e=qd){(0,W.useRef)(e),Z(),(0,W.useEffect)(()=>{},[e])}function Yd(){Z(),(0,W.useRef)(!1),(0,W.useEffect)(()=>{},[])}function Xd({nodeTypes:e,edgeTypes:t,onInit:n,onNodeClick:r,onEdgeClick:i,onNodeDoubleClick:a,onEdgeDoubleClick:o,onNodeMouseEnter:s,onNodeMouseMove:c,onNodeMouseLeave:l,onNodeContextMenu:u,onSelectionContextMenu:d,onSelectionStart:f,onSelectionEnd:p,connectionLineType:m,connectionLineStyle:h,connectionLineComponent:g,connectionLineContainerStyle:_,selectionKeyCode:v,selectionOnDrag:y,selectionMode:b,multiSelectionKeyCode:x,panActivationKeyCode:S,zoomActivationKeyCode:C,deleteKeyCode:w,onlyRenderVisibleElements:T,elementsSelectable:E,defaultViewport:D,translateExtent:O,minZoom:k,maxZoom:A,preventScrolling:j,defaultMarkerColor:M,zoomOnScroll:N,zoomOnPinch:P,panOnScroll:F,panOnScrollSpeed:I,panOnScrollMode:L,zoomOnDoubleClick:R,panOnDrag:z,autoPanOnSelection:B,onPaneClick:V,onPaneMouseEnter:H,onPaneMouseMove:ee,onPaneMouseLeave:te,onPaneScroll:U,onPaneContextMenu:ne,paneClickDistance:re,nodeClickDistance:ie,onEdgeContextMenu:ae,onEdgeMouseEnter:oe,onEdgeMouseMove:se,onEdgeMouseLeave:ce,reconnectRadius:le,onReconnect:ue,onReconnectStart:de,onReconnectEnd:fe,noDragClassName:pe,noWheelClassName:me,noPanClassName:he,disableKeyboardA11y:ge,nodeExtent:_e,rfId:ve,viewport:ye,onViewportChange:be,nodesDraggable:xe}){return Jd(e),Jd(t),Yd(),Rd(n),Bd(ye),(0,G.jsx)(zu,{onPaneClick:V,onPaneMouseEnter:H,onPaneMouseMove:ee,onPaneMouseLeave:te,onPaneContextMenu:ne,onPaneScroll:U,paneClickDistance:re,deleteKeyCode:w,selectionKeyCode:v,selectionOnDrag:y,selectionMode:b,onSelectionStart:f,onSelectionEnd:p,multiSelectionKeyCode:x,panActivationKeyCode:S,zoomActivationKeyCode:C,elementsSelectable:E,zoomOnScroll:N,zoomOnPinch:P,zoomOnDoubleClick:R,panOnScroll:F,panOnScrollSpeed:I,panOnScrollMode:L,panOnDrag:z,autoPanOnSelection:B,defaultViewport:D,translateExtent:O,minZoom:k,maxZoom:A,onSelectionContextMenu:d,preventScrolling:j,noDragClassName:pe,noWheelClassName:me,noPanClassName:he,disableKeyboardA11y:ge,onViewportChange:be,isControlledViewport:!!ye,children:(0,G.jsxs)(Ld,{children:[(0,G.jsx)(Fd,{edgeTypes:t,onEdgeClick:i,onEdgeDoubleClick:o,onReconnect:ue,onReconnectStart:de,onReconnectEnd:fe,onlyRenderVisibleElements:T,onEdgeContextMenu:ae,onEdgeMouseEnter:oe,onEdgeMouseMove:se,onEdgeMouseLeave:ce,reconnectRadius:le,defaultMarkerColor:M,noPanClassName:he,disableKeyboardA11y:ge,rfId:ve}),(0,G.jsx)(Gd,{style:h,type:m,component:g,containerStyle:_}),(0,G.jsx)(`div`,{className:`react-flow__edgelabel-renderer`}),(0,G.jsx)(Yu,{nodeTypes:e,onNodeClick:r,onNodeDoubleClick:a,onNodeMouseEnter:s,onNodeMouseMove:c,onNodeMouseLeave:l,onNodeContextMenu:u,nodeClickDistance:ie,onlyRenderVisibleElements:T,noPanClassName:he,noDragClassName:pe,disableKeyboardA11y:ge,nodeExtent:_e,rfId:ve,nodesDraggable:xe}),(0,G.jsx)(`div`,{className:`react-flow__viewport-portal`})]})})}Xd.displayName=`GraphView`;var Zd=(0,W.memo)(Xd),Qd=Go(`React Flow`,`https://reactflow.dev/`),$d=({nodes:e,edges:t,defaultNodes:n,defaultEdges:r,width:i,height:a,fitView:o,fitViewOptions:s,minZoom:c=.5,maxZoom:l=2,nodeOrigin:u,nodeExtent:d,zIndexMode:f=`basic`}={})=>{let p=new Map,m=new Map,h=new Map,g=new Map,_=r??t??[],v=n??e??[],y=u??[0,0],b=d??oo;nc(h,g,_);let{nodesInitialized:x}=qs(v,p,m,{nodeOrigin:y,nodeExtent:b,zIndexMode:f}),S=[0,0,1];if(o&&i&&a){let{x:e,y:t,zoom:n}=Qo(Co(p,{filter:e=>!!((e.width||e.initialWidth)&&(e.height||e.initialHeight))}),i,a,c,l,s?.padding??.1);S=[e,t,n]}return{rfId:`1`,width:i??0,height:a??0,transform:S,nodes:v,nodesInitialized:x,nodeLookup:p,parentLookup:m,edges:_,edgeLookup:g,connectionLookup:h,onNodesChange:null,onEdgesChange:null,hasDefaultNodes:n!==void 0,hasDefaultEdges:r!==void 0,panZoom:null,minZoom:c,maxZoom:l,translateExtent:oo,nodeExtent:b,nodesSelectionActive:!1,userSelectionActive:!1,userSelectionRect:null,connectionMode:lo.Strict,domNode:null,paneDragging:!1,noPanClassName:`nopan`,nodeOrigin:y,nodeDragThreshold:1,connectionDragThreshold:1,snapGrid:[15,15],snapToGrid:!1,nodesDraggable:!0,nodesConnectable:!0,nodesFocusable:!0,edgesFocusable:!0,edgesReconnectable:!0,elementsSelectable:!0,elevateNodesOnSelect:!0,elevateEdgesOnSelect:!0,selectNodesOnDrag:!0,multiSelectionActive:!1,fitViewQueued:o??!1,fitViewOptions:s,fitViewResolver:null,connection:{...po},connectionClickStartHandle:null,connectOnClick:!0,ariaLiveMessage:``,autoPanOnConnect:!0,autoPanOnNodeDrag:!0,autoPanOnNodeFocus:!0,autoPanSpeed:15,connectionRadius:20,onError:Qd,isValidConnection:void 0,onSelectionChangeHandlers:[],lib:`react`,debug:!1,ariaLabelConfig:co,zIndexMode:f,onNodesChangeMiddlewareMap:new Map,onEdgesChangeMiddlewareMap:new Map}},ef=({nodes:e,edges:t,defaultNodes:n,defaultEdges:r,width:i,height:a,fitView:o,fitViewOptions:s,minZoom:c,maxZoom:l,nodeOrigin:u,nodeExtent:d,zIndexMode:f})=>Qc((p,m)=>{async function h(){let{nodeLookup:e,panZoom:t,fitViewOptions:n,fitViewResolver:r,width:i,height:a,minZoom:o,maxZoom:s}=m();t&&(await Do({nodes:e,width:i,height:a,panZoom:t,minZoom:o,maxZoom:s},n),r?.resolve(!0),p({fitViewResolver:null}))}return{...$d({nodes:e,edges:t,width:i,height:a,fitView:o,fitViewOptions:s,minZoom:c,maxZoom:l,nodeOrigin:u,nodeExtent:d,defaultNodes:n,defaultEdges:r,zIndexMode:f}),setNodes:e=>{let{nodeLookup:t,parentLookup:n,nodeOrigin:r,elevateNodesOnSelect:i,fitViewQueued:a,zIndexMode:o,nodesSelectionActive:s}=m(),{nodesInitialized:c,hasSelectedNodes:l}=qs(e,t,n,{nodeOrigin:r,nodeExtent:d,elevateNodesOnSelect:i,checkEquality:!0,zIndexMode:o}),u=s&&l;a&&c?(h(),p({nodes:e,nodesInitialized:c,fitViewQueued:!1,fitViewOptions:void 0,nodesSelectionActive:u})):p({nodes:e,nodesInitialized:c,nodesSelectionActive:u})},setEdges:e=>{let{connectionLookup:t,edgeLookup:n}=m();nc(t,n,e),p({edges:e})},setDefaultNodesAndEdges:(e,t)=>{if(e){let{setNodes:t}=m();t(e),p({hasDefaultNodes:!0})}if(t){let{setEdges:e}=m();e(t),p({hasDefaultEdges:!0})}},updateNodeInternals:e=>{let{triggerNodeChanges:t,nodeLookup:n,parentLookup:r,domNode:i,nodeOrigin:a,nodeExtent:o,debug:s,fitViewQueued:c,zIndexMode:l}=m(),{changes:u,updatedInternals:d}=$s(e,n,r,i,a,o,l);d&&(Ws(n,r,{nodeOrigin:a,nodeExtent:o,zIndexMode:l}),c?(h(),p({fitViewQueued:!1,fitViewOptions:void 0})):p({}),u?.length>0&&(s&&console.log(`React Flow: trigger node changes`,u),t?.(u)))},updateNodePositions:(e,t=!1)=>{let n=[],r=[],{nodeLookup:i,triggerNodeChanges:a,connection:o,updateConnection:s,onNodesChangeMiddlewareMap:c}=m();for(let[a,c]of e){let e=i.get(a),l=!!(e?.expandParent&&e?.parentId&&c?.position),u={id:a,type:`position`,position:l?{x:Math.max(0,c.position.x),y:Math.max(0,c.position.y)}:c.position,dragging:t};if(e&&o.inProgress&&o.fromNode.id===e.id){let t=Fs(e,o.fromHandle,J.Left,!0);s({...o,from:t})}l&&e.parentId&&n.push({id:a,parentId:e.parentId,rect:{...c.internals.positionAbsolute,width:c.measured.width??0,height:c.measured.height??0}}),r.push(u)}if(n.length>0){let{parentLookup:e,nodeOrigin:t}=m(),a=Qs(n,i,e,t);r.push(...a)}for(let e of c.values())r=e(r);a(r)},triggerNodeChanges:e=>{let{onNodesChange:t,setNodes:n,nodes:r,hasDefaultNodes:i,debug:a}=m();e?.length&&(i&&n(Fl(e,r)),a&&console.log(`React Flow: trigger node changes`,e),t?.(e))},triggerEdgeChanges:e=>{let{onEdgesChange:t,setEdges:n,edges:r,hasDefaultEdges:i,debug:a}=m();e?.length&&(i&&n(Il(e,r)),a&&console.log(`React Flow: trigger edge changes`,e),t?.(e))},addSelectedNodes:e=>{let{multiSelectionActive:t,edgeLookup:n,nodeLookup:r,triggerNodeChanges:i,triggerEdgeChanges:a}=m();if(t){i(e.map(e=>Ll(e,!0)));return}i(Rl(r,new Set([...e]),!0)),a(Rl(n))},addSelectedEdges:e=>{let{multiSelectionActive:t,edgeLookup:n,nodeLookup:r,triggerNodeChanges:i,triggerEdgeChanges:a}=m();if(t){a(e.map(e=>Ll(e,!0)));return}a(Rl(n,new Set([...e]))),i(Rl(r,new Set,!0))},unselectNodesAndEdges:({nodes:e,edges:t}={})=>{let{edges:n,nodes:r,nodeLookup:i,triggerNodeChanges:a,triggerEdgeChanges:o}=m(),s=e||r,c=t||n,l=[];for(let e of s){if(!e.selected)continue;let t=i.get(e.id);t&&(t.selected=!1),l.push(Ll(e.id,!1))}let u=[];for(let e of c)e.selected&&u.push(Ll(e.id,!1));a(l),o(u)},setMinZoom:e=>{let{panZoom:t,maxZoom:n}=m();t?.setScaleExtent([e,n]),p({minZoom:e})},setMaxZoom:e=>{let{panZoom:t,minZoom:n}=m();t?.setScaleExtent([n,e]),p({maxZoom:e})},setTranslateExtent:e=>{m().panZoom?.setTranslateExtent(e),p({translateExtent:e})},resetSelectedElements:()=>{let{edges:e,nodes:t,triggerNodeChanges:n,triggerEdgeChanges:r,elementsSelectable:i}=m();if(!i)return;let a=t.reduce((e,t)=>t.selected?[...e,Ll(t.id,!1)]:e,[]),o=e.reduce((e,t)=>t.selected?[...e,Ll(t.id,!1)]:e,[]);n(a),r(o)},setNodeExtent:e=>{let{nodes:t,nodeLookup:n,parentLookup:r,nodeOrigin:i,elevateNodesOnSelect:a,nodeExtent:o,zIndexMode:s}=m();e[0][0]===o[0][0]&&e[0][1]===o[0][1]&&e[1][0]===o[1][0]&&e[1][1]===o[1][1]||(qs(t,n,r,{nodeOrigin:i,nodeExtent:e,elevateNodesOnSelect:a,checkEquality:!1,zIndexMode:s}),p({nodeExtent:e}))},panBy:e=>{let{transform:t,width:n,height:r,panZoom:i,translateExtent:a}=m();return ec({delta:e,panZoom:i,transform:t,translateExtent:a,width:n,height:r})},setCenter:async(e,t,n)=>{let{width:r,height:i,maxZoom:a,panZoom:o}=m();if(!o)return!1;let s=n?.zoom===void 0?a:n.zoom;return await o.setViewport({x:r/2-e*s,y:i/2-t*s,zoom:s},{duration:n?.duration,ease:n?.ease,interpolate:n?.interpolate}),!0},cancelConnection:()=>{p({connection:{...po}})},updateConnection:e=>{p({connection:e})},reset:()=>p({...$d()})}},Object.is);function tf({initialNodes:e,initialEdges:t,defaultNodes:n,defaultEdges:r,initialWidth:i,initialHeight:a,initialMinZoom:o,initialMaxZoom:s,initialFitViewOptions:c,fitView:l,nodeOrigin:u,nodeExtent:d,zIndexMode:f,children:p}){let[m]=(0,W.useState)(()=>ef({nodes:e,edges:t,defaultNodes:n,defaultEdges:r,width:i,height:a,fitView:l,minZoom:o,maxZoom:s,fitViewOptions:c,nodeOrigin:u,nodeExtent:d,zIndexMode:f}));return(0,G.jsx)(el,{value:m,children:(0,G.jsx)(Xl,{children:(0,G.jsx)(xu,{children:p})})})}function nf({children:e,nodes:t,edges:n,defaultNodes:r,defaultEdges:i,width:a,height:o,fitView:s,fitViewOptions:c,minZoom:l,maxZoom:u,nodeOrigin:d,nodeExtent:f,zIndexMode:p}){return(0,W.useContext)($c)?(0,G.jsx)(G.Fragment,{children:e}):(0,G.jsx)(tf,{initialNodes:t,initialEdges:n,defaultNodes:r,defaultEdges:i,initialWidth:a,initialHeight:o,fitView:s,initialFitViewOptions:c,initialMinZoom:l,initialMaxZoom:u,nodeOrigin:d,nodeExtent:f,zIndexMode:p,children:e})}var rf={width:`100%`,height:`100%`,overflow:`hidden`,position:`relative`,zIndex:0};function af({nodes:e,edges:t,defaultNodes:n,defaultEdges:r,className:i,nodeTypes:a,edgeTypes:o,onNodeClick:s,onEdgeClick:c,onInit:l,onMove:u,onMoveStart:d,onMoveEnd:f,onConnect:p,onConnectStart:m,onConnectEnd:h,onClickConnectStart:g,onClickConnectEnd:_,onNodeMouseEnter:v,onNodeMouseMove:y,onNodeMouseLeave:b,onNodeContextMenu:x,onNodeDoubleClick:S,onNodeDragStart:C,onNodeDrag:w,onNodeDragStop:T,onNodesDelete:E,onEdgesDelete:D,onDelete:O,onSelectionChange:k,onSelectionDragStart:A,onSelectionDrag:j,onSelectionDragStop:M,onSelectionContextMenu:N,onSelectionStart:P,onSelectionEnd:F,onBeforeDelete:I,connectionMode:L,connectionLineType:R=mo.Bezier,connectionLineStyle:z,connectionLineComponent:B,connectionLineContainerStyle:V,deleteKeyCode:H=`Backspace`,selectionKeyCode:ee=`Shift`,selectionOnDrag:te=!1,selectionMode:U=fo.Full,panActivationKeyCode:ne=`Space`,multiSelectionKeyCode:re=$o()?`Meta`:`Control`,zoomActivationKeyCode:ie=$o()?`Meta`:`Control`,snapToGrid:ae,snapGrid:oe,onlyRenderVisibleElements:se=!1,selectNodesOnDrag:ce,nodesDraggable:le,autoPanOnNodeFocus:ue,nodesConnectable:de,nodesFocusable:fe,nodeOrigin:pe=bl,edgesFocusable:me,edgesReconnectable:he,elementsSelectable:ge=!0,defaultViewport:_e=xl,minZoom:ve=.5,maxZoom:ye=2,translateExtent:be=oo,preventScrolling:xe=!0,nodeExtent:Se,defaultMarkerColor:Ce=`#b1b1b7`,zoomOnScroll:we=!0,zoomOnPinch:Te=!0,panOnScroll:Ee=!1,panOnScrollSpeed:De=.5,panOnScrollMode:Oe=uo.Free,zoomOnDoubleClick:ke=!0,panOnDrag:Ae=!0,onPaneClick:je,onPaneMouseEnter:Me,onPaneMouseMove:Ne,onPaneMouseLeave:Pe,onPaneScroll:Fe,onPaneContextMenu:Ie,paneClickDistance:Le=1,nodeClickDistance:Re=0,children:ze,onReconnect:Be,onReconnectStart:Ve,onReconnectEnd:He,onEdgeContextMenu:Ue,onEdgeDoubleClick:We,onEdgeMouseEnter:Ge,onEdgeMouseMove:Ke,onEdgeMouseLeave:qe,reconnectRadius:Je=10,onNodesChange:Ye,onEdgesChange:Xe,noDragClassName:Ze=`nodrag`,noWheelClassName:Qe=`nowheel`,noPanClassName:$e=`nopan`,fitView:et,fitViewOptions:tt,connectOnClick:nt,attributionPosition:rt,proOptions:it,defaultEdgeOptions:at,elevateNodesOnSelect:ot=!0,elevateEdgesOnSelect:st=!1,disableKeyboardA11y:ct=!1,autoPanOnConnect:lt,autoPanOnNodeDrag:ut,autoPanOnSelection:dt=!0,autoPanSpeed:ft,connectionRadius:pt,isValidConnection:mt,onError:ht,style:gt,id:_t,nodeDragThreshold:vt,connectionDragThreshold:yt,viewport:bt,onViewportChange:xt,width:St,height:Ct,colorMode:wt=`light`,debug:Tt,onScroll:Et,ariaLabelConfig:Dt,zIndexMode:Ot=`basic`,...kt},At){let jt=_t||`1`,Mt=Dl(wt),Nt=(0,W.useCallback)(e=>{e.currentTarget.scrollTo({top:0,left:0,behavior:`instant`}),Et?.(e)},[Et]);return(0,G.jsx)(`div`,{"data-testid":`rf__wrapper`,...kt,onScroll:Nt,style:{...gt,...rf},ref:At,className:K([`react-flow`,i,Mt]),id:_t,role:`application`,children:(0,G.jsxs)(nf,{nodes:e,edges:t,width:St,height:Ct,fitView:et,fitViewOptions:tt,minZoom:ve,maxZoom:ye,nodeOrigin:pe,nodeExtent:Se,zIndexMode:Ot,children:[(0,G.jsx)(Tl,{nodes:e,edges:t,defaultNodes:n,defaultEdges:r,onConnect:p,onConnectStart:m,onConnectEnd:h,onClickConnectStart:g,onClickConnectEnd:_,nodesDraggable:le,autoPanOnNodeFocus:ue,nodesConnectable:de,nodesFocusable:fe,edgesFocusable:me,edgesReconnectable:he,elementsSelectable:ge,elevateNodesOnSelect:ot,elevateEdgesOnSelect:st,minZoom:ve,maxZoom:ye,nodeExtent:Se,onNodesChange:Ye,onEdgesChange:Xe,snapToGrid:ae,snapGrid:oe,connectionMode:L,translateExtent:be,connectOnClick:nt,defaultEdgeOptions:at,fitView:et,fitViewOptions:tt,onNodesDelete:E,onEdgesDelete:D,onDelete:O,onNodeDragStart:C,onNodeDrag:w,onNodeDragStop:T,onSelectionDrag:j,onSelectionDragStart:A,onSelectionDragStop:M,onMove:u,onMoveStart:d,onMoveEnd:f,noPanClassName:$e,nodeOrigin:pe,rfId:jt,autoPanOnConnect:lt,autoPanOnNodeDrag:ut,autoPanSpeed:ft,onError:ht,connectionRadius:pt,isValidConnection:mt,selectNodesOnDrag:ce,nodeDragThreshold:vt,connectionDragThreshold:yt,onBeforeDelete:I,debug:Tt,ariaLabelConfig:Dt,zIndexMode:Ot}),(0,G.jsx)(Zd,{onInit:l,onNodeClick:s,onEdgeClick:c,onNodeMouseEnter:v,onNodeMouseMove:y,onNodeMouseLeave:b,onNodeContextMenu:x,onNodeDoubleClick:S,nodeTypes:a,edgeTypes:o,connectionLineType:R,connectionLineStyle:z,connectionLineComponent:B,connectionLineContainerStyle:V,selectionKeyCode:ee,selectionOnDrag:te,selectionMode:U,deleteKeyCode:H,multiSelectionKeyCode:re,panActivationKeyCode:ne,zoomActivationKeyCode:ie,onlyRenderVisibleElements:se,defaultViewport:_e,translateExtent:be,minZoom:ve,maxZoom:ye,preventScrolling:xe,zoomOnScroll:we,zoomOnPinch:Te,zoomOnDoubleClick:ke,panOnScroll:Ee,panOnScrollSpeed:De,panOnScrollMode:Oe,panOnDrag:Ae,autoPanOnSelection:dt,onPaneClick:je,onPaneMouseEnter:Me,onPaneMouseMove:Ne,onPaneMouseLeave:Pe,onPaneScroll:Fe,onPaneContextMenu:Ie,paneClickDistance:Le,nodeClickDistance:Re,onSelectionContextMenu:N,onSelectionStart:P,onSelectionEnd:F,onReconnect:Be,onReconnectStart:Ve,onReconnectEnd:He,onEdgeContextMenu:Ue,onEdgeDoubleClick:We,onEdgeMouseEnter:Ge,onEdgeMouseMove:Ke,onEdgeMouseLeave:qe,reconnectRadius:Je,defaultMarkerColor:Ce,noDragClassName:Ze,noWheelClassName:Qe,noPanClassName:$e,rfId:jt,disableKeyboardA11y:ct,nodeExtent:Se,viewport:bt,onViewportChange:xt,nodesDraggable:le}),(0,G.jsx)(yl,{onSelectionChange:k}),ze,(0,G.jsx)(pl,{proOptions:it,position:rt}),(0,G.jsx)(ul,{rfId:jt,disableKeyboardA11y:ct})]})})}var of=Gl(af);function sf(e){let[t,n]=(0,W.useState)(e);return[t,n,(0,W.useCallback)(e=>n(t=>Fl(e,t)),[])]}function cf(e){let[t,n]=(0,W.useState)(e);return[t,n,(0,W.useCallback)(e=>n(t=>Il(e,t)),[])]}ao.error014();function lf({dimensions:e,lineWidth:t,variant:n,className:r}){return(0,G.jsx)(`path`,{strokeWidth:t,d:`M${e[0]/2} 0 V${e[1]} M0 ${e[1]/2} H${e[0]}`,className:K([`react-flow__background-pattern`,n,r])})}function uf({radius:e,className:t}){return(0,G.jsx)(`circle`,{cx:e,cy:e,r:e,className:K([`react-flow__background-pattern`,`dots`,t])})}var df;(function(e){e.Lines=`lines`,e.Dots=`dots`,e.Cross=`cross`})(df||={});var ff={[df.Dots]:1,[df.Lines]:1,[df.Cross]:6},pf=e=>({transform:e.transform,patternId:`pattern-${e.rfId}`});function mf({id:e,variant:t=df.Dots,gap:n=20,size:r,lineWidth:i=1,offset:a=0,color:o,bgColor:s,style:c,className:l,patternClassName:u}){let d=(0,W.useRef)(null),{transform:f,patternId:p}=X(pf,Y),m=r||ff[t],h=t===df.Dots,g=t===df.Cross,_=Array.isArray(n)?n:[n,n],v=[_[0]*f[2]||1,_[1]*f[2]||1],y=m*f[2],b=Array.isArray(a)?a:[a,a],x=g?[y,y]:v,S=[b[0]*f[2]||1+x[0]/2,b[1]*f[2]||1+x[1]/2],C=`${p}${e||``}`;return(0,G.jsxs)(`svg`,{className:K([`react-flow__background`,l]),style:{...c,...iu,"--xy-background-color-props":s,"--xy-background-pattern-color-props":o},ref:d,"data-testid":`rf__background`,children:[(0,G.jsx)(`pattern`,{id:C,x:f[0]%v[0],y:f[1]%v[1],width:v[0],height:v[1],patternUnits:`userSpaceOnUse`,patternTransform:`translate(-${S[0]},-${S[1]})`,children:h?(0,G.jsx)(uf,{radius:y/2,className:u}):(0,G.jsx)(lf,{dimensions:x,lineWidth:i,variant:t,className:u})}),(0,G.jsx)(`rect`,{x:`0`,y:`0`,width:`100%`,height:`100%`,fill:`url(#${C})`})]})}mf.displayName=`Background`;var hf=(0,W.memo)(mf);function gf(){return(0,G.jsx)(`svg`,{xmlns:`http://www.w3.org/2000/svg`,viewBox:`0 0 32 32`,children:(0,G.jsx)(`path`,{d:`M32 18.133H18.133V32h-4.266V18.133H0v-4.266h13.867V0h4.266v13.867H32z`})})}function _f(){return(0,G.jsx)(`svg`,{xmlns:`http://www.w3.org/2000/svg`,viewBox:`0 0 32 5`,children:(0,G.jsx)(`path`,{d:`M0 0h32v4.2H0z`})})}function vf(){return(0,G.jsx)(`svg`,{xmlns:`http://www.w3.org/2000/svg`,viewBox:`0 0 32 30`,children:(0,G.jsx)(`path`,{d:`M3.692 4.63c0-.53.4-.938.939-.938h5.215V0H4.708C2.13 0 0 2.054 0 4.63v5.216h3.692V4.631zM27.354 0h-5.2v3.692h5.17c.53 0 .984.4.984.939v5.215H32V4.631A4.624 4.624 0 0027.354 0zm.954 24.83c0 .532-.4.94-.939.94h-5.215v3.768h5.215c2.577 0 4.631-2.13 4.631-4.707v-5.139h-3.692v5.139zm-23.677.94c-.531 0-.939-.4-.939-.94v-5.138H0v5.139c0 2.577 2.13 4.707 4.708 4.707h5.138V25.77H4.631z`})})}function yf(){return(0,G.jsx)(`svg`,{xmlns:`http://www.w3.org/2000/svg`,viewBox:`0 0 25 32`,children:(0,G.jsx)(`path`,{d:`M21.333 10.667H19.81V7.619C19.81 3.429 16.38 0 12.19 0 8 0 4.571 3.429 4.571 7.619v3.048H3.048A3.056 3.056 0 000 13.714v15.238A3.056 3.056 0 003.048 32h18.285a3.056 3.056 0 003.048-3.048V13.714a3.056 3.056 0 00-3.048-3.047zM12.19 24.533a3.056 3.056 0 01-3.047-3.047 3.056 3.056 0 013.047-3.048 3.056 3.056 0 013.048 3.048 3.056 3.056 0 01-3.048 3.047zm4.724-13.866H7.467V7.619c0-2.59 2.133-4.724 4.723-4.724 2.591 0 4.724 2.133 4.724 4.724v3.048z`})})}function bf(){return(0,G.jsx)(`svg`,{xmlns:`http://www.w3.org/2000/svg`,viewBox:`0 0 25 32`,children:(0,G.jsx)(`path`,{d:`M21.333 10.667H19.81V7.619C19.81 3.429 16.38 0 12.19 0c-4.114 1.828-1.37 2.133.305 2.438 1.676.305 4.42 2.59 4.42 5.181v3.048H3.047A3.056 3.056 0 000 13.714v15.238A3.056 3.056 0 003.048 32h18.285a3.056 3.056 0 003.048-3.048V13.714a3.056 3.056 0 00-3.048-3.047zM12.19 24.533a3.056 3.056 0 01-3.047-3.047 3.056 3.056 0 013.047-3.048 3.056 3.056 0 013.048 3.048 3.056 3.056 0 01-3.048 3.047z`})})}function xf({children:e,className:t,...n}){return(0,G.jsx)(`button`,{type:`button`,className:K([`react-flow__controls-button`,t]),...n,children:e})}var Sf=e=>({isInteractive:e.nodesDraggable||e.nodesConnectable||e.elementsSelectable,minZoomReached:e.transform[2]<=e.minZoom,maxZoomReached:e.transform[2]>=e.maxZoom,ariaLabelConfig:e.ariaLabelConfig});function Cf({style:e,showZoom:t=!0,showFitView:n=!0,showInteractive:r=!0,fitViewOptions:i,onZoomIn:a,onZoomOut:o,onFitView:s,onInteractiveChange:c,className:l,children:u,position:d=`bottom-left`,orientation:f=`vertical`,"aria-label":p}){let m=Z(),{isInteractive:h,minZoomReached:g,maxZoomReached:_,ariaLabelConfig:v}=X(Sf,Y),{zoomIn:y,zoomOut:b,fitView:x}=$l();return(0,G.jsxs)(dl,{className:K([`react-flow__controls`,f===`horizontal`?`horizontal`:`vertical`,l]),position:d,style:e,"data-testid":`rf__controls`,"aria-label":p??v[`controls.ariaLabel`],children:[t&&(0,G.jsxs)(G.Fragment,{children:[(0,G.jsx)(xf,{onClick:()=>{y(),a?.()},className:`react-flow__controls-zoomin`,title:v[`controls.zoomIn.ariaLabel`],"aria-label":v[`controls.zoomIn.ariaLabel`],disabled:_,children:(0,G.jsx)(gf,{})}),(0,G.jsx)(xf,{onClick:()=>{b(),o?.()},className:`react-flow__controls-zoomout`,title:v[`controls.zoomOut.ariaLabel`],"aria-label":v[`controls.zoomOut.ariaLabel`],disabled:g,children:(0,G.jsx)(_f,{})})]}),n&&(0,G.jsx)(xf,{className:`react-flow__controls-fitview`,onClick:()=>{x(i),s?.()},title:v[`controls.fitView.ariaLabel`],"aria-label":v[`controls.fitView.ariaLabel`],children:(0,G.jsx)(vf,{})}),r&&(0,G.jsx)(xf,{className:`react-flow__controls-interactive`,onClick:()=>{m.setState({nodesDraggable:!h,nodesConnectable:!h,elementsSelectable:!h}),c?.(!h)},title:v[`controls.interactive.ariaLabel`],"aria-label":v[`controls.interactive.ariaLabel`],children:h?(0,G.jsx)(bf,{}):(0,G.jsx)(yf,{})}),u]})}Cf.displayName=`Controls`,(0,W.memo)(Cf);function wf({id:e,x:t,y:n,width:r,height:i,style:a,color:o,strokeColor:s,strokeWidth:c,className:l,borderRadius:u,shapeRendering:d,selected:f,onClick:p}){let{background:m,backgroundColor:h}=a||{},g=o||m||h;return(0,G.jsx)(`rect`,{className:K([`react-flow__minimap-node`,{selected:f},l]),x:t,y:n,rx:u,ry:u,width:r,height:i,style:{fill:g,stroke:s,strokeWidth:c},shapeRendering:d,onClick:p?t=>p(t,e):void 0})}var Tf=(0,W.memo)(wf),Ef=e=>e.nodes.map(e=>e.id),Df=e=>e instanceof Function?e:()=>e;function Of({nodeStrokeColor:e,nodeColor:t,nodeClassName:n=``,nodeBorderRadius:r=5,nodeStrokeWidth:i,nodeComponent:a=Tf,onClick:o}){let s=X(Ef,Y),c=Df(t),l=Df(e),u=Df(n),d=typeof window>`u`||window.chrome?`crispEdges`:`geometricPrecision`;return(0,G.jsx)(G.Fragment,{children:s.map(e=>(0,G.jsx)(Af,{id:e,nodeColorFunc:c,nodeStrokeColorFunc:l,nodeClassNameFunc:u,nodeBorderRadius:r,nodeStrokeWidth:i,NodeComponent:a,onClick:o,shapeRendering:d},e))})}function kf({id:e,nodeColorFunc:t,nodeStrokeColorFunc:n,nodeClassNameFunc:r,nodeBorderRadius:i,nodeStrokeWidth:a,shapeRendering:o,NodeComponent:s,onClick:c}){let{node:l,x:u,y:d,width:f,height:p}=X(t=>{let n=t.nodeLookup.get(e);if(!n)return{node:void 0,x:0,y:0,width:0,height:0};let r=n.internals.userNode,{x:i,y:a}=n.internals.positionAbsolute,{width:o,height:s}=ts(r);return{node:r,x:i,y:a,width:o,height:s}},Y);return!l||l.hidden||!ns(l)?null:(0,G.jsx)(s,{x:u,y:d,width:f,height:p,style:l.style,selected:!!l.selected,className:r(l),color:t(l),borderRadius:i,strokeColor:n(l),strokeWidth:a,shapeRendering:o,onClick:c,id:l.id})}var Af=(0,W.memo)(kf),jf=(0,W.memo)(Of),Mf=200,Nf=150,Pf=e=>!e.hidden,Ff=e=>{let t={x:-e.transform[0]/e.transform[2],y:-e.transform[1]/e.transform[2],width:e.width/e.transform[2],height:e.height/e.transform[2]};return{viewBB:t,boundingRect:e.nodeLookup.size>0?Bo(Co(e.nodeLookup,{filter:Pf}),t):t,rfId:e.rfId,panZoom:e.panZoom,translateExtent:e.translateExtent,flowWidth:e.width,flowHeight:e.height,ariaLabelConfig:e.ariaLabelConfig}},If=(e,t)=>e.x===t.x&&e.y===t.y&&e.width===t.width&&e.height===t.height,Lf=(e,t)=>If(e.viewBB,t.viewBB)&&If(e.boundingRect,t.boundingRect)&&e.rfId===t.rfId&&e.panZoom===t.panZoom&&e.translateExtent===t.translateExtent&&e.flowWidth===t.flowWidth&&e.flowHeight===t.flowHeight&&e.ariaLabelConfig===t.ariaLabelConfig,Rf=`react-flow__minimap-desc`;function zf({style:e,className:t,nodeStrokeColor:n,nodeColor:r,nodeClassName:i=``,nodeBorderRadius:a=5,nodeStrokeWidth:o,nodeComponent:s,bgColor:c,maskColor:l,maskStrokeColor:u,maskStrokeWidth:d,position:f=`bottom-right`,onClick:p,onNodeClick:m,pannable:h=!1,zoomable:g=!1,ariaLabel:_,inversePan:v,zoomStep:y=1,offsetScale:b=5}){let x=Z(),S=(0,W.useRef)(null),{boundingRect:C,viewBB:w,rfId:T,panZoom:E,translateExtent:D,flowWidth:O,flowHeight:k,ariaLabelConfig:A}=X(Ff,Lf),j=e?.width??Mf,M=e?.height??Nf,N=C.width/j,P=C.height/M,F=Math.max(N,P),I=F*j,L=F*M,R=b*F,z=C.x-(I-C.width)/2-R,B=C.y-(L-C.height)/2-R,V=I+R*2,H=L+R*2,ee=`${Rf}-${T}`,te=(0,W.useRef)(0),U=(0,W.useRef)();te.current=F,(0,W.useEffect)(()=>{if(S.current&&E)return U.current=yc({domNode:S.current,panZoom:E,getTransform:()=>x.getState().transform,getViewScale:()=>te.current}),()=>{U.current?.destroy()}},[E]),(0,W.useEffect)(()=>{U.current?.update({translateExtent:D,width:O,height:k,inversePan:v,pannable:h,zoomStep:y,zoomable:g})},[h,g,v,y,D,O,k]);let ne=p?e=>{let[t,n]=U.current?.pointer(e)||[0,0];p(e,{x:t,y:n})}:void 0,re=m?(0,W.useCallback)((e,t)=>{let n=x.getState().nodeLookup.get(t).internals.userNode;m(e,n)},[]):void 0,ie=_??A[`minimap.ariaLabel`];return(0,G.jsx)(dl,{position:f,style:{...e,"--xy-minimap-background-color-props":typeof c==`string`?c:void 0,"--xy-minimap-mask-background-color-props":typeof l==`string`?l:void 0,"--xy-minimap-mask-stroke-color-props":typeof u==`string`?u:void 0,"--xy-minimap-mask-stroke-width-props":typeof d==`number`?d*F:void 0,"--xy-minimap-node-background-color-props":typeof r==`string`?r:void 0,"--xy-minimap-node-stroke-color-props":typeof n==`string`?n:void 0,"--xy-minimap-node-stroke-width-props":typeof o==`number`?o:void 0},className:K([`react-flow__minimap`,t]),"data-testid":`rf__minimap`,children:(0,G.jsxs)(`svg`,{width:j,height:M,viewBox:`${z} ${B} ${V} ${H}`,className:`react-flow__minimap-svg`,role:`img`,"aria-labelledby":ee,ref:S,onClick:ne,children:[ie&&(0,G.jsx)(`title`,{id:ee,children:ie}),(0,G.jsx)(jf,{onClick:re,nodeColor:r,nodeStrokeColor:n,nodeBorderRadius:a,nodeClassName:i,nodeStrokeWidth:o,nodeComponent:s}),(0,G.jsx)(`path`,{className:`react-flow__minimap-mask`,d:`M${z-R},${B-R}h${V+R*2}v${H+R*2}h${-V-R*2}z - M${w.x},${w.y}h${w.width}v${w.height}h${-w.width}z`,fillRule:`evenodd`,pointerEvents:`none`})]})})}zf.displayName=`MiniMap`;var Bf=(0,W.memo)(zf),Vf=e=>t=>e?`${Math.max(1/t.transform[2],1)}`:void 0,Hf={[Pc.Line]:`right`,[Pc.Handle]:`bottom-right`};function Uf({nodeId:e,position:t,variant:n=Pc.Handle,className:r,style:i=void 0,children:a,color:o,minWidth:s=10,minHeight:c=10,maxWidth:l=Number.MAX_VALUE,maxHeight:u=Number.MAX_VALUE,keepAspectRatio:d=!1,resizeDirection:f,autoScale:p=!0,shouldResize:m,onResizeStart:h,onResize:g,onResizeEnd:_}){let v=vu(),y=typeof e==`string`?e:v,b=Z(),x=(0,W.useRef)(null),S=n===Pc.Handle,C=X((0,W.useCallback)(Vf(S&&p),[S,p]),Y),w=(0,W.useRef)(null),T=t??Hf[n];return(0,W.useEffect)(()=>{if(!(!x.current||!y))return w.current||=Gc({domNode:x.current,nodeId:y,getStoreItems:()=>{let{nodeLookup:e,transform:t,snapGrid:n,snapToGrid:r,nodeOrigin:i,domNode:a}=b.getState();return{nodeLookup:e,transform:t,snapGrid:n,snapToGrid:r,nodeOrigin:i,paneDomNode:a}},onChange:(e,t)=>{let{triggerNodeChanges:n,nodeLookup:r,parentLookup:i,nodeOrigin:a}=b.getState(),o=[],s={x:e.x,y:e.y},c=r.get(y);if(c&&c.expandParent&&c.parentId){let t=c.origin??a,n=e.width??c.measured.width??0,l=e.height??c.measured.height??0,u=Qs([{id:c.id,parentId:c.parentId,rect:{width:n,height:l,...rs({x:e.x??c.position.x,y:e.y??c.position.y},{width:n,height:l},c.parentId,r,t)}}],r,i,a);o.push(...u),s.x=e.x?Math.max(t[0]*n,e.x):void 0,s.y=e.y?Math.max(t[1]*l,e.y):void 0}if(s.x!==void 0&&s.y!==void 0){let e={id:y,type:`position`,position:{...s}};o.push(e)}if(e.width!==void 0&&e.height!==void 0){let t={id:y,type:`dimensions`,resizing:!0,setAttributes:f?f===`horizontal`?`width`:`height`:!0,dimensions:{width:e.width,height:e.height}};o.push(t)}for(let e of t){let t={...e,type:`position`};o.push(t)}n(o)},onEnd:({width:e,height:t})=>{let n={id:y,type:`dimensions`,resizing:!1,dimensions:{width:e,height:t}};b.getState().triggerNodeChanges([n])}}),w.current.update({controlPosition:T,boundaries:{minWidth:s,minHeight:c,maxWidth:l,maxHeight:u},keepAspectRatio:d,resizeDirection:f,onResizeStart:h,onResize:g,onResizeEnd:_,shouldResize:m}),()=>{w.current?.destroy()}},[T,s,c,l,u,d,h,g,_,m]),(0,G.jsx)(`div`,{className:K([`react-flow__resize-control`,`nodrag`,...T.split(`-`),n,r]),ref:x,style:{...i,scale:C,...o&&{[S?`backgroundColor`:`borderColor`]:o}},children:a})}(0,W.memo)(Uf);function Wf(e,t){if(e.match(/^[a-z]+:\/\//i))return e;if(e.match(/^\/\//))return window.location.protocol+e;if(e.match(/^[a-z]+:/i))return e;let n=document.implementation.createHTMLDocument(),r=n.createElement(`base`),i=n.createElement(`a`);return n.head.appendChild(r),n.body.appendChild(i),t&&(r.href=t),i.href=e,i.href}var Gf=(()=>{let e=0,t=()=>`0000${(Math.random()*36**4<<0).toString(36)}`.slice(-4);return()=>(e+=1,`u${t()}${e}`)})();function Kf(e){let t=[];for(let n=0,r=e.length;nQ||e.height>Q)&&(e.width>Q&&e.height>Q?e.width>e.height?(e.height*=Q/e.width,e.width=Q):(e.width*=Q/e.height,e.height=Q):e.width>Q?(e.height*=Q/e.width,e.width=Q):(e.width*=Q/e.height,e.height=Q))}function tp(e){return new Promise((t,n)=>{let r=new Image;r.onload=()=>{r.decode().then(()=>{requestAnimationFrame(()=>t(r))})},r.onerror=n,r.crossOrigin=`anonymous`,r.decoding=`async`,r.src=e})}async function np(e){return Promise.resolve().then(()=>new XMLSerializer().serializeToString(e)).then(encodeURIComponent).then(e=>`data:image/svg+xml;charset=utf-8,${e}`)}async function rp(e,t,n){let r=`http://www.w3.org/2000/svg`,i=document.createElementNS(r,`svg`),a=document.createElementNS(r,`foreignObject`);return i.setAttribute(`width`,`${t}`),i.setAttribute(`height`,`${n}`),i.setAttribute(`viewBox`,`0 0 ${t} ${n}`),a.setAttribute(`width`,`100%`),a.setAttribute(`height`,`100%`),a.setAttribute(`x`,`0`),a.setAttribute(`y`,`0`),a.setAttribute(`externalResourcesRequired`,`true`),i.appendChild(a),a.appendChild(e),np(i)}var $=(e,t)=>{if(e instanceof t)return!0;let n=Object.getPrototypeOf(e);return n===null?!1:n.constructor.name===t.name||$(n,t)};function ip(e){let t=e.getPropertyValue(`content`);return`${e.cssText} content: '${t.replace(/'|"/g,``)}';`}function ap(e,t){return Jf(t).map(t=>`${t}: ${e.getPropertyValue(t)}${e.getPropertyPriority(t)?` !important`:``};`).join(` `)}function op(e,t,n,r){let i=`.${e}:${t}`,a=n.cssText?ip(n):ap(n,r);return document.createTextNode(`${i}{${a}}`)}function sp(e,t,n,r){let i=window.getComputedStyle(e,n),a=i.getPropertyValue(`content`);if(a===``||a===`none`)return;let o=Gf();try{t.className=`${t.className} ${o}`}catch{return}let s=document.createElement(`style`);s.appendChild(op(o,n,i,r)),t.appendChild(s)}function cp(e,t,n){sp(e,t,`:before`,n),sp(e,t,`:after`,n)}var lp=`application/font-woff`,up=`image/jpeg`,dp={woff:lp,woff2:lp,ttf:`application/font-truetype`,eot:`application/vnd.ms-fontobject`,png:`image/png`,jpg:up,jpeg:up,gif:`image/gif`,tiff:`image/tiff`,svg:`image/svg+xml`,webp:`image/webp`};function fp(e){let t=/\.([^./]*?)$/g.exec(e);return t?t[1]:``}function pp(e){return dp[fp(e).toLowerCase()]||``}function mp(e){return e.split(/,/)[1]}function hp(e){return e.search(/^(data:)/)!==-1}function gp(e,t){return`data:${t};base64,${e}`}async function _p(e,t,n){let r=await fetch(e,t);if(r.status===404)throw Error(`Resource "${r.url}" not found`);let i=await r.blob();return new Promise((e,t)=>{let a=new FileReader;a.onerror=t,a.onloadend=()=>{try{e(n({res:r,result:a.result}))}catch(e){t(e)}},a.readAsDataURL(i)})}var vp={};function yp(e,t,n){let r=e.replace(/\?.*/,``);return n&&(r=e),/ttf|otf|eot|woff2?/i.test(r)&&(r=r.replace(/.*\//,``)),t?`[${t}]${r}`:r}async function bp(e,t,n){let r=yp(e,t,n.includeQueryParams);if(vp[r]!=null)return vp[r];n.cacheBust&&(e+=(/\?/.test(e)?`&`:`?`)+new Date().getTime());let i;try{i=gp(await _p(e,n.fetchRequestInit,({res:e,result:n})=>(t||=e.headers.get(`Content-Type`)||``,mp(n))),t)}catch(t){i=n.imagePlaceholder||``;let r=`Failed to fetch resource: ${e}`;t&&(r=typeof t==`string`?t:t.message),r&&console.warn(r)}return vp[r]=i,i}async function xp(e){let t=e.toDataURL();return t===`data:,`?e.cloneNode(!1):tp(t)}async function Sp(e,t){if(e.currentSrc){let t=document.createElement(`canvas`),n=t.getContext(`2d`);return t.width=e.clientWidth,t.height=e.clientHeight,n?.drawImage(e,0,0,t.width,t.height),tp(t.toDataURL())}let n=e.poster;return tp(await bp(n,pp(n),t))}async function Cp(e,t){try{if(e?.contentDocument?.body)return await Np(e.contentDocument.body,t,!0)}catch{}return e.cloneNode(!1)}async function wp(e,t){return $(e,HTMLCanvasElement)?xp(e):$(e,HTMLVideoElement)?Sp(e,t):$(e,HTMLIFrameElement)?Cp(e,t):e.cloneNode(Ep(e))}var Tp=e=>e.tagName!=null&&e.tagName.toUpperCase()===`SLOT`,Ep=e=>e.tagName!=null&&e.tagName.toUpperCase()===`SVG`;async function Dp(e,t,n){if(Ep(t))return t;let r=[];return r=Tp(e)&&e.assignedNodes?Kf(e.assignedNodes()):$(e,HTMLIFrameElement)&&e.contentDocument?.body?Kf(e.contentDocument.body.childNodes):Kf((e.shadowRoot??e).childNodes),r.length===0||$(e,HTMLVideoElement)||await r.reduce((e,r)=>e.then(()=>Np(r,n)).then(e=>{e&&t.appendChild(e)}),Promise.resolve()),t}function Op(e,t,n){let r=t.style;if(!r)return;let i=window.getComputedStyle(e);i.cssText?(r.cssText=i.cssText,r.transformOrigin=i.transformOrigin):Jf(n).forEach(n=>{let a=i.getPropertyValue(n);n===`font-size`&&a.endsWith(`px`)&&(a=`${Math.floor(parseFloat(a.substring(0,a.length-2)))-.1}px`),$(e,HTMLIFrameElement)&&n===`display`&&a===`inline`&&(a=`block`),n===`d`&&t.getAttribute(`d`)&&(a=`path(${t.getAttribute(`d`)})`),r.setProperty(n,a,i.getPropertyPriority(n))})}function kp(e,t){$(e,HTMLTextAreaElement)&&(t.innerHTML=e.value),$(e,HTMLInputElement)&&t.setAttribute(`value`,e.value)}function Ap(e,t){if($(e,HTMLSelectElement)){let n=t,r=Array.from(n.children).find(t=>e.value===t.getAttribute(`value`));r&&r.setAttribute(`selected`,``)}}function jp(e,t,n){return $(t,Element)&&(Op(e,t,n),cp(e,t,n),kp(e,t),Ap(e,t)),t}async function Mp(e,t){let n=e.querySelectorAll?e.querySelectorAll(`use`):[];if(n.length===0)return e;let r={};for(let i=0;iwp(e,t)).then(n=>Dp(e,n,t)).then(n=>jp(e,n,t)).then(e=>Mp(e,t))}var Pp=/url\((['"]?)([^'"]+?)\1\)/g,Fp=/url\([^)]+\)\s*format\((["']?)([^"']+)\1\)/g,Ip=/src:\s*(?:url\([^)]+\)\s*format\([^)]+\)[,;]\s*)+/g;function Lp(e){let t=e.replace(/([.*+?^${}()|\[\]\/\\])/g,`\\$1`);return RegExp(`(url\\(['"]?)(${t})(['"]?\\))`,`g`)}function Rp(e){let t=[];return e.replace(Pp,(e,n,r)=>(t.push(r),e)),t.filter(e=>!hp(e))}async function zp(e,t,n,r,i){try{let a=n?Wf(t,n):t,o=pp(t),s;return s=i?gp(await i(a),o):await bp(a,o,r),e.replace(Lp(t),`$1${s}$3`)}catch{}return e}function Bp(e,{preferredFontFormat:t}){return t?e.replace(Ip,e=>{for(;;){let[n,,r]=Fp.exec(e)||[];if(!r)return``;if(r===t)return`src: ${n};`}}):e}function Vp(e){return e.search(Pp)!==-1}async function Hp(e,t,n){if(!Vp(e))return e;let r=Bp(e,n);return Rp(r).reduce((e,r)=>e.then(e=>zp(e,r,t,n)),Promise.resolve(r))}async function Up(e,t,n){let r=t.style?.getPropertyValue(e);if(r){let i=await Hp(r,null,n);return t.style.setProperty(e,i,t.style.getPropertyPriority(e)),!0}return!1}async function Wp(e,t){await Up(`background`,e,t)||await Up(`background-image`,e,t),await Up(`mask`,e,t)||await Up(`-webkit-mask`,e,t)||await Up(`mask-image`,e,t)||await Up(`-webkit-mask-image`,e,t)}async function Gp(e,t){let n=$(e,HTMLImageElement);if(!(n&&!hp(e.src))&&!($(e,SVGImageElement)&&!hp(e.href.baseVal)))return;let r=n?e.src:e.href.baseVal,i=await bp(r,pp(r),t);await new Promise((r,a)=>{e.onload=r,e.onerror=t.onImageErrorHandler?(...e)=>{try{r(t.onImageErrorHandler(...e))}catch(e){a(e)}}:a;let o=e;o.decode&&=r,o.loading===`lazy`&&(o.loading=`eager`),n?(e.srcset=``,e.src=i):e.href.baseVal=i})}async function Kp(e,t){let n=Kf(e.childNodes).map(e=>qp(e,t));await Promise.all(n).then(()=>e)}async function qp(e,t){$(e,Element)&&(await Wp(e,t),await Gp(e,t),await Kp(e,t))}function Jp(e,t){let{style:n}=e;t.backgroundColor&&(n.backgroundColor=t.backgroundColor),t.width&&(n.width=`${t.width}px`),t.height&&(n.height=`${t.height}px`);let r=t.style;return r!=null&&Object.keys(r).forEach(e=>{n[e]=r[e]}),e}var Yp={};async function Xp(e){let t=Yp[e];return t??(t={url:e,cssText:await(await fetch(e)).text()},Yp[e]=t,t)}async function Zp(e,t){let n=e.cssText,r=/url\(["']?([^"')]+)["']?\)/g,i=(n.match(/url\([^)]+\)/g)||[]).map(async i=>{let a=i.replace(r,`$1`);return a.startsWith(`https://`)||(a=new URL(a,e.url).href),_p(a,t.fetchRequestInit,({result:e})=>(n=n.replace(i,`url(${e})`),[i,e]))});return Promise.all(i).then(()=>n)}function Qp(e){if(e==null)return[];let t=[],n=e.replace(/(\/\*[\s\S]*?\*\/)/gi,``),r=RegExp(`((@.*?keyframes [\\s\\S]*?){([\\s\\S]*?}\\s*?)})`,`gi`);for(;;){let e=r.exec(n);if(e===null)break;t.push(e[0])}n=n.replace(r,``);let i=/@import[\s\S]*?url\([^)]*\)[\s\S]*?;/gi,a=RegExp(`((\\s*?(?:\\/\\*[\\s\\S]*?\\*\\/)?\\s*?@media[\\s\\S]*?){([\\s\\S]*?)}\\s*?})|(([\\s\\S]*?){([\\s\\S]*?)})`,`gi`);for(;;){let e=i.exec(n);if(e===null){if(e=a.exec(n),e===null)break;i.lastIndex=a.lastIndex}else a.lastIndex=i.lastIndex;t.push(e[0])}return t}async function $p(e,t){let n=[],r=[];return e.forEach(n=>{if(`cssRules`in n)try{Kf(n.cssRules||[]).forEach((e,i)=>{if(e.type===CSSRule.IMPORT_RULE){let a=i+1,o=e.href,s=Xp(o).then(e=>Zp(e,t)).then(e=>Qp(e).forEach(e=>{try{n.insertRule(e,e.startsWith(`@import`)?a+=1:n.cssRules.length)}catch(t){console.error(`Error inserting rule from remote css`,{rule:e,error:t})}})).catch(e=>{console.error(`Error loading remote css`,e.toString())});r.push(s)}})}catch(i){let a=e.find(e=>e.href==null)||document.styleSheets[0];n.href!=null&&r.push(Xp(n.href).then(e=>Zp(e,t)).then(e=>Qp(e).forEach(e=>{a.insertRule(e,a.cssRules.length)})).catch(e=>{console.error(`Error loading remote stylesheet`,e)})),console.error(`Error inlining remote css file`,i)}}),Promise.all(r).then(()=>(e.forEach(e=>{if(`cssRules`in e)try{Kf(e.cssRules||[]).forEach(e=>{n.push(e)})}catch(t){console.error(`Error while reading CSS rules from ${e.href}`,t)}}),n))}function em(e){return e.filter(e=>e.type===CSSRule.FONT_FACE_RULE).filter(e=>Vp(e.style.getPropertyValue(`src`)))}async function tm(e,t){if(e.ownerDocument==null)throw Error(`Provided element is not within a Document`);return em(await $p(Kf(e.ownerDocument.styleSheets),t))}function nm(e){return e.trim().replace(/["']/g,``)}function rm(e){let t=new Set;function n(e){(e.style.fontFamily||getComputedStyle(e).fontFamily).split(`,`).forEach(e=>{t.add(nm(e))}),Array.from(e.children).forEach(e=>{e instanceof HTMLElement&&n(e)})}return n(e),t}async function im(e,t){let n=await tm(e,t),r=rm(e);return(await Promise.all(n.filter(e=>r.has(nm(e.style.fontFamily))).map(e=>{let n=e.parentStyleSheet?e.parentStyleSheet.href:null;return Hp(e.cssText,n,t)}))).join(` -`)}async function am(e,t){let n=t.fontEmbedCSS==null?t.skipFonts?null:await im(e,t):t.fontEmbedCSS;if(n){let t=document.createElement(`style`),r=document.createTextNode(n);t.appendChild(r),e.firstChild?e.insertBefore(t,e.firstChild):e.appendChild(t)}}async function om(e,t={}){let{width:n,height:r}=Qf(e,t),i=await Np(e,t,!0);return await am(i,t),await qp(i,t),Jp(i,t),await rp(i,n,r)}async function sm(e,t={}){let{width:n,height:r}=Qf(e,t),i=await tp(await om(e,t)),a=document.createElement(`canvas`),o=a.getContext(`2d`),s=t.pixelRatio||$f(),c=t.canvasWidth||n,l=t.canvasHeight||r;return a.width=c*s,a.height=l*s,t.skipAutoScale||ep(a),a.style.width=`${c}`,a.style.height=`${l}`,t.backgroundColor&&(o.fillStyle=t.backgroundColor,o.fillRect(0,0,a.width,a.height)),o.drawImage(i,0,0,a.width,a.height),a}async function cm(e,t={}){return(await sm(e,t)).toDataURL()}var lm={frontend:{icon:pe,color:`text-blue-400`,bg:`bg-blue-500/10 border-blue-500/30`},backend:{icon:ge,color:`text-emerald-400`,bg:`bg-emerald-500/10 border-emerald-500/30`},controller:{icon:ve,color:`text-amber-400`,bg:`bg-amber-500/10 border-amber-500/30`},route:{icon:he,color:`text-cyan-400`,bg:`bg-cyan-500/10 border-cyan-500/30`},service:{icon:U,color:`text-violet-400`,bg:`bg-violet-500/10 border-violet-500/30`},repository:{icon:d,color:`text-rose-400`,bg:`bg-rose-500/10 border-rose-500/30`},database:{icon:d,color:`text-orange-400`,bg:`bg-orange-500/10 border-orange-500/30`},configuration:{icon:M,color:`text-slate-400`,bg:`bg-slate-500/10 border-slate-500/30`},authentication:{icon:_e,color:`text-green-400`,bg:`bg-green-500/10 border-green-500/30`},middleware:{icon:g,color:`text-teal-400`,bg:`bg-teal-500/10 border-teal-500/30`},utilities:{icon:ye,color:`text-zinc-400`,bg:`bg-zinc-500/10 border-zinc-500/30`},models:{icon:s,color:`text-pink-400`,bg:`bg-pink-500/10 border-pink-500/30`},"external-api":{icon:p,color:`text-sky-400`,bg:`bg-sky-500/10 border-sky-500/30`},"shared-library":{icon:ce,color:`text-indigo-400`,bg:`bg-indigo-500/10 border-indigo-500/30`},environment:{icon:te,color:`text-gray-400`,bg:`bg-gray-500/10 border-gray-500/30`},queue:{icon:le,color:`text-yellow-400`,bg:`bg-yellow-500/10 border-yellow-500/30`},cache:{icon:u,color:`text-red-400`,bg:`bg-red-500/10 border-red-500/30`}},um=(0,W.memo)(({data:t})=>{let n=lm[t.nodeType]||lm.utilities,r=n.icon,i=t.heatmapIntensity??0,a=i>0?i>.7?`ring-2 ring-red-500/60`:i>.4?`ring-2 ring-amber-500/50`:`ring-1 ring-yellow-500/30`:``;return(0,G.jsxs)(`div`,{className:e(`relative rounded-lg border px-4 py-3 min-w-[160px] max-w-[200px] shadow-sm transition-all duration-200`,n.bg,t.isSelected&&`ring-2 ring-primary shadow-lg scale-105`,t.isHighlighted&&!t.isSelected&&`ring-1 ring-primary/50 shadow-md`,a&&!t.isSelected&&a),children:[(0,G.jsx)(Eu,{type:`target`,position:J.Top,className:`!w-2 !h-2 !bg-muted-foreground/50 !border-0`}),(0,G.jsxs)(`div`,{className:`flex items-start gap-2.5`,children:[(0,G.jsx)(`div`,{className:e(`mt-0.5 shrink-0`,n.color),children:(0,G.jsx)(r,{className:`h-4 w-4`})}),(0,G.jsxs)(`div`,{className:`min-w-0 flex-1`,children:[(0,G.jsxs)(`div`,{className:`flex items-center gap-1`,children:[(0,G.jsx)(`p`,{className:`text-xs font-medium text-foreground truncate`,children:t.label}),t.isBookmarked&&(0,G.jsx)(`span`,{className:`text-amber-400 text-[10px]`,children:`*`})]}),(0,G.jsx)(`p`,{className:`text-2xs text-muted-foreground mt-0.5 line-clamp-1`,children:t.description}),(0,G.jsxs)(`div`,{className:`flex items-center gap-2 mt-1.5`,children:[t.filesCount>0&&(0,G.jsxs)(`span`,{className:`text-2xs text-muted-foreground`,children:[t.filesCount,` files`]}),(0,G.jsx)(`span`,{className:e(`text-2xs px-1 py-0.5 rounded`,t.complexity===`high`&&`bg-destructive/10 text-destructive`,t.complexity===`medium`&&`bg-warning/10 text-warning`,t.complexity===`low`&&`bg-success/10 text-success`),children:t.complexity})]})]})]}),(0,G.jsx)(Eu,{type:`source`,position:J.Bottom,className:`!w-2 !h-2 !bg-muted-foreground/50 !border-0`})]})});um.displayName=`ArchitectureNode`;var dm=j(e=>({model:null,setModel:t=>e({model:t}),selectedNodeId:null,setSelectedNodeId:t=>e({selectedNodeId:t,inspectorOpen:!!t}),highlightedNodeIds:new Set,setHighlightedNodeIds:t=>e({highlightedNodeIds:t}),searchQuery:``,setSearchQuery:t=>e({searchQuery:t}),expandedModules:new Set([`presentation`,`business-logic`,`domain`,`infrastructure`]),toggleModule:t=>e(e=>{let n=new Set(e.expandedModules);return n.has(t)?n.delete(t):n.add(t),{expandedModules:n}}),showMiniMap:!0,setShowMiniMap:t=>e({showMiniMap:t}),showGrid:!0,setShowGrid:t=>e({showGrid:t}),activeTab:`graph`,setActiveTab:t=>e({activeTab:t}),inspectorOpen:!1,setInspectorOpen:t=>e({inspectorOpen:t}),explorerOpen:!0,setExplorerOpen:t=>e({explorerOpen:t}),heatmapMode:`none`,setHeatmapMode:t=>e({heatmapMode:t}),bookmarkedNodes:new Set,toggleBookmark:t=>e(e=>{let n=new Set(e.bookmarkedNodes);return n.has(t)?n.delete(t):n.add(t),{bookmarkedNodes:n}}),hiddenNodes:new Set,hideNode:t=>e(e=>{let n=new Set(e.hiddenNodes);return n.add(t),{hiddenNodes:n}}),showAllNodes:()=>e({hiddenNodes:new Set}),isolatedSubtree:null,setIsolatedSubtree:t=>e({isolatedSubtree:t}),contextMenuTarget:null,contextMenuPosition:null,openContextMenu:(t,n)=>e({contextMenuTarget:t,contextMenuPosition:n}),closeContextMenu:()=>e({contextMenuTarget:null,contextMenuPosition:null}),bottomPanelOpen:!1,setBottomPanelOpen:t=>e({bottomPanelOpen:t})}));function fm({node:e,onClose:t}){return(0,G.jsxs)(w.div,{initial:{x:20,opacity:0},animate:{x:0,opacity:1},exit:{x:20,opacity:0},transition:{duration:.2},className:`w-80 border-l border-border bg-card flex flex-col h-full overflow-hidden`,children:[(0,G.jsxs)(`div`,{className:`flex items-center justify-between px-4 py-3 border-b border-border shrink-0`,children:[(0,G.jsxs)(`div`,{className:`min-w-0`,children:[(0,G.jsx)(`h3`,{className:`text-sm font-medium text-foreground truncate`,children:e.name}),(0,G.jsx)(`p`,{className:`text-2xs text-muted-foreground capitalize`,children:e.type.replace(`-`,` `)})]}),(0,G.jsx)(`button`,{onClick:t,className:`flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-accent transition-colors shrink-0`,children:(0,G.jsx)(C,{className:`h-3.5 w-3.5`})})]}),(0,G.jsxs)(`div`,{className:`flex-1 overflow-y-auto scrollbar-thin p-4 space-y-3`,children:[(0,G.jsxs)(pm,{title:`Overview`,defaultOpen:!0,children:[(0,G.jsx)(`p`,{className:`text-xs text-muted-foreground leading-relaxed`,children:e.description}),(0,G.jsxs)(`div`,{className:`grid grid-cols-2 gap-2 mt-3`,children:[(0,G.jsx)(mm,{label:`Complexity`,value:e.estimatedComplexity}),(0,G.jsx)(mm,{label:`Est. Lines`,value:String(e.estimatedLines)}),(0,G.jsx)(mm,{label:`Layer`,value:e.layer.replace(`-`,` `)}),(0,G.jsx)(mm,{label:`Files`,value:String(e.files.length)})]})]}),(0,G.jsx)(pm,{title:`Responsibilities`,icon:g,defaultOpen:!0,children:(0,G.jsx)(`ul`,{className:`space-y-1`,children:e.responsibilities.map((e,t)=>(0,G.jsxs)(`li`,{className:`flex items-start gap-2 text-xs text-muted-foreground`,children:[(0,G.jsx)(`span`,{className:`text-primary mt-1 shrink-0`,children:`-`}),e]},t))})}),(0,G.jsx)(pm,{title:`Files`,icon:x,children:e.files.length===0?(0,G.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`No files mapped`}):(0,G.jsx)(`ul`,{className:`space-y-1`,children:e.files.map(e=>(0,G.jsx)(`li`,{className:`text-xs text-muted-foreground font-mono truncate`,children:e},e))})}),(0,G.jsx)(pm,{title:`Dependencies`,icon:A,children:e.dependencies.length===0?(0,G.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`No dependencies`}):(0,G.jsx)(`div`,{className:`flex flex-wrap gap-1.5`,children:e.dependencies.map(e=>(0,G.jsx)(hm,{id:e,direction:`out`},e))})}),(0,G.jsx)(pm,{title:`Dependents`,icon:A,children:e.dependents.length===0?(0,G.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`No dependents`}):(0,G.jsx)(`div`,{className:`flex flex-wrap gap-1.5`,children:e.dependents.map(e=>(0,G.jsx)(hm,{id:e,direction:`in`},e))})}),(0,G.jsx)(pm,{title:`Tags`,icon:l,children:(0,G.jsx)(`div`,{className:`flex flex-wrap gap-1.5`,children:e.tags.map(e=>(0,G.jsx)(`span`,{className:`inline-flex items-center rounded-md bg-muted px-2 py-0.5 text-2xs text-muted-foreground`,children:e},e))})}),(0,G.jsx)(pm,{title:`AI Summary`,icon:_,children:(0,G.jsx)(`div`,{className:`rounded-lg bg-muted/50 border border-border p-3`,children:(0,G.jsx)(`p`,{className:`text-xs text-muted-foreground italic`,children:`AI-powered analysis will provide detailed explanations, refactoring suggestions, and documentation for this component.`})})})]})]})}function pm({title:t,icon:n,defaultOpen:r=!1,children:i}){let[a,o]=(0,W.useState)(r);return(0,G.jsxs)(`div`,{className:`rounded-lg border border-border overflow-hidden`,children:[(0,G.jsxs)(`button`,{onClick:()=>o(!a),className:`w-full flex items-center gap-2 px-3 py-2 text-left hover:bg-accent/30 transition-colors`,children:[n&&(0,G.jsx)(n,{className:`h-3.5 w-3.5 text-muted-foreground`}),(0,G.jsx)(`span`,{className:`text-xs font-medium text-foreground flex-1`,children:t}),(0,G.jsx)(R,{className:e(`h-3.5 w-3.5 text-muted-foreground transition-transform duration-150`,a&&`rotate-180`)})]}),(0,G.jsx)(L,{children:a&&(0,G.jsx)(w.div,{initial:{height:0},animate:{height:`auto`},exit:{height:0},transition:{duration:.15},className:`overflow-hidden`,children:(0,G.jsx)(`div`,{className:`px-3 pb-3`,children:i})})})]})}function mm({label:e,value:t}){return(0,G.jsxs)(`div`,{className:`rounded-md bg-muted/50 px-2.5 py-1.5`,children:[(0,G.jsx)(`p`,{className:`text-2xs text-muted-foreground`,children:e}),(0,G.jsx)(`p`,{className:`text-xs font-medium text-foreground capitalize`,children:t})]})}function hm({id:t,direction:n}){let{setSelectedNodeId:r,model:i}=dm(),a=i?.nodes.find(e=>e.id===t)?.name||t;return(0,G.jsx)(`button`,{onClick:()=>r(t),className:e(`inline-flex items-center rounded-md px-2 py-0.5 text-2xs font-medium transition-colors`,n===`out`?`bg-primary/10 text-primary hover:bg-primary/20`:`bg-accent text-accent-foreground hover:bg-accent/80`),children:a})}function gm(){let{model:t,expandedModules:n,toggleModule:r,selectedNodeId:i,setSelectedNodeId:a,setHighlightedNodeIds:o}=dm();if(!t)return null;let s=e=>{o(new Set(e))},c=()=>{o(new Set)};return(0,G.jsxs)(`div`,{className:`w-56 border-r border-border bg-card flex flex-col h-full overflow-hidden`,children:[(0,G.jsx)(`div`,{className:`px-3 py-3 border-b border-border shrink-0`,children:(0,G.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,G.jsx)(g,{className:`h-3.5 w-3.5 text-muted-foreground`}),(0,G.jsx)(`h3`,{className:`text-xs font-medium text-foreground`,children:`Modules`})]})}),(0,G.jsx)(`div`,{className:`flex-1 overflow-y-auto scrollbar-thin p-2 space-y-0.5`,children:t.detectedLayers.map(l=>{let u=n.has(l.id),d=t.nodes.filter(e=>l.nodes.includes(e.id));return(0,G.jsxs)(`div`,{children:[(0,G.jsxs)(`button`,{onClick:()=>r(l.id),onMouseEnter:()=>s(l.nodes),onMouseLeave:c,className:`w-full flex items-center gap-1.5 px-2 py-1.5 rounded-md text-left hover:bg-accent/50 transition-colors`,children:[(0,G.jsx)(f,{className:e(`h-3 w-3 text-muted-foreground shrink-0 transition-transform duration-150`,u&&`rotate-90`)}),(0,G.jsx)(`span`,{className:`text-xs font-medium text-foreground truncate`,children:l.name}),(0,G.jsx)(`span`,{className:`text-2xs text-muted-foreground ml-auto`,children:l.nodes.length})]}),(0,G.jsx)(L,{children:u&&(0,G.jsx)(w.div,{initial:{height:0,opacity:0},animate:{height:`auto`,opacity:1},exit:{height:0,opacity:0},transition:{duration:.15},className:`overflow-hidden`,children:(0,G.jsx)(`div`,{className:`pl-4 space-y-0.5 py-0.5`,children:d.map(t=>(0,G.jsxs)(`button`,{onClick:()=>a(t.id),onMouseEnter:()=>o(new Set([t.id])),onMouseLeave:c,className:e(`w-full flex items-center gap-2 px-2 py-1 rounded-md text-left transition-colors`,i===t.id?`bg-primary/10 text-primary`:`text-muted-foreground hover:bg-accent/50 hover:text-foreground`),children:[(0,G.jsx)(y,{className:`h-2 w-2 shrink-0 fill-current`}),(0,G.jsx)(`span`,{className:`text-2xs truncate`,children:t.name})]},t.id))})})})]},l.id)})})]})}function _m({onFitView:e,onZoomIn:t,onZoomOut:n,onResetLayout:r,onExportPng:i,onExportSvg:a,onExportJson:o,onExportMarkdown:s,onToggleFullscreen:c,isFullscreen:l}){let{searchQuery:u,setSearchQuery:d,showGrid:f,setShowGrid:p,showMiniMap:h,setShowMiniMap:g,explorerOpen:_,setExplorerOpen:y}=dm();return(0,G.jsxs)(`div`,{className:`absolute top-3 left-3 right-3 z-10 flex items-center justify-between gap-3 pointer-events-none`,children:[(0,G.jsxs)(`div`,{className:`flex items-center gap-2 pointer-events-auto`,children:[(0,G.jsx)(vm,{icon:_?m:v,onClick:()=>y(!_),title:_?`Hide Explorer`:`Show Explorer`}),(0,G.jsxs)(`div`,{className:`relative`,children:[(0,G.jsx)(I,{className:`absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground`}),(0,G.jsx)(`input`,{type:`text`,placeholder:`Search modules, services, routes...`,value:u,onChange:e=>d(e.target.value),className:`w-56 rounded-md border border-border bg-card/90 backdrop-blur-sm pl-8 pr-3 py-1.5 text-xs text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring`})]})]}),(0,G.jsxs)(`div`,{className:`flex items-center gap-1 pointer-events-auto`,children:[(0,G.jsx)(vm,{icon:be,onClick:t,title:`Zoom In`}),(0,G.jsx)(vm,{icon:xe,onClick:n,title:`Zoom Out`}),(0,G.jsx)(vm,{icon:de,onClick:e,title:`Fit View`}),(0,G.jsx)(vm,{icon:me,onClick:r,title:`Reset Layout`}),(0,G.jsx)(`div`,{className:`w-px h-5 bg-border mx-1`}),(0,G.jsx)(vm,{icon:se,onClick:()=>p(!f),title:`Toggle Grid`,active:f}),(0,G.jsx)(vm,{icon:ue,onClick:()=>g(!h),title:`Toggle Mini Map`,active:h}),(0,G.jsx)(`div`,{className:`w-px h-5 bg-border mx-1`}),(0,G.jsx)(ym,{onPng:i,onSvg:a,onJson:o,onMarkdown:s}),(0,G.jsx)(vm,{icon:l?fe:de,onClick:c,title:l?`Exit Fullscreen`:`Fullscreen`})]})]})}function vm({icon:t,onClick:n,title:r,active:i}){return(0,G.jsx)(`button`,{onClick:n,title:r,className:e(`flex h-7 w-7 items-center justify-center rounded-md border border-border bg-card/90 backdrop-blur-sm text-muted-foreground hover:text-foreground hover:bg-accent transition-colors`,i&&`bg-primary/10 text-primary border-primary/30`),children:(0,G.jsx)(t,{className:`h-3.5 w-3.5`})})}function ym({onPng:e,onSvg:t,onJson:n,onMarkdown:r}){let[i,a]=(0,W.useState)(!1);return(0,G.jsxs)(`div`,{className:`relative`,children:[(0,G.jsx)(`button`,{onClick:()=>a(!i),title:`Export`,className:`flex h-7 w-7 items-center justify-center rounded-md border border-border bg-card/90 backdrop-blur-sm text-muted-foreground hover:text-foreground hover:bg-accent transition-colors`,children:(0,G.jsx)(b,{className:`h-3.5 w-3.5`})}),i&&(0,G.jsxs)(G.Fragment,{children:[(0,G.jsx)(`div`,{className:`fixed inset-0 z-40`,onClick:()=>a(!1)}),(0,G.jsxs)(`div`,{className:`absolute top-full right-0 mt-1 w-36 rounded-lg border border-border bg-popover shadow-lg z-50 p-1`,children:[(0,G.jsx)(`button`,{onClick:()=>{e(),a(!1)},className:`w-full px-3 py-1.5 text-xs text-left rounded-md hover:bg-accent transition-colors`,children:`Export PNG`}),(0,G.jsx)(`button`,{onClick:()=>{t(),a(!1)},className:`w-full px-3 py-1.5 text-xs text-left rounded-md hover:bg-accent transition-colors`,children:`Export SVG`}),(0,G.jsx)(`button`,{onClick:()=>{n(),a(!1)},className:`w-full px-3 py-1.5 text-xs text-left rounded-md hover:bg-accent transition-colors`,children:`Export JSON`}),(0,G.jsx)(`button`,{onClick:()=>{r(),a(!1)},className:`w-full px-3 py-1.5 text-xs text-left rounded-md hover:bg-accent transition-colors`,children:`Export Markdown`}),(0,G.jsx)(`button`,{disabled:!0,className:`w-full px-3 py-1.5 text-xs text-left rounded-md text-muted-foreground cursor-not-allowed`,children:`Export PDF Coming Soon`})]})]})]})}function bm({model:e,source:t}){let n=[{icon:_,label:`Language`,value:e.summary.language},{icon:g,label:`Framework`,value:e.summary.framework},{icon:ne,label:`Architecture`,value:e.summary.architecturePattern},{icon:s,label:`Layers`,value:String(e.detectedLayers.length)},{icon:he,label:`Modules`,value:String(e.summary.totalModules)},{icon:x,label:`Entry`,value:e.summary.entryPoint.split(`/`).pop()||``}];return(0,G.jsxs)(`div`,{className:`flex items-center gap-4 px-4 py-2.5 border-b border-border bg-card/50 overflow-x-auto scrollbar-thin`,children:[(0,G.jsx)(B,{source:t}),n.map(e=>(0,G.jsxs)(`div`,{className:`flex items-center gap-2 shrink-0`,children:[(0,G.jsx)(e.icon,{className:`h-3.5 w-3.5 text-muted-foreground`}),(0,G.jsxs)(`span`,{className:`text-2xs text-muted-foreground`,children:[e.label,`:`]}),(0,G.jsx)(`span`,{className:`text-2xs font-medium text-foreground`,children:e.value})]},e.label))]})}var xm={frontend:pe,route:he,controller:ve,service:U,"external-api":p,middleware:_e,authentication:_e,database:d,cache:u,queue:le};function Sm({steps:t}){let[n,r]=(0,W.useState)(null);return(0,G.jsxs)(`div`,{className:`flex flex-col items-center py-6`,children:[(0,G.jsx)(`h3`,{className:`text-sm font-medium text-foreground mb-6`,children:`Request Flow`}),(0,G.jsx)(`div`,{className:`space-y-0`,children:t.map((i,a)=>{let o=xm[i.type]||U,s=n===i.id,c=a===t.length-1;return(0,G.jsxs)(`div`,{className:`flex flex-col items-center`,children:[(0,G.jsxs)(w.button,{onClick:()=>r(s?null:i.id),whileHover:{scale:1.02},whileTap:{scale:.98},className:e(`relative flex items-center gap-3 rounded-xl border px-5 py-3 w-72 text-left transition-all`,s?`border-primary bg-primary/5 shadow-sm`:`border-border bg-card hover:border-muted-foreground/30`),children:[(0,G.jsx)(`div`,{className:e(`flex h-9 w-9 items-center justify-center rounded-lg shrink-0`,s?`bg-primary/10`:`bg-muted`),children:(0,G.jsx)(o,{className:e(`h-4 w-4`,s?`text-primary`:`text-muted-foreground`)})}),(0,G.jsxs)(`div`,{className:`min-w-0 flex-1`,children:[(0,G.jsx)(`p`,{className:e(`text-xs font-medium`,`text-foreground`),children:i.name}),(0,G.jsx)(`p`,{className:`text-2xs text-muted-foreground truncate`,children:i.description})]}),(0,G.jsx)(`span`,{className:`text-2xs text-muted-foreground shrink-0`,children:a+1})]}),s&&(0,G.jsx)(w.div,{initial:{opacity:0,height:0},animate:{opacity:1,height:`auto`},exit:{opacity:0,height:0},className:`w-72 mt-2 mb-2 rounded-lg border border-border bg-card p-3`,children:(0,G.jsx)(`ul`,{className:`space-y-1`,children:i.details.map((e,t)=>(0,G.jsxs)(`li`,{className:`flex items-start gap-2 text-2xs text-muted-foreground`,children:[(0,G.jsx)(`span`,{className:`text-primary mt-0.5`,children:`-`}),e]},t))})}),!c&&(0,G.jsxs)(`div`,{className:`flex flex-col items-center py-1`,children:[(0,G.jsx)(`div`,{className:`w-px h-4 bg-border`}),(0,G.jsx)(V,{className:`h-3 w-3 text-muted-foreground`}),(0,G.jsx)(`div`,{className:`w-px h-4 bg-border`})]})]},i.id)})})]})}function Cm(){let{contextMenuTarget:e,contextMenuPosition:t,closeContextMenu:n,setSelectedNodeId:r,setIsolatedSubtree:i,hideNode:a,toggleBookmark:o,model:s}=dm(),c=(0,W.useRef)(null);if((0,W.useEffect)(()=>{let t=e=>{c.current&&!c.current.contains(e.target)&&n()};if(e)return document.addEventListener(`mousedown`,t),()=>document.removeEventListener(`mousedown`,t)},[e,n]),!e||!t||!s)return null;let l=s.nodes.find(t=>t.id===e);if(!l)return null;let u=[{icon:oe,label:`Focus Node`,action:()=>{r(e),n()}},{icon:re,label:`Isolate Subtree`,action:()=>{i(e),n()}},{icon:H,label:`Toggle Bookmark`,action:()=>{o(e),n()}},{icon:ie,label:`Hide Node`,action:()=>{a(e),n()}},{icon:h,label:`Copy Name`,action:()=>{navigator.clipboard.writeText(l.name),n()}}];return(0,G.jsxs)(`div`,{ref:c,className:`fixed z-[100] min-w-[160px] rounded-lg border border-border bg-popover shadow-xl p-1 animate-in fade-in zoom-in-95 duration-100`,style:{left:t.x,top:t.y},children:[(0,G.jsxs)(`div`,{className:`px-2 py-1.5 border-b border-border mb-1`,children:[(0,G.jsx)(`p`,{className:`text-xs font-medium text-foreground truncate`,children:l.name}),(0,G.jsx)(`p`,{className:`text-[10px] text-muted-foreground capitalize`,children:l.type.replace(`-`,` `)})]}),u.map(({icon:e,label:t,action:n})=>(0,G.jsxs)(`button`,{onClick:n,className:`w-full flex items-center gap-2 px-2 py-1.5 rounded-md text-xs text-foreground hover:bg-accent transition-colors`,children:[(0,G.jsx)(e,{className:`h-3.5 w-3.5 text-muted-foreground`}),t]},t))]})}var wm=[{id:`none`,label:`Off`,icon:a},{id:`complexity`,label:`Complexity`,icon:ae},{id:`usage`,label:`Usage`,icon:a},{id:`size`,label:`Size`,icon:S},{id:`critical`,label:`Critical`,icon:c}];function Tm(){let{heatmapMode:t,setHeatmapMode:n}=dm();return(0,G.jsx)(`div`,{className:`flex items-center gap-1`,children:wm.map(({id:r,label:i,icon:a})=>(0,G.jsxs)(`button`,{onClick:()=>n(r),title:`Heatmap: ${i}`,className:e(`flex items-center gap-1.5 px-2 py-1 rounded-md text-[11px] font-medium transition-colors`,t===r?`bg-accent text-foreground`:`text-muted-foreground hover:text-foreground hover:bg-accent/50`),children:[(0,G.jsx)(a,{className:`h-3 w-3`}),i]},r))})}function Em(){let{model:t,selectedNodeId:n,bottomPanelOpen:r,setBottomPanelOpen:i}=dm();if(!t)return null;let a=n?t.nodes.find(e=>e.id===n):null,s=n?t.edges.filter(e=>e.source===n||e.target===n):[];return(0,G.jsxs)(`div`,{className:`border-t border-border bg-card`,children:[(0,G.jsxs)(`button`,{onClick:()=>i(!r),className:`w-full flex items-center justify-between px-4 py-2 hover:bg-accent/30 transition-colors`,children:[(0,G.jsxs)(`span`,{className:`text-xs font-medium text-foreground`,children:[`Relationships `,a?`- ${a.name}`:``,` `,s.length>0&&(0,G.jsxs)(`span`,{className:`text-muted-foreground`,children:[`(`,s.length,`)`]})]}),r?(0,G.jsx)(R,{className:`h-3.5 w-3.5 text-muted-foreground`}):(0,G.jsx)(ee,{className:`h-3.5 w-3.5 text-muted-foreground`})]}),r&&(0,G.jsx)(w.div,{initial:{height:0},animate:{height:`auto`},className:`overflow-hidden max-h-40 overflow-y-auto scrollbar-thin`,children:(0,G.jsx)(`div`,{className:`px-4 pb-3`,children:a?s.length===0?(0,G.jsx)(`p`,{className:`text-xs text-muted-foreground py-2`,children:`No relationships found`}):(0,G.jsx)(`div`,{className:`grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2 pt-1`,children:s.map(r=>{let i=r.source===n,a=i?r.target:r.source,s=t.nodes.find(e=>e.id===a);return s?(0,G.jsxs)(`div`,{className:`flex items-center gap-2 rounded-md border border-border px-3 py-2`,children:[(0,G.jsx)(`span`,{className:e(`text-[10px] font-medium px-1.5 py-0.5 rounded`,i?`bg-blue-500/10 text-blue-400`:`bg-emerald-500/10 text-emerald-400`),children:i?`OUT`:`IN`}),(0,G.jsx)(o,{className:`h-3 w-3 text-muted-foreground shrink-0`}),(0,G.jsxs)(`div`,{className:`min-w-0`,children:[(0,G.jsx)(`p`,{className:`text-xs font-medium text-foreground truncate`,children:s.name}),(0,G.jsx)(`p`,{className:`text-[10px] text-muted-foreground capitalize`,children:r.type.replace(`-`,` `)})]})]},r.id):null})}):(0,G.jsx)(`p`,{className:`text-xs text-muted-foreground py-2`,children:`Select a node to view its relationships`})})})]})}var Dm=Object.defineProperty,Om=(e,t,n)=>t in e?Dm(e,t,{enumerable:!0,configurable:!0,writable:!0,value:n}):e[t]=n,km=(e,t)=>{for(var n in t)Dm(e,n,{get:t[n],enumerable:!0})},Am=(e,t,n)=>Om(e,typeof t==`symbol`?t:t+``,n);km({},{Graph:()=>Nm,alg:()=>Gm,json:()=>Bm,version:()=>zm});var jm=Object.defineProperty,Mm=(e,t)=>{for(var n in t)jm(e,n,{get:t[n],enumerable:!0})},Nm=class{constructor(e){this._isDirected=!0,this._isMultigraph=!1,this._isCompound=!1,this._nodes={},this._in={},this._preds={},this._out={},this._sucs={},this._edgeObjs={},this._edgeLabels={},this._nodeCount=0,this._edgeCount=0,this._defaultNodeLabelFn=()=>{},this._defaultEdgeLabelFn=()=>{},e&&(this._isDirected=`directed`in e?e.directed:!0,this._isMultigraph=`multigraph`in e&&e.multigraph,this._isCompound=`compound`in e&&e.compound),this._isCompound&&(this._parent={},this._children={},this._children[`\0`]={})}isDirected(){return this._isDirected}isMultigraph(){return this._isMultigraph}isCompound(){return this._isCompound}setGraph(e){return this._label=e,this}graph(){return this._label}setDefaultNodeLabel(e){return typeof e==`function`?this._defaultNodeLabelFn=e:this._defaultNodeLabelFn=()=>e,this}nodeCount(){return this._nodeCount}nodes(){return Object.keys(this._nodes)}sources(){return this.nodes().filter(e=>Object.keys(this._in[e]).length===0)}sinks(){return this.nodes().filter(e=>Object.keys(this._out[e]).length===0)}setNodes(e,t){return e.forEach(e=>{t===void 0?this.setNode(e):this.setNode(e,t)}),this}setNode(e,t){return e in this._nodes?(arguments.length>1&&(this._nodes[e]=t),this):(this._nodes[e]=arguments.length>1?t:this._defaultNodeLabelFn(e),this._isCompound&&(this._parent[e]=`\0`,this._children[e]={},this._children[`\0`][e]=!0),this._in[e]={},this._preds[e]={},this._out[e]={},this._sucs[e]={},++this._nodeCount,this)}node(e){return this._nodes[e]}hasNode(e){return e in this._nodes}removeNode(e){if(e in this._nodes){let t=e=>this.removeEdge(this._edgeObjs[e]);delete this._nodes[e],this._isCompound&&(this._removeFromParentsChildList(e),delete this._parent[e],this.children(e).forEach(e=>{this.setParent(e)}),delete this._children[e]),Object.keys(this._in[e]).forEach(t),delete this._in[e],delete this._preds[e],Object.keys(this._out[e]).forEach(t),delete this._out[e],delete this._sucs[e],--this._nodeCount}return this}setParent(e,t){if(!this._isCompound)throw Error(`Cannot set parent in a non-compound graph`);if(t===void 0)t=`\0`;else{t+=``;for(let n=t;n!==void 0;n=this.parent(n))if(n===e)throw Error(`Setting `+t+` as parent of `+e+` would create a cycle`);this.setNode(t)}return this.setNode(e),this._removeFromParentsChildList(e),this._parent[e]=t,this._children[t][e]=!0,this}parent(e){if(this._isCompound){let t=this._parent[e];if(t!==`\0`)return t}}children(e=`\0`){if(this._isCompound){let t=this._children[e];if(t)return Object.keys(t)}else{if(e===`\0`)return this.nodes();if(this.hasNode(e))return[]}return[]}predecessors(e){let t=this._preds[e];if(t)return Object.keys(t)}successors(e){let t=this._sucs[e];if(t)return Object.keys(t)}neighbors(e){let t=this.predecessors(e);if(t){let n=new Set(t);for(let t of this.successors(e))n.add(t);return Array.from(n.values())}}isLeaf(e){let t;return t=this.isDirected()?this.successors(e):this.neighbors(e),t.length===0}filterNodes(e){let t=new this.constructor({directed:this._isDirected,multigraph:this._isMultigraph,compound:this._isCompound});t.setGraph(this.graph()),Object.entries(this._nodes).forEach(([n,r])=>{e(n)&&t.setNode(n,r)}),Object.values(this._edgeObjs).forEach(e=>{t.hasNode(e.v)&&t.hasNode(e.w)&&t.setEdge(e,this.edge(e))});let n={},r=e=>{let i=this.parent(e);return!i||t.hasNode(i)?(n[e]=i??void 0,i??void 0):i in n?n[i]:r(i)};return this._isCompound&&t.nodes().forEach(e=>t.setParent(e,r(e))),t}setDefaultEdgeLabel(e){return typeof e==`function`?this._defaultEdgeLabelFn=e:this._defaultEdgeLabelFn=()=>e,this}edgeCount(){return this._edgeCount}edges(){return Object.values(this._edgeObjs)}setPath(e,t){return e.reduce((e,n)=>(t===void 0?this.setEdge(e,n):this.setEdge(e,n,t),n)),this}setEdge(e,t,n,r){let i,a,o,s,c=!1;typeof e==`object`&&e&&`v`in e?(i=e.v,a=e.w,o=e.name,arguments.length===2&&(s=t,c=!0)):(i=e,a=t,o=r,arguments.length>2&&(s=n,c=!0)),i=``+i,a=``+a,o!==void 0&&(o=``+o);let l=Im(this._isDirected,i,a,o);if(l in this._edgeLabels)return c&&(this._edgeLabels[l]=s),this;if(o!==void 0&&!this._isMultigraph)throw Error(`Cannot set a named edge when isMultigraph = false`);this.setNode(i),this.setNode(a),this._edgeLabels[l]=c?s:this._defaultEdgeLabelFn(i,a,o);let u=Lm(this._isDirected,i,a,o);return i=u.v,a=u.w,Object.freeze(u),this._edgeObjs[l]=u,Pm(this._preds[a],i),Pm(this._sucs[i],a),this._in[a][l]=u,this._out[i][l]=u,this._edgeCount++,this}edge(e,t,n){let r=arguments.length===1?Rm(this._isDirected,e):Im(this._isDirected,e,t,n);return this._edgeLabels[r]}edgeAsObj(e,t,n){let r=arguments.length===1?this.edge(e):this.edge(e,t,n);return typeof r==`object`?r:{label:r}}hasEdge(e,t,n){return(arguments.length===1?Rm(this._isDirected,e):Im(this._isDirected,e,t,n))in this._edgeLabels}removeEdge(e,t,n){let r=arguments.length===1?Rm(this._isDirected,e):Im(this._isDirected,e,t,n),i=this._edgeObjs[r];if(i){let e=i.v,t=i.w;delete this._edgeLabels[r],delete this._edgeObjs[r],Fm(this._preds[t],e),Fm(this._sucs[e],t),delete this._in[t][r],delete this._out[e][r],this._edgeCount--}return this}inEdges(e,t){return this.isDirected()?this.filterEdges(this._in[e],e,t):this.nodeEdges(e,t)}outEdges(e,t){return this.isDirected()?this.filterEdges(this._out[e],e,t):this.nodeEdges(e,t)}nodeEdges(e,t){if(e in this._nodes)return this.filterEdges({...this._in[e],...this._out[e]},e,t)}_removeFromParentsChildList(e){delete this._children[this._parent[e]][e]}filterEdges(e,t,n){if(!e)return;let r=Object.values(e);return n?r.filter(e=>e.v===t&&e.w===n||e.v===n&&e.w===t):r}};function Pm(e,t){e[t]?e[t]++:e[t]=1}function Fm(e,t){e[t]!==void 0&&!--e[t]&&delete e[t]}function Im(e,t,n,r){let i=``+t,a=``+n;if(!e&&i>a){let e=i;i=a,a=e}return i+``+a+``+(r===void 0?`\0`:r)}function Lm(e,t,n,r){let i=``+t,a=``+n;if(!e&&i>a){let e=i;i=a,a=e}let o={v:i,w:a};return r&&(o.name=r),o}function Rm(e,t){return Im(e,t.v,t.w,t.name)}var zm=`4.0.1`,Bm={};Mm(Bm,{read:()=>Wm,write:()=>Vm});function Vm(e){let t={options:{directed:e.isDirected(),multigraph:e.isMultigraph(),compound:e.isCompound()},nodes:Hm(e),edges:Um(e)},n=e.graph();return n!==void 0&&(t.value=structuredClone(n)),t}function Hm(e){return e.nodes().map(t=>{let n=e.node(t),r=e.parent(t),i={v:t};return n!==void 0&&(i.value=n),r!==void 0&&(i.parent=r),i})}function Um(e){return e.edges().map(t=>{let n=e.edge(t),r={v:t.v,w:t.w};return t.name!==void 0&&(r.name=t.name),n!==void 0&&(r.value=n),r})}function Wm(e){let t=new Nm(e.options);return e.value!==void 0&&t.setGraph(e.value),e.nodes.forEach(e=>{t.setNode(e.v,e.value),e.parent&&t.setParent(e.v,e.parent)}),e.edges.forEach(e=>{t.setEdge({v:e.v,w:e.w,name:e.name},e.value)}),t}var Gm={};Mm(Gm,{CycleException:()=>oh,bellmanFord:()=>qm,components:()=>Ym,dijkstra:()=>Qm,dijkstraAll:()=>eh,findCycles:()=>nh,floydWarshall:()=>ih,isAcyclic:()=>ch,postorder:()=>fh,preorder:()=>ph,prim:()=>mh,shortestPaths:()=>hh,tarjan:()=>th,topsort:()=>sh});var Km=()=>1;function qm(e,t,n,r){return Jm(e,String(t),n||Km,r||function(t){return e.outEdges(t)})}function Jm(e,t,n,r){let i={},a,o=0,s=e.nodes(),c=function(e){let t=n(e);i[e.v].distance+te.key)}has(e){return e in this._keyIndices}priority(e){let t=this._keyIndices[e];if(t!==void 0)return this._arr[t].priority}min(){if(this.size()===0)throw Error(`Queue underflow`);return this._arr[0].key}add(e,t){let n=this._keyIndices,r=String(e);if(!(r in n)){let e=this._arr,i=e.length;return n[r]=i,e.push({key:r,priority:t}),this._decrease(i),!0}return!1}removeMin(){this._swap(0,this._arr.length-1);let e=this._arr.pop();return delete this._keyIndices[e.key],this._heapify(0),e.key}decrease(e,t){let n=this._keyIndices[e];if(n===void 0)throw Error(`Key not found: ${e}`);let r=this._arr[n].priority;if(t>r)throw Error(`New priority is greater than current priority. Key: ${e} Old: ${r} New: ${t}`);this._arr[n].priority=t,this._decrease(n)}_heapify(e){let t=this._arr,n=2*e,r=n+1,i=e;n>1,!(t[r].priority1;function Qm(e,t,n,r){return $m(e,String(t),n||Zm,r||function(t){return e.outEdges(t)})}function $m(e,t,n,r){let i={},a=new Xm,o,s,c=function(e){let t=e.v===o?e.w:e.v,r=i[t],c=n(e),l=s.distance+c;if(c<0)throw Error(`dijkstra does not allow negative edge weights. Bad edge: `+e+` Weight: `+c);l0&&(o=a.removeMin(),s=i[o],s.distance!==1/0);)r(o).forEach(c);return i}function eh(e,t,n){return e.nodes().reduce(function(r,i){return r[i]=Qm(e,i,t,n),r},{})}function th(e){let t=0,n=[],r={},i=[];function a(o){let s=r[o]={onStack:!0,lowlink:t,index:t++};if(n.push(o),e.successors(o).forEach(function(e){e in r?r[e].onStack&&(s.lowlink=Math.min(s.lowlink,r[e].index)):(a(e),s.lowlink=Math.min(s.lowlink,r[e].lowlink))}),s.lowlink===s.index){let e=[],t;do t=n.pop(),r[t].onStack=!1,e.push(t);while(o!==t);i.push(e)}}return e.nodes().forEach(function(e){e in r||a(e)}),i}function nh(e){return th(e).filter(function(t){return t.length>1||t.length===1&&e.hasEdge(t[0],t[0])})}var rh=()=>1;function ih(e,t,n){return ah(e,t||rh,n||function(t){return e.outEdges(t)})}function ah(e,t,n){let r={},i=e.nodes();return i.forEach(function(e){r[e]={},r[e][e]={distance:0,predecessor:``},i.forEach(function(t){e!==t&&(r[e][t]={distance:1/0,predecessor:``})}),n(e).forEach(function(n){let i=n.v===e?n.w:n.v,a=t(n);r[e][i]={distance:a,predecessor:e}})}),i.forEach(function(e){let t=r[e];i.forEach(function(n){let a=r[n];i.forEach(function(n){let r=a[e],i=t[n],o=a[n],s=r.distance+i.distance;s(e.isDirected()?e.successors(t):e.neighbors(t))??[]),o={};return t.forEach(function(t){if(!e.hasNode(t))throw Error(`Graph does not have node: `+t);i=uh(e,t,n===`post`,o,a,r,i)}),i}function uh(e,t,n,r,i,a,o){return t in r||(r[t]=!0,n||(o=a(o,t)),i(t).forEach(function(t){o=uh(e,t,n,r,i,a,o)}),n&&(o=a(o,t))),o}function dh(e,t,n){return lh(e,t,n,function(e,t){return e.push(t),e},[])}function fh(e,t){return dh(e,t,`post`)}function ph(e,t){return dh(e,t,`pre`)}function mh(e,t){let n=new Nm,r={},i=new Xm,a;function o(e){let n=e.v===a?e.w:e.v,o=i.priority(n);if(o!==void 0){let s=t(e);s0;){if(a=i.removeMin(),a in r)n.setEdge(a,r[a]);else{if(s)throw Error(`Input graph is not connected: `+e);s=!0}e.nodeEdges(a).forEach(o)}return n}function hh(e,t,n,r){return gh(e,t,n,r??(t=>e.outEdges(t)??[]))}function gh(e,t,n,r){if(n===void 0)return Qm(e,t,n,r);let i=!1,a=e.nodes();for(let o=0;ot.setNode(n,e.node(n))),e.edges().forEach(n=>{let r=t.edge(n.v,n.w)||{weight:0,minlen:1},i=e.edge(n);t.setEdge(n.v,n.w,{weight:r.weight+i.weight,minlen:Math.max(r.minlen,i.minlen)})}),t}function yh(e){let t=new Nm({multigraph:e.isMultigraph()}).setGraph(e.graph());return e.nodes().forEach(n=>{e.children(n).length||t.setNode(n,e.node(n))}),e.edges().forEach(n=>{t.setEdge(n,e.edge(n))}),t}function bh(e,t){let n=e.x,r=e.y,i=t.x-n,a=t.y-r,o=e.width/2,s=e.height/2;if(!i&&!a)throw Error(`Not possible to find intersection inside of the rectangle`);let c,l;return Math.abs(a)*o>Math.abs(i)*s?(a<0&&(s=-s),c=s*i/a,l=s):(i<0&&(o=-o),c=o,l=o*a/i),{x:n+c,y:r+l}}function xh(e){let t=Ph(Oh(e)+1).map(()=>[]);return e.nodes().forEach(n=>{let r=e.node(n),i=r.rank;i!==void 0&&(t[i]||(t[i]=[]),t[i][r.order]=n)}),t}function Sh(e){let t=e.nodes().map(t=>{let n=e.node(t).rank;return n===void 0?Number.MAX_VALUE:n}),n=Dh(Math.min,t);e.nodes().forEach(t=>{let r=e.node(t);Object.hasOwn(r,`rank`)&&(r.rank-=n)})}function Ch(e){let t=e.nodes().map(t=>e.node(t).rank).filter(e=>e!==void 0),n=Dh(Math.min,t),r=[];e.nodes().forEach(t=>{let i=e.node(t).rank-n;r[i]||(r[i]=[]),r[i].push(t)});let i=0,a=e.graph().nodeRankFactor;Array.from(r).forEach((t,n)=>{t===void 0&&n%a!==0?--i:t!==void 0&&i&&t.forEach(t=>e.node(t).rank+=i)})}function wh(e,t,n,r){let i={width:0,height:0};return arguments.length>=4&&(i.rank=n,i.order=r),_h(e,`border`,i,t)}function Th(e,t=Eh){let n=[];for(let r=0;rEh?e(...Th(t).map(t=>e(...t))):e(...t)}function Oh(e){let t=e.nodes().map(t=>{let n=e.node(t).rank;return n===void 0?Number.MIN_VALUE:n});return Dh(Math.max,t)}function kh(e,t){let n={lhs:[],rhs:[]};return e.forEach(e=>{t(e)?n.lhs.push(e):n.rhs.push(e)}),n}function Ah(e,t){let n=Date.now();try{return t()}finally{console.log(e+` time: `+(Date.now()-n)+`ms`)}}function jh(e,t){return t()}var Mh=0;function Nh(e){return e+(``+ ++Mh)}function Ph(e,t,n=1){t??(t=e,e=0);let r=e=>ete[t]:t,Object.entries(e).reduce((e,[t,r])=>(e[t]=n(r,t),e),{})}function Lh(e,t){return e.reduce((e,n,r)=>(e[n]=t[r],e),{})}var Rh=`\0`,zh=class{constructor(){Am(this,`_sentinel`);let e={};e._next=e._prev=e,this._sentinel=e}dequeue(){let e=this._sentinel,t=e._prev;if(t!==e)return Bh(t),t}enqueue(e){let t=this._sentinel;e._prev&&e._next&&Bh(e),e._next=t._next,t._next._prev=e,t._next=e,e._prev=t}toString(){let e=[],t=this._sentinel,n=t._prev;for(;n!==t;)e.push(JSON.stringify(n,Vh)),n=n._prev;return`[`+e.join(`, `)+`]`}};function Bh(e){e._prev._next=e._next,e._next._prev=e._prev,delete e._next,delete e._prev}function Vh(e,t){if(e!==`_next`&&e!==`_prev`)return t}var Hh=zh,Uh=()=>1;function Wh(e,t){if(e.nodeCount()<=1)return[];let n=qh(e,t||Uh);return Gh(n.graph,n.buckets,n.zeroIdx).flatMap(t=>e.outEdges(t.v,t.w)||[])}function Gh(e,t,n){let r=[],i=t[t.length-1],a=t[0],o;for(;e.nodeCount();){for(;o=a.dequeue();)Kh(e,t,n,o);for(;o=i.dequeue();)Kh(e,t,n,o);if(e.nodeCount()){for(let i=t.length-2;i>0;--i)if(o=t[i]?.dequeue(),o){r=r.concat(Kh(e,t,n,o,!0)||[]);break}}}return r}function Kh(e,t,n,r,i){let a=[],o=i?a:void 0;return(e.inEdges(r.v)||[]).forEach(r=>{let o=e.edge(r),s=e.node(r.v);i&&a.push({v:r.v,w:r.w}),s.out-=o,Jh(t,n,s)}),(e.outEdges(r.v)||[]).forEach(r=>{let i=e.edge(r),a=r.w,o=e.node(a);o.in-=i,Jh(t,n,o)}),e.removeNode(r.v),o}function qh(e,t){let n=new Nm,r=0,i=0;e.nodes().forEach(e=>{n.setNode(e,{v:e,in:0,out:0})}),e.edges().forEach(e=>{let a=n.edge(e.v,e.w)||0,o=t(e),s=a+o;n.setEdge(e.v,e.w,s);let c=n.node(e.v),l=n.node(e.w);i=Math.max(i,c.out+=o),r=Math.max(r,l.in+=o)});let a=Yh(i+r+3).map(()=>new Hh),o=r+1;return n.nodes().forEach(e=>{Jh(a,o,n.node(e))}),{graph:n,buckets:a,zeroIdx:o}}function Jh(e,t,n){var r,i,a;n.out?n.in?(a=e[n.out-n.in+t])==null||a.enqueue(n):(i=e[e.length-1])==null||i.enqueue(n):(r=e[0])==null||r.enqueue(n)}function Yh(e){let t=[];for(let n=0;n{let n=e.edge(t);e.removeEdge(t),n.forwardName=t.name,n.reversed=!0,e.setEdge(t.w,t.v,n,Nh(`rev`))});function t(e){return t=>e.edge(t).weight}}function Zh(e){let t=[],n={},r={};function i(a){Object.hasOwn(r,a)||(r[a]=!0,n[a]=!0,e.outEdges(a).forEach(e=>{Object.hasOwn(n,e.w)?t.push(e):i(e.w)}),delete n[a])}return e.nodes().forEach(i),t}function Qh(e){e.edges().forEach(t=>{let n=e.edge(t);if(n.reversed){e.removeEdge(t);let r=n.forwardName;delete n.reversed,delete n.forwardName,e.setEdge(t.w,t.v,n,r)}})}function $h(e){e.graph().dummyChains=[],e.edges().forEach(t=>eg(e,t))}function eg(e,t){let n=t.v,r=e.node(n).rank,i=t.w,a=e.node(i).rank,o=t.name,s=e.edge(t),c=s.labelRank;if(a===r+1)return;e.removeEdge(t);let l,u,d;for(d=0,++r;r{let n=e.node(t),r=n.edgeLabel,i;for(e.setEdge(n.edgeObj,r);n.dummy;)i=e.successors(t)[0],e.removeNode(t),r.points.push({x:n.x,y:n.y}),n.dummy===`edge-label`&&(r.x=n.x,r.y=n.y,r.width=n.width,r.height=n.height),t=i,n=e.node(t)})}function ng(e){let t={};function n(r){let i=e.node(r);if(Object.hasOwn(t,r))return i.rank;t[r]=!0;let a=e.outEdges(r),o=a?a.map(t=>t==null?1/0:n(t.w)-e.edge(t).minlen):[],s=Dh(Math.min,o);return s===1/0&&(s=0),i.rank=s}e.sources().forEach(n)}function rg(e,t){return e.node(t.w).rank-e.node(t.v).rank-e.edge(t).minlen}var ig=ag;function ag(e){let t=new Nm({directed:!1}),n=e.nodes();if(n.length===0)throw Error(`Graph must have at least one node`);let r=n[0],i=e.nodeCount();t.setNode(r,{});let a,o;for(;og(t,e){let a=i.v,o=r===a?i.w:a;!e.hasNode(o)&&!rg(t,i)&&(e.setNode(o,{}),e.setEdge(r,o,{}),n(o))})}return e.nodes().forEach(n),e.nodeCount()}function sg(e,t){return t.edges().reduce((n,r)=>{let i=1/0;return e.hasNode(r.v)!==e.hasNode(r.w)&&(i=rg(t,r)),it.node(e).rank+=n)}var{preorder:lg,postorder:ug}=Gm,dg=fg;fg.initLowLimValues=gg,fg.initCutValues=pg,fg.calcCutValue=hg,fg.leaveEdge=vg,fg.enterEdge=yg,fg.exchangeEdges=bg;function fg(e){e=vh(e),ng(e);let t=ig(e);gg(t),pg(t,e);let n,r;for(;n=vg(t);)r=yg(t,e,n),bg(t,e,n,r)}function pg(e,t){let n=ug(e,e.nodes());n=n.slice(0,n.length-1),n.forEach(n=>mg(e,t,n))}function mg(e,t,n){let r=e.node(n).parent,i=e.edge(n,r);i.cutvalue=hg(e,t,n)}function hg(e,t,n){let r=e.node(n).parent,i=!0,a=t.edge(n,r),o=0;a||=(i=!1,t.edge(r,n)),o=a.weight;let s=t.nodeEdges(n);return s&&s.forEach(a=>{let s=a.v===n,c=s?a.w:a.v;if(c!==r){let r=s===i,l=t.edge(a).weight;if(o+=r?l:-l,Sg(e,n,c)){let t=e.edge(n,c).cutvalue;o+=r?-t:t}}}),o}function gg(e,t){arguments.length<2&&(t=e.nodes()[0]),_g(e,{},1,t)}function _g(e,t,n,r,i){let a=n,o=e.node(r);t[r]=!0;let s=e.neighbors(r);return s&&s.forEach(i=>{Object.hasOwn(t,i)||(n=_g(e,t,n,i,r))}),o.low=a,o.lim=n++,i?o.parent=i:delete o.parent,n}function vg(e){return e.edges().find(t=>e.edge(t).cutvalue<0)}function yg(e,t,n){let r=n.v,i=n.w;t.hasEdge(r,i)||(r=n.w,i=n.v);let a=e.node(r),o=e.node(i),s=a,c=!1;return a.lim>o.lim&&(s=o,c=!0),t.edges().filter(t=>c===Cg(e,e.node(t.v),s)&&c!==Cg(e,e.node(t.w),s)).reduce((e,n)=>rg(t,n)!e.node(t).parent);if(!n)return;let r=lg(e,[n]);r=r.slice(1),r.forEach(n=>{let r=e.node(n).parent,i=t.edge(n,r),a=!1;i||(i=t.edge(r,n),a=!0),t.node(n).rank=t.node(r).rank+(a?i.minlen:-i.minlen)})}function Sg(e,t,n){return e.hasEdge(t,n)}function Cg(e,t,n){return n.low<=t.lim&&t.lim<=n.lim}var wg=Tg;function Tg(e){let t=e.graph().ranker;if(typeof t==`function`)return t(e);switch(t){case`network-simplex`:Og(e);break;case`tight-tree`:Dg(e);break;case`longest-path`:Eg(e);break;case`none`:break;default:Og(e)}}var Eg=ng;function Dg(e){ng(e),ig(e)}function Og(e){dg(e)}var kg=Ag;function Ag(e){let t=Mg(e);e.graph().dummyChains.forEach(n=>{let r=e.node(n),i=r.edgeObj,a=jg(e,t,i.v,i.w),o=a.path,s=a.lca,c=0,l=o[c],u=!0;for(;n!==i.w;){if(r=e.node(n),u){for(;(l=o[c])!==s&&e.node(l).maxRanko||s>t[c].lim));let l=c,u=r;for(;(u=e.parent(u))!==l;)a.push(u);return{path:i.concat(a.reverse()),lca:l}}function Mg(e){let t={},n=0;function r(i){let a=n;e.children(i).forEach(r),t[i]={low:a,lim:n++}}return e.children(Rh).forEach(r),t}function Ng(e){let t=_h(e,`root`,{},`_root`),n=Fg(e),r=Object.values(n),i=Dh(Math.max,r)-1,a=2*i+1;e.graph().nestingRoot=t,e.edges().forEach(t=>e.edge(t).minlen*=a);let o=Ig(e)+1;e.children(Rh).forEach(r=>Pg(e,t,a,o,i,n,r)),e.graph().nodeRankFactor=a}function Pg(e,t,n,r,i,a,o){let s=e.children(o);if(!s.length){o!==t&&e.setEdge(t,o,{weight:0,minlen:n});return}let c=wh(e,`_bt`),l=wh(e,`_bb`),u=e.node(o);e.setParent(c,o),u.borderTop=c,e.setParent(l,o),u.borderBottom=l,s.forEach(s=>{Pg(e,t,n,r,i,a,s);let u=e.node(s),d=u.borderTop?u.borderTop:s,f=u.borderBottom?u.borderBottom:s,p=u.borderTop?r:2*r,m=d===f?i-(a[o]??0)+1:1;e.setEdge(c,d,{weight:p,minlen:m,nestingEdge:!0}),e.setEdge(f,l,{weight:p,minlen:m,nestingEdge:!0})}),e.parent(o)||e.setEdge(t,c,{weight:0,minlen:i+(a[o]??0)})}function Fg(e){let t={};function n(r,i){let a=e.children(r);a&&a.length&&a.forEach(e=>n(e,i+1)),t[r]=i}return e.children(Rh).forEach(e=>n(e,1)),t}function Ig(e){return e.edges().reduce((t,n)=>t+e.edge(n).weight,0)}function Lg(e){let t=e.graph();e.removeNode(t.nestingRoot),delete t.nestingRoot,e.edges().forEach(t=>{e.edge(t).nestingEdge&&e.removeEdge(t)})}var Rg=zg;function zg(e){function t(n){let r=e.children(n),i=e.node(n);if(r.length&&r.forEach(t),Object.hasOwn(i,`minRank`)){i.borderLeft=[],i.borderRight=[];for(let t=i.minRank,r=i.maxRank+1;tWg(e.node(t))),e.edges().forEach(t=>Wg(e.edge(t)))}function Wg(e){let t=e.width;e.width=e.height,e.height=t}function Gg(e){e.nodes().forEach(t=>Kg(e.node(t))),e.edges().forEach(t=>{var n;let r=e.edge(t);(n=r.points)==null||n.forEach(Kg),Object.hasOwn(r,`y`)&&Kg(r)})}function Kg(e){e.y=-e.y}function qg(e){e.nodes().forEach(t=>Jg(e.node(t))),e.edges().forEach(t=>{var n;let r=e.edge(t);(n=r.points)==null||n.forEach(Jg),Object.hasOwn(r,`x`)&&Jg(r)})}function Jg(e){let t=e.x;e.x=e.y,e.y=t}function Yg(e){let t={},n=e.nodes().filter(t=>!e.children(t).length),r=n.map(t=>e.node(t).rank),i=Ph(Dh(Math.max,r)+1).map(()=>[]);function a(n){if(t[n])return;t[n]=!0;let r=e.node(n);i[r.rank].push(n);let o=e.successors(n);o&&o.forEach(a)}return n.sort((t,n)=>e.node(t).rank-e.node(n).rank).forEach(a),i}function Xg(e,t){let n=0;for(let r=1;rt)),i=t.flatMap(t=>{let n=e.outEdges(t);return n?n.map(t=>({pos:r[t.w],weight:e.edge(t).weight})).sort((e,t)=>e.pos-t.pos):[]}),a=1;for(;a{let t=e.pos+a;s[t]+=e.weight;let n=0;for(;t>0;)t%2&&(n+=s[t+1]),t=t-1>>1,s[t]+=e.weight;c+=e.weight*n}),c}function Qg(e,t=[]){return t.map(t=>{let n=e.inEdges(t);if(!n||!n.length)return{v:t};{let r=n.reduce((t,n)=>{let r=e.edge(n),i=e.node(n.v);return{sum:t.sum+r.weight*i.order,weight:t.weight+r.weight}},{sum:0,weight:0});return{v:t,barycenter:r.sum/r.weight,weight:r.weight}}})}function $g(e,t){let n={};return e.forEach((e,t)=>{let r={indegree:0,in:[],out:[],vs:[e.v],i:t};e.barycenter!==void 0&&(r.barycenter=e.barycenter,r.weight=e.weight),n[e.v]=r}),t.edges().forEach(e=>{let t=n[e.v],r=n[e.w];t!==void 0&&r!==void 0&&(r.indegree++,t.out.push(r))}),e_(Object.values(n).filter(e=>!e.indegree))}function e_(e){let t=[];function n(e){return t=>{t.merged||(t.barycenter===void 0||e.barycenter===void 0||t.barycenter>=e.barycenter)&&t_(e,t)}}function r(t){return n=>{n.in.push(t),--n.indegree===0&&e.push(n)}}for(;e.length;){let i=e.pop();t.push(i),i.in.reverse().forEach(n(i)),i.out.forEach(r(i))}return t.filter(e=>!e.merged).map(e=>Fh(e,[`vs`,`i`,`barycenter`,`weight`]))}function t_(e,t){let n=0,r=0;e.weight&&(n+=e.barycenter*e.weight,r+=e.weight),t.weight&&(n+=t.barycenter*t.weight,r+=t.weight),e.vs=t.vs.concat(e.vs),e.barycenter=n/r,e.weight=r,e.i=Math.min(t.i,e.i),t.merged=!0}function n_(e,t){let n=kh(e,e=>Object.hasOwn(e,`barycenter`)),r=n.lhs,i=n.rhs.sort((e,t)=>t.i-e.i),a=[],o=0,s=0,c=0;r.sort(i_(!!t)),c=r_(a,i,c),r.forEach(e=>{c+=e.vs.length,a.push(e.vs),o+=e.barycenter*e.weight,s+=e.weight,c=r_(a,i,c)});let l={vs:a.flat(1)};return s&&(l.barycenter=o/s,l.weight=s),l}function r_(e,t,n){let r;for(;t.length&&(r=t[t.length-1]).i<=n;)t.pop(),e.push(r.vs),n++;return n}function i_(e){return(t,n)=>t.barycentern.barycenter?1:e?n.i-t.i:t.i-n.i}function a_(e,t,n,r){let i=e.children(t),a=e.node(t),o=a?a.borderLeft:void 0,s=a?a.borderRight:void 0,c={};o&&(i=i.filter(e=>e!==o&&e!==s));let l=Qg(e,i);l.forEach(t=>{if(e.children(t.v).length){let i=a_(e,t.v,n,r);c[t.v]=i,Object.hasOwn(i,`barycenter`)&&s_(t,i)}});let u=$g(l,n);o_(u,c);let d=n_(u,r);if(o&&s){d.vs=[o,d.vs,s].flat(1);let t=e.predecessors(o);if(t&&t.length){let n=e.node(t[0]),r=e.predecessors(s),i=e.node(r[0]);Object.hasOwn(d,`barycenter`)||(d.barycenter=0,d.weight=0),d.barycenter=(d.barycenter*d.weight+n.order+i.order)/(d.weight+2),d.weight+=2}}return d}function o_(e,t){e.forEach(e=>{e.vs=e.vs.flatMap(e=>t[e]?t[e].vs:e)})}function s_(e,t){e.barycenter===void 0?(e.barycenter=t.barycenter,e.weight=t.weight):(e.barycenter=(e.barycenter*e.weight+t.barycenter*t.weight)/(e.weight+t.weight),e.weight+=t.weight)}function c_(e,t,n,r){r||=e.nodes();let i=l_(e),a=new Nm({compound:!0}).setGraph({root:i}).setDefaultNodeLabel(t=>e.node(t));return r.forEach(r=>{let o=e.node(r),s=e.parent(r);if(o.rank===t||o.minRank<=t&&t<=o.maxRank){a.setNode(r),a.setParent(r,s||i);let c=e[n](r);c&&c.forEach(t=>{let n=t.v===r?t.w:t.v,i=a.edge(n,r),o=i===void 0?0:i.weight;a.setEdge(n,r,{weight:e.edge(t).weight+o})}),Object.hasOwn(o,`minRank`)&&a.setNode(r,{borderLeft:o.borderLeft[t],borderRight:o.borderRight[t]})}}),a}function l_(e){let t;for(;e.hasNode(t=Nh(`_root`)););return t}function u_(e,t,n){let r={},i;n.forEach(n=>{let a=e.parent(n),o,s;for(;a;){if(o=e.parent(a),o?(s=r[o],r[o]=a):(s=i,i=a),s&&s!==a){t.setEdge(s,a);return}a=o}})}function d_(e,t={}){if(typeof t.customOrder==`function`){t.customOrder(e,d_);return}let n=Oh(e),r=f_(e,Ph(1,n+1),`inEdges`),i=f_(e,Ph(n-1,-1,-1),`outEdges`),a=Yg(e);if(m_(e,a),t.disableOptimalOrderHeuristic)return;let o=1/0,s,c=t.constraints||[];for(let t=0,n=0;n<4;++t,++n){p_(t%2?r:i,t%4>=2,c),a=xh(e);let l=Xg(e,a);l{r.has(e)||r.set(e,[]),r.get(e).push(t)};for(let t of e.nodes()){let n=e.node(t);if(typeof n.rank==`number`&&i(n.rank,t),typeof n.minRank==`number`&&typeof n.maxRank==`number`)for(let e=n.minRank;e<=n.maxRank;e++)e!==n.rank&&i(e,t)}return t.map(function(t){return c_(e,t,n,r.get(t)||[])})}function p_(e,t,n){let r=new Nm;e.forEach(function(e){n.forEach(e=>r.setEdge(e.left,e.right));let i=e.graph().root,a=a_(e,i,r,t);a.vs.forEach((t,n)=>e.node(t).order=n),u_(e,r,a.vs)})}function m_(e,t){Object.values(t).forEach(t=>t.forEach((t,n)=>e.node(t).order=n))}function h_(e,t){let n={};function r(t,r){let i=0,a=0,o=t.length,s=r[r.length-1];return r.forEach((t,c)=>{let l=__(e,t),u=l?e.node(l).order:o;(l||t===s)&&(r.slice(a,c+1).forEach(t=>{let r=e.predecessors(t);r&&r.forEach(r=>{let a=e.node(r),o=a.order;(o{let i=t[r];if(i!==void 0&&e.node(i).dummy){let t=e.predecessors(i);t&&t.forEach(t=>{if(t===void 0)return;let r=e.node(t);r.dummy&&(r.ordero)&&v_(n,t,i)})}})}function i(t,n){let i=-1,a=-1,o=0;return n.forEach((s,c)=>{if(e.node(s).dummy===`border`){let t=e.predecessors(s);if(t&&t.length){let s=t[0];if(s===void 0)return;a=e.node(s).order,r(n,o,c,i,a),o=c,i=a}}r(n,o,n.length,a,t.length)}),n}return t.length&&t.reduce(i),n}function __(e,t){if(e.node(t).dummy){let n=e.predecessors(t);if(n)return n.find(t=>e.node(t).dummy)}}function v_(e,t,n){if(t>n){let e=t;t=n,n=e}let r=e[t];r||(e[t]=r={}),r[n]=!0}function y_(e,t,n){if(t>n){let e=t;t=n,n=e}let r=e[t];return r!==void 0&&Object.hasOwn(r,n)}function b_(e,t,n,r){let i={},a={},o={};return t.forEach(e=>{e.forEach((e,t)=>{i[e]=e,a[e]=e,o[e]=t})}),t.forEach(e=>{let t=-1;e.forEach(e=>{let s=r(e);if(s&&s.length){let r=s.sort((e,t)=>{let n=o[e],r=o[t];return(n===void 0?0:n)-(r===void 0?0:r)}),c=(r.length-1)/2;for(let s=Math.floor(c),l=Math.ceil(c);s<=l;++s){let c=r[s];if(c===void 0)continue;let l=o[c];if(l!==void 0&&a[e]===e&&t{let n=a[t.v]??0,r=o.edge(t);return Math.max(e,n+(r===void 0?0:r))},0):a[e]=0}function u(t){let n=o.outEdges(t),r=1/0;n&&(r=n.reduce((e,t)=>{let n=a[t.w],r=o.edge(t);return Math.min(e,(n===void 0?0:n)-(r===void 0?0:r))},1/0));let i=e.node(t);r!==1/0&&i.borderType!==s&&(a[t]=Math.max(a[t]===void 0?0:a[t],r))}function d(e){return o.predecessors(e)||[]}function f(e){return o.successors(e)||[]}return c(l,d),c(u,f),Object.keys(r).forEach(e=>{let t=n[e];t!==void 0&&(a[e]=a[t]??0)}),a}function S_(e,t,n,r){let i=new Nm,a=e.graph(),o=D_(a.nodesep,a.edgesep,r);return t.forEach(t=>{let r;t.forEach(t=>{let a=n[t];if(a!==void 0){if(i.setNode(a),r!==void 0){let s=n[r];if(s!==void 0){let n=i.edge(s,a);i.setEdge(s,a,Math.max(o(e,t,r),n||0))}}r=t}})}),i}function C_(e,t){return Object.values(t).reduce((t,n)=>{let r=-1/0,i=1/0;Object.entries(n).forEach(([t,n])=>{let a=O_(e,t)/2;r=Math.max(n+a,r),i=Math.min(n-a,i)});let a=r-i;return a{[`l`,`r`].forEach(a=>{let o=n+a,s=e[o];if(!s||s===t)return;let c=Object.values(s),l=r-Dh(Math.min,c);a!==`l`&&(l=i-Dh(Math.max,c)),l&&(e[o]=Ih(s,e=>e+l))})})}function T_(e,t=void 0){let n=e.ul;return n?Ih(n,(n,r)=>{if(t){let n=e[t.toLowerCase()];if(n&&n[r]!==void 0)return n[r]}let i=Object.values(e).map(e=>{let t=e[r];return t===void 0?0:t}).sort((e,t)=>e-t);return((i[1]??0)+(i[2]??0))/2}):{}}function E_(e){let t=xh(e),n=Object.assign(h_(e,t),g_(e,t)),r={},i;return[`u`,`d`].forEach(a=>{i=a===`u`?t:Object.values(t).reverse(),[`l`,`r`].forEach(t=>{t===`r`&&(i=i.map(e=>Object.values(e).reverse()));let o=b_(e,i,n,t=>(a===`u`?e.predecessors(t):e.successors(t))||[]),s=x_(e,i,o.root,o.align,t===`r`);t===`r`&&(s=Ih(s,e=>-e)),r[a+t]=s})}),w_(r,C_(e,r)),T_(r,e.graph().align)}function D_(e,t,n){return(r,i,a)=>{let o=r.node(i),s=r.node(a),c=0,l;if(c+=o.width/2,Object.hasOwn(o,`labelpos`))switch(o.labelpos.toLowerCase()){case`l`:l=-o.width/2;break;case`r`:l=o.width/2;break}if(l&&(c+=n?l:-l),l=void 0,c+=(o.dummy?t:e)/2,c+=(s.dummy?t:e)/2,c+=s.width/2,Object.hasOwn(s,`labelpos`))switch(s.labelpos.toLowerCase()){case`l`:l=s.width/2;break;case`r`:l=-s.width/2;break}return l&&(c+=n?l:-l),c}}function O_(e,t){return e.node(t).width}function k_(e){e=yh(e),A_(e),Object.entries(E_(e)).forEach(([t,n])=>e.node(t).x=n)}function A_(e){let t=xh(e),n=e.graph(),r=n.ranksep,i=n.rankalign,a=0;t.forEach(t=>{let n=t.reduce((t,n)=>{let r=e.node(n).height??0;return t>r?t:r},0);t.forEach(t=>{let r=e.node(t);i===`top`?r.y=a+r.height/2:i===`bottom`?r.y=a+n-r.height/2:r.y=a+n/2}),a+=n+r})}function j_(e,t={}){let n=t.debugTiming?Ah:jh;return n(`layout`,()=>{let r=n(` buildLayoutGraph`,()=>H_(e));return n(` runLayout`,()=>M_(r,n,t)),n(` updateInputGraph`,()=>N_(e,r)),r})}function M_(e,t,n){t(` makeSpaceForEdgeLabels`,()=>U_(e)),t(` removeSelfEdges`,()=>Q_(e)),t(` acyclic`,()=>Xh(e)),t(` nestingGraph.run`,()=>Ng(e)),t(` rank`,()=>wg(yh(e))),t(` injectEdgeLabelProxies`,()=>W_(e)),t(` removeEmptyRanks`,()=>Ch(e)),t(` nestingGraph.cleanup`,()=>Lg(e)),t(` normalizeRanks`,()=>Sh(e)),t(` assignRankMinMax`,()=>G_(e)),t(` removeEdgeLabelProxies`,()=>K_(e)),t(` normalize.run`,()=>$h(e)),t(` parentDummyChains`,()=>kg(e)),t(` addBorderSegments`,()=>Rg(e)),t(` order`,()=>d_(e,n)),t(` insertSelfEdges`,()=>$_(e)),t(` adjustCoordinateSystem`,()=>Vg(e)),t(` position`,()=>k_(e)),t(` positionSelfEdges`,()=>ev(e)),t(` removeBorderNodes`,()=>Z_(e)),t(` normalize.undo`,()=>tg(e)),t(` fixupEdgeLabelCoords`,()=>Y_(e)),t(` undoCoordinateSystem`,()=>Hg(e)),t(` translateGraph`,()=>q_(e)),t(` assignNodeIntersects`,()=>J_(e)),t(` reversePoints`,()=>X_(e)),t(` acyclic.undo`,()=>Qh(e))}function N_(e,t){e.nodes().forEach(n=>{let r=e.node(n),i=t.node(n);r&&(r.x=i.x,r.y=i.y,r.order=i.order,r.rank=i.rank,t.children(n).length&&(r.width=i.width,r.height=i.height))}),e.edges().forEach(n=>{let r=e.edge(n),i=t.edge(n);r.points=i.points,Object.hasOwn(i,`x`)&&(r.x=i.x,r.y=i.y)}),e.graph().width=t.graph().width,e.graph().height=t.graph().height}var P_=[`nodesep`,`edgesep`,`ranksep`,`marginx`,`marginy`],F_={ranksep:50,edgesep:20,nodesep:50,rankdir:`TB`,rankalign:`center`},I_=[`acyclicer`,`ranker`,`rankdir`,`align`,`rankalign`],L_=[`width`,`height`,`rank`],R_={width:0,height:0},z_=[`minlen`,`weight`,`width`,`height`,`labeloffset`],B_={minlen:1,weight:1,width:0,height:0,labeloffset:10,labelpos:`r`},V_=[`labelpos`];function H_(e){let t=new Nm({multigraph:!0,compound:!0}),n=nv(e.graph());return t.setGraph(Object.assign({},F_,tv(n,P_),Fh(n,I_))),e.nodes().forEach(n=>{let r=tv(nv(e.node(n)),L_);Object.keys(R_).forEach(e=>{r[e]===void 0&&(r[e]=R_[e])}),t.setNode(n,r);let i=e.parent(n);i!==void 0&&t.setParent(n,i)}),e.edges().forEach(n=>{let r=nv(e.edge(n));t.setEdge(n,Object.assign({},B_,tv(r,z_),Fh(r,V_)))}),t}function U_(e){let t=e.graph();t.ranksep/=2,e.edges().forEach(n=>{let r=e.edge(n);r.minlen*=2,r.labelpos.toLowerCase()!==`c`&&(t.rankdir===`TB`||t.rankdir===`BT`?r.width+=r.labeloffset:r.height+=r.labeloffset)})}function W_(e){e.edges().forEach(t=>{let n=e.edge(t);if(n.width&&n.height){let n=e.node(t.v);_h(e,`edge-proxy`,{rank:(e.node(t.w).rank-n.rank)/2+n.rank,e:t},`_ep`)}})}function G_(e){let t=0;e.nodes().forEach(n=>{let r=e.node(n);r.borderTop&&(r.minRank=e.node(r.borderTop).rank,r.maxRank=e.node(r.borderBottom).rank,t=Math.max(t,r.maxRank))}),e.graph().maxRank=t}function K_(e){e.nodes().forEach(t=>{let n=e.node(t);if(n.dummy===`edge-proxy`){let r=n;e.edge(r.e).labelRank=n.rank,e.removeNode(t)}})}function q_(e){let t=1/0,n=0,r=1/0,i=0,a=e.graph(),o=a.marginx||0,s=a.marginy||0;function c(e){let a=e.x,o=e.y,s=e.width,c=e.height;t=Math.min(t,a-s/2),n=Math.max(n,a+s/2),r=Math.min(r,o-c/2),i=Math.max(i,o+c/2)}e.nodes().forEach(t=>c(e.node(t))),e.edges().forEach(t=>{let n=e.edge(t);Object.hasOwn(n,`x`)&&c(n)}),t-=o,r-=s,e.nodes().forEach(n=>{let i=e.node(n);i.x-=t,i.y-=r}),e.edges().forEach(n=>{let i=e.edge(n);i.points.forEach(e=>{e.x-=t,e.y-=r}),Object.hasOwn(i,`x`)&&(i.x-=t),Object.hasOwn(i,`y`)&&(i.y-=r)}),a.width=n-t+o,a.height=i-r+s}function J_(e){e.edges().forEach(t=>{let n=e.edge(t),r=e.node(t.v),i=e.node(t.w),a,o;n.points?(a=n.points[0],o=n.points[n.points.length-1]):(n.points=[],a=i,o=r),n.points.unshift(bh(r,a)),n.points.push(bh(i,o))})}function Y_(e){e.edges().forEach(t=>{let n=e.edge(t);if(Object.hasOwn(n,`x`))switch((n.labelpos===`l`||n.labelpos===`r`)&&(n.width-=n.labeloffset),n.labelpos){case`l`:n.x-=n.width/2+n.labeloffset;break;case`r`:n.x+=n.width/2+n.labeloffset;break}})}function X_(e){e.edges().forEach(t=>{let n=e.edge(t);n.reversed&&n.points.reverse()})}function Z_(e){e.nodes().forEach(t=>{if(e.children(t).length){let n=e.node(t),r=e.node(n.borderTop),i=e.node(n.borderBottom),a=e.node(n.borderLeft[n.borderLeft.length-1]),o=e.node(n.borderRight[n.borderRight.length-1]);n.width=Math.abs(o.x-a.x),n.height=Math.abs(i.y-r.y),n.x=a.x+n.width/2,n.y=r.y+n.height/2}}),e.nodes().forEach(t=>{e.node(t).dummy===`border`&&e.removeNode(t)})}function Q_(e){e.edges().forEach(t=>{if(t.v===t.w){let n=e.node(t.v);n.selfEdges||=[],n.selfEdges.push({e:t,label:e.edge(t)}),e.removeEdge(t)}})}function $_(e){xh(e).forEach(t=>{let n=0;t.forEach((t,r)=>{let i=e.node(t);i.order=r+n,(i.selfEdges||[]).forEach(t=>{_h(e,`selfedge`,{width:t.label.width,height:t.label.height,rank:i.rank,order:r+ ++n,e:t.e,label:t.label},`_se`)}),delete i.selfEdges})})}function ev(e){e.nodes().forEach(t=>{let n=e.node(t);if(n.dummy===`selfedge`){let r=n,i=e.node(r.e.v),a=i.x+i.width/2,o=i.y,s=n.x-a,c=i.height/2;e.setEdge(r.e,r.label),e.removeNode(t),r.label.points=[{x:a+2*s/3,y:o-c},{x:a+5*s/6,y:o-c},{x:a+s,y:o},{x:a+5*s/6,y:o+c},{x:a+2*s/3,y:o+c}],r.label.x=n.x,r.label.y=n.y}})}function tv(e,t){return Ih(Fh(e,t),Number)}function nv(e){let t={};return e&&Object.entries(e).forEach(([e,n])=>{typeof e==`string`&&(e=e.toLowerCase()),t[e]=n}),t}var rv=180,iv=80;function av(e,t,n){let r=n?.direction||`TB`,i=n?.heatmapMode||`none`,a=n?.bookmarks||new Set,o=n?.hiddenNodes||new Set,s=n?.isolatedSubtree||null,c=e.filter(e=>!o.has(e.id)),l=t;if(s){let n=ov(s,e,t);c=c.filter(e=>n.has(e.id)),l=t.filter(e=>n.has(e.source)&&n.has(e.target))}let u=new Nm;return u.setDefaultEdgeLabel(()=>({})),u.setGraph({rankdir:r,ranksep:80,nodesep:40,marginx:40,marginy:40}),c.forEach(e=>{u.setNode(e.id,{width:rv,height:iv})}),l.forEach(e=>{c.some(t=>t.id===e.source)&&c.some(t=>t.id===e.target)&&u.setEdge(e.source,e.target)}),j_(u),{nodes:c.map(t=>{let n=u.node(t.id);return{id:t.id,type:`architectureNode`,position:{x:n.x-rv/2,y:n.y-iv/2},data:{label:t.name,nodeType:t.type,description:t.description,filesCount:t.files.length,complexity:t.estimatedComplexity,isSelected:!1,isHighlighted:!1,heatmapIntensity:sv(t,e,i),isBookmarked:a.has(t.id)}}}),edges:l.filter(e=>c.some(t=>t.id===e.source)&&c.some(t=>t.id===e.target)).map(e=>({id:e.id,source:e.source,target:e.target,type:`smoothstep`,animated:e.type===`data-flow`||e.type===`event`,style:{stroke:cv(e.type),strokeWidth:1.5,opacity:.6}}))}}function ov(e,t,n){let r=new Set([e]),i=[e];for(;i.length>0;){let e=i.shift();for(let t of n)t.source===e&&!r.has(t.target)&&(r.add(t.target),i.push(t.target))}let a=t.find(t=>t.id===e);if(a)for(let e of a.dependents)r.add(e);return r}function sv(e,t,n){if(n===`none`)return 0;switch(n){case`complexity`:return{low:.2,medium:.5,high:.9}[e.estimatedComplexity]||0;case`usage`:{let n=Math.max(...t.map(e=>e.dependents.length),1);return e.dependents.length/n}case`size`:{let n=Math.max(...t.map(e=>e.estimatedLines),1);return e.estimatedLines/n}case`critical`:{let t=e.dependents.length*.4+(e.estimatedComplexity===`high`?.4:e.estimatedComplexity===`medium`?.2:0)+(e.files.length>3?.2:0);return Math.min(t,1)}default:return 0}}function cv(e){switch(e){case`api-call`:return`hsl(200, 70%, 60%)`;case`data-flow`:return`hsl(150, 60%, 50%)`;case`event`:return`hsl(280, 60%, 60%)`;case`import`:return`hsl(220, 40%, 55%)`;default:return`hsl(var(--muted-foreground))`}}var lv={architectureNode:um};function uv({model:e,source:t}){let n=$l(),r=(0,W.useRef)(null),[i,a]=(0,W.useState)(!1),{selectedNodeId:o,setSelectedNodeId:s,highlightedNodeIds:c,setHighlightedNodeIds:l,searchQuery:u,showGrid:d,showMiniMap:f,inspectorOpen:p,setInspectorOpen:m,explorerOpen:h,activeTab:g,setActiveTab:_,heatmapMode:v,bookmarkedNodes:y,hiddenNodes:b,showAllNodes:x,isolatedSubtree:S,setIsolatedSubtree:C,openContextMenu:w,closeContextMenu:T}=dm(),{nodes:E,edges:D}=(0,W.useMemo)(()=>av(e.nodes,e.edges,{heatmapMode:v,bookmarks:y,hiddenNodes:b,isolatedSubtree:S}),[e,v,y,b,S]),[O,k,A]=sf(E),[j,M,N]=cf(D);(0,W.useEffect)(()=>{let{nodes:t,edges:n}=av(e.nodes,e.edges,{heatmapMode:v,bookmarks:y,hiddenNodes:b,isolatedSubtree:S});k(t),M(n)},[v,y,b,S,e,k,M]),(0,W.useEffect)(()=>{k(e=>e.map(e=>({...e,data:{...e.data,isSelected:e.id===o,isHighlighted:c.has(e.id)}})))},[o,c,k]),(0,W.useEffect)(()=>{if(!u){k(e=>e.map(e=>({...e,style:void 0})));return}let t=u.toLowerCase();k(n=>n.map(n=>{let r=e.nodes.find(e=>e.id===n.id),i=r&&(r.name.toLowerCase().includes(t)||r.type.toLowerCase().includes(t)||r.tags.some(e=>e.includes(t))||r.layer.toLowerCase().includes(t));return{...n,style:i?void 0:{opacity:.15}}}))},[u,k,e.nodes]);let P=(0,W.useCallback)((e,t)=>{T(),s(t.id)},[s,T]),F=(0,W.useCallback)((e,t)=>{C(S===t.id?null:t.id)},[C,S]),I=(0,W.useCallback)((e,t)=>{e.preventDefault(),w(t.id,{x:e.clientX,y:e.clientY})},[w]),R=(0,W.useCallback)((t,n)=>{let r=e.nodes.find(e=>e.id===n.id);if(!r)return;let i=new Set([n.id,...r.dependencies,...r.dependents]);l(i)},[e.nodes,l]),z=(0,W.useCallback)(()=>{l(new Set)},[l]),B=(0,W.useCallback)(()=>{s(null),m(!1),T()},[s,m,T]),V=(0,W.useCallback)(()=>{n.fitView({padding:.2,duration:300})},[n]),H=(0,W.useCallback)(()=>{n.zoomIn({duration:200})},[n]),ee=(0,W.useCallback)(()=>{n.zoomOut({duration:200})},[n]),te=(0,W.useCallback)(()=>{C(null),x();let{nodes:t,edges:r}=av(e.nodes,e.edges,{heatmapMode:v,bookmarks:y});k(t),M(r),setTimeout(()=>n.fitView({padding:.2,duration:300}),50)},[e,k,M,n,v,y,C,x]),U=(0,W.useCallback)(()=>{let t=document.querySelector(`.react-flow`);t&&cm(t,{backgroundColor:`#0a0e1a`}).then(t=>{let n=document.createElement(`a`);n.href=t,n.download=`${e.repositoryName}-architecture.png`,n.click()})},[e.repositoryName]),ne=(0,W.useCallback)(()=>{let t=document.querySelector(`.react-flow`);t&&om(t,{backgroundColor:`#0a0e1a`}).then(t=>{let n=document.createElement(`a`);n.href=t,n.download=`${e.repositoryName}-architecture.svg`,n.click()})},[e.repositoryName]),re=(0,W.useCallback)(()=>{let t=JSON.stringify(e,null,2),n=new Blob([t],{type:`application/json`}),r=URL.createObjectURL(n),i=document.createElement(`a`);i.href=r,i.download=`${e.repositoryName}-architecture.json`,i.click(),URL.revokeObjectURL(r)},[e]),ie=(0,W.useCallback)(()=>{let t=pv(e),n=new Blob([t],{type:`text/markdown`}),r=URL.createObjectURL(n),i=document.createElement(`a`);i.href=r,i.download=`${e.repositoryName}-architecture.md`,i.click(),URL.revokeObjectURL(r)},[e]),ae=(0,W.useCallback)(()=>{r.current&&(document.fullscreenElement?(document.exitFullscreen(),a(!1)):(r.current.requestFullscreen(),a(!0)))},[]);(0,W.useEffect)(()=>{let e=()=>a(!!document.fullscreenElement);return document.addEventListener(`fullscreenchange`,e),()=>document.removeEventListener(`fullscreenchange`,e)},[]);let oe=(0,W.useMemo)(()=>o?e.nodes.find(e=>e.id===o):null,[o,e.nodes]);return(0,G.jsxs)(`div`,{ref:r,className:`flex flex-col h-full bg-background`,children:[(0,G.jsx)(bm,{model:e,source:t}),(0,G.jsxs)(`div`,{className:`flex items-center justify-between gap-2 px-4 py-2 border-b border-border`,children:[(0,G.jsxs)(`div`,{className:`flex items-center gap-1`,children:[(0,G.jsx)(fv,{active:g===`graph`,onClick:()=>_(`graph`),children:`Architecture Graph`}),(0,G.jsx)(fv,{active:g===`request-flow`,onClick:()=>_(`request-flow`),children:`Request Flow`}),(0,G.jsx)(fv,{active:g===`heatmap`,onClick:()=>_(`heatmap`),children:`Heatmap`})]}),g===`heatmap`&&(0,G.jsx)(Tm,{}),S&&(0,G.jsx)(`button`,{onClick:()=>C(null),className:`text-[11px] text-primary hover:text-primary/80 font-medium transition-colors`,children:`Exit Isolation`})]}),(0,G.jsxs)(`div`,{className:`flex flex-1 overflow-hidden`,children:[h&&(g===`graph`||g===`heatmap`)&&(0,G.jsx)(gm,{}),(0,G.jsxs)(`div`,{className:`flex-1 relative flex flex-col`,children:[(0,G.jsx)(`div`,{className:`flex-1 relative`,children:g===`graph`||g===`heatmap`?(0,G.jsxs)(G.Fragment,{children:[(0,G.jsx)(_m,{onFitView:V,onZoomIn:H,onZoomOut:ee,onResetLayout:te,onExportPng:U,onExportSvg:ne,onExportJson:re,onExportMarkdown:ie,onToggleFullscreen:ae,isFullscreen:i}),(0,G.jsxs)(of,{nodes:O,edges:j,onNodesChange:A,onEdgesChange:N,onNodeClick:P,onNodeDoubleClick:F,onNodeContextMenu:I,onNodeMouseEnter:R,onNodeMouseLeave:z,onPaneClick:B,nodeTypes:lv,fitView:!0,fitViewOptions:{padding:.2},minZoom:.1,maxZoom:3,proOptions:{hideAttribution:!0},children:[d&&(0,G.jsx)(hf,{variant:df.Dots,gap:20,size:1,color:`hsl(var(--border))`}),f&&(0,G.jsx)(Bf,{nodeColor:()=>`hsl(var(--primary))`,maskColor:`hsl(var(--background) / 0.8)`,className:`!bg-card !border-border !rounded-lg`,style:{width:140,height:90}})]})]}):(0,G.jsx)(`div`,{className:`flex-1 overflow-y-auto scrollbar-thin`,children:(0,G.jsx)(Sm,{steps:e.requestFlow})})}),(0,G.jsx)(Em,{})]}),(0,G.jsx)(L,{children:p&&oe&&(0,G.jsx)(fm,{node:oe,onClose:()=>{s(null),m(!1)}})})]}),(0,G.jsx)(Cm,{})]})}function dv({model:e,source:t}){return(0,G.jsx)(tf,{children:(0,G.jsx)(uv,{model:e,source:t})})}function fv({active:t,onClick:n,children:r}){return(0,G.jsx)(`button`,{onClick:n,className:e(`px-3 py-1.5 rounded-md text-xs font-medium transition-colors`,t?`bg-accent text-foreground`:`text-muted-foreground hover:text-foreground hover:bg-accent/50`),children:r})}function pv(e){let t=[`# ${e.repositoryName} - Architecture`,``,`**Pattern:** ${e.architectureType}`,`**Language:** ${e.summary.language}`,`**Framework:** ${e.summary.framework}`,`**Entry Point:** ${e.summary.entryPoint}`,``,`## Layers`,``];for(let n of e.detectedLayers){t.push(`### ${n.name}`),t.push(``);let r=e.nodes.filter(e=>n.nodes.includes(e.id));for(let e of r)t.push(`- **${e.name}** (${e.type}) - ${e.description}`),e.files.length>0&&t.push(` - Files: ${e.files.join(`, `)}`);t.push(``)}t.push(`## Dependencies`),t.push(``);for(let n of e.edges){let r=e.nodes.find(e=>e.id===n.source),i=e.nodes.find(e=>e.id===n.target);r&&i&&t.push(`- ${r.name} -> ${i.name} (${n.type})`)}return t.join(` -`)}function mv(){let{activeRepository:e,completedRepositories:t}=F(),r=dm(e=>e.setModel),[i,a]=(0,W.useState)(dm(e=>e.model)),[o,s]=(0,W.useState)(null),[c,l]=(0,W.useState)(`idle`),[u,d]=(0,W.useState)(null),[f,p]=(0,W.useState)(0),m=(0,W.useCallback)(()=>p(e=>e+1),[]);(0,W.useEffect)(()=>{if(t.length===0){l(`empty`),a(null),s(null),d(null);return}if(!e||e.status!==`completed`){l(`empty`),a(null),s(null),d(null);return}let i=!1;async function o(){if(e){l(`loading`),d(null);try{let t=await N.fetchArchitecture(e);if(i)return;a(t),r(t),s(`real`),l(`success`)}catch(e){if(i)return;a(null),s(null),d(n(e)),l(`error`)}}}return o(),()=>{i=!0}},[e,t.length,f,r]);let h=c===`empty`?t.length===0?`no-completed-repositories`:`no-active-repository`:null;return{model:i,data:i,source:o,status:c,loading:c===`loading`,error:u,empty:c===`empty`,success:c===`success`,retry:m,refresh:m,activeRepository:e,completedRepositories:t,emptyReason:h,usingMockData:!1}}function hv(){let e=T(),t=mv();return t.emptyReason===`no-completed-repositories`?(0,G.jsx)(`div`,{className:`h-full flex flex-col`,children:(0,G.jsx)(z,{icon:k,title:`No architecture data`,description:`Upload and analyse a repository to generate its architecture model. The architecture graph is built during the analysis pipeline.`,action:{label:`Upload Repository`,onClick:()=>e(`/upload`)}})}):t.emptyReason===`no-active-repository`?(0,G.jsx)(`div`,{className:`h-full flex flex-col`,children:(0,G.jsx)(z,{icon:k,title:`Select a repository`,description:`Choose an analysed repository from the top bar to explore its architecture.`})}):t.model?(0,G.jsx)(`div`,{className:`h-[calc(100vh-8rem)] -m-6 flex flex-col`,children:(0,G.jsx)(dv,{model:t.model,source:t.source})}):(0,G.jsx)(`div`,{className:`h-full flex items-center justify-center`,children:(0,G.jsxs)(`div`,{className:`flex flex-col items-center gap-3`,children:[t.loading&&(0,G.jsxs)(G.Fragment,{children:[(0,G.jsx)(`div`,{className:`h-4 w-4 rounded-full border-2 border-primary border-t-transparent animate-spin`}),(0,G.jsx)(`span`,{className:`text-sm text-muted-foreground`,children:`Loading architecture model...`})]}),t.error&&(0,G.jsxs)(`div`,{className:`text-center`,children:[(0,G.jsx)(`p`,{className:`text-sm text-destructive mb-2`,children:t.error}),(0,G.jsx)(`button`,{onClick:t.retry,className:`text-xs text-primary hover:underline`,children:`Retry`})]})]})})}export{hv as ArchitecturePage}; \ No newline at end of file diff --git a/dist/assets/ArchitecturePage-DLioOiRN.css b/dist/assets/ArchitecturePage-DLioOiRN.css deleted file mode 100644 index bb05bccf..00000000 --- a/dist/assets/ArchitecturePage-DLioOiRN.css +++ /dev/null @@ -1 +0,0 @@ -.react-flow{--xy-edge-stroke-default:#b1b1b7;--xy-edge-stroke-width-default:1;--xy-edge-stroke-selected-default:#555;--xy-connectionline-stroke-default:#b1b1b7;--xy-connectionline-stroke-width-default:1;--xy-attribution-background-color-default:#ffffff80;--xy-minimap-background-color-default:#fff;--xy-minimap-mask-background-color-default:#f0f0f099;--xy-minimap-mask-stroke-color-default:transparent;--xy-minimap-mask-stroke-width-default:1;--xy-minimap-node-background-color-default:#e2e2e2;--xy-minimap-node-stroke-color-default:transparent;--xy-minimap-node-stroke-width-default:2;--xy-background-color-default:transparent;--xy-background-pattern-dots-color-default:#91919a;--xy-background-pattern-lines-color-default:#eee;--xy-background-pattern-cross-color-default:#e2e2e2;background-color:var(--xy-background-color,var(--xy-background-color-default));--xy-node-color-default:inherit;--xy-node-border-default:1px solid #1a192b;--xy-node-background-color-default:#fff;--xy-node-group-background-color-default:#f0f0f040;--xy-node-boxshadow-hover-default:0 1px 4px 1px #00000014;--xy-node-boxshadow-selected-default:0 0 0 .5px #1a192b;--xy-node-border-radius-default:3px;--xy-handle-background-color-default:#1a192b;--xy-handle-border-color-default:#fff;--xy-selection-background-color-default:#0059dc14;--xy-selection-border-default:1px dotted #0059dccc;--xy-controls-button-background-color-default:#fefefe;--xy-controls-button-background-color-hover-default:#f4f4f4;--xy-controls-button-color-default:inherit;--xy-controls-button-color-hover-default:inherit;--xy-controls-button-border-color-default:#eee;--xy-controls-box-shadow-default:0 0 2px 1px #00000014;--xy-edge-label-background-color-default:#fff;--xy-edge-label-color-default:inherit;--xy-resize-background-color-default:#3367d9;direction:ltr}.react-flow.dark{--xy-edge-stroke-default:#3e3e3e;--xy-edge-stroke-width-default:1;--xy-edge-stroke-selected-default:#727272;--xy-connectionline-stroke-default:#b1b1b7;--xy-connectionline-stroke-width-default:1;--xy-attribution-background-color-default:#96969640;--xy-minimap-background-color-default:#141414;--xy-minimap-mask-background-color-default:#3c3c3c99;--xy-minimap-mask-stroke-color-default:transparent;--xy-minimap-mask-stroke-width-default:1;--xy-minimap-node-background-color-default:#2b2b2b;--xy-minimap-node-stroke-color-default:transparent;--xy-minimap-node-stroke-width-default:2;--xy-background-color-default:#141414;--xy-background-pattern-dots-color-default:#555;--xy-background-pattern-lines-color-default:#333;--xy-background-pattern-cross-color-default:#333;--xy-node-color-default:#f8f8f8;--xy-node-border-default:1px solid #3c3c3c;--xy-node-background-color-default:#1e1e1e;--xy-node-group-background-color-default:#f0f0f040;--xy-node-boxshadow-hover-default:0 1px 4px 1px #ffffff14;--xy-node-boxshadow-selected-default:0 0 0 .5px #999;--xy-handle-background-color-default:#bebebe;--xy-handle-border-color-default:#1e1e1e;--xy-selection-background-color-default:#c8c8dc14;--xy-selection-border-default:1px dotted #c8c8dccc;--xy-controls-button-background-color-default:#2b2b2b;--xy-controls-button-background-color-hover-default:#3e3e3e;--xy-controls-button-color-default:#f8f8f8;--xy-controls-button-color-hover-default:#fff;--xy-controls-button-border-color-default:#5b5b5b;--xy-controls-box-shadow-default:0 0 2px 1px #00000014;--xy-edge-label-background-color-default:#141414;--xy-edge-label-color-default:#f8f8f8}.react-flow__background{background-color:var(--xy-background-color-props,var(--xy-background-color,var(--xy-background-color-default)));pointer-events:none;z-index:-1}.react-flow__container{width:100%;height:100%;position:absolute;top:0;left:0}.react-flow__pane{z-index:1;touch-action:none}.react-flow__pane.draggable{cursor:grab}.react-flow__pane.dragging{cursor:grabbing}.react-flow__pane.selection{cursor:pointer}.react-flow__viewport{transform-origin:0 0;z-index:2;pointer-events:none}.react-flow__renderer{z-index:4}.react-flow__selection{z-index:6}.react-flow__nodesselection-rect:focus,.react-flow__nodesselection-rect:focus-visible{outline:none}.react-flow__edge-path{stroke:var(--xy-edge-stroke,var(--xy-edge-stroke-default));stroke-width:var(--xy-edge-stroke-width,var(--xy-edge-stroke-width-default));fill:none}.react-flow__connection-path{stroke:var(--xy-connectionline-stroke,var(--xy-connectionline-stroke-default));stroke-width:var(--xy-connectionline-stroke-width,var(--xy-connectionline-stroke-width-default));fill:none}.react-flow .react-flow__edges{position:absolute}.react-flow .react-flow__edges svg{pointer-events:none;position:absolute;overflow:visible}.react-flow__edge{pointer-events:visibleStroke}.react-flow__edge.selectable{cursor:pointer}.react-flow__edge.animated path{stroke-dasharray:5;animation:.5s linear infinite dashdraw}.react-flow__edge.animated path.react-flow__edge-interaction{stroke-dasharray:none;animation:none}.react-flow__edge.inactive{pointer-events:none}.react-flow__edge.selected,.react-flow__edge:focus,.react-flow__edge:focus-visible{outline:none}.react-flow__edge.selected .react-flow__edge-path,.react-flow__edge.selectable:focus .react-flow__edge-path,.react-flow__edge.selectable:focus-visible .react-flow__edge-path{stroke:var(--xy-edge-stroke-selected,var(--xy-edge-stroke-selected-default))}.react-flow__edge-textwrapper{pointer-events:all}.react-flow__edge .react-flow__edge-text{pointer-events:none;-webkit-user-select:none;user-select:none}.react-flow__arrowhead polyline{stroke:var(--xy-edge-stroke,var(--xy-edge-stroke-default))}.react-flow__arrowhead polyline.arrowclosed{fill:var(--xy-edge-stroke,var(--xy-edge-stroke-default))}.react-flow__connection{pointer-events:none}.react-flow__connection .animated{stroke-dasharray:5;animation:.5s linear infinite dashdraw}svg.react-flow__connectionline{z-index:1001;position:absolute;overflow:visible}.react-flow__nodes{pointer-events:none;transform-origin:0 0}.react-flow__node{-webkit-user-select:none;user-select:none;pointer-events:all;transform-origin:0 0;box-sizing:border-box;cursor:default;position:absolute}.react-flow__node.selectable{cursor:pointer}.react-flow__node.draggable{cursor:grab;pointer-events:all}.react-flow__node.draggable.dragging{cursor:grabbing}.react-flow__nodesselection{z-index:3;transform-origin:0 0;pointer-events:none}.react-flow__nodesselection-rect{pointer-events:all;cursor:grab;position:absolute}.react-flow__handle{pointer-events:none;background-color:var(--xy-handle-background-color,var(--xy-handle-background-color-default));border:1px solid var(--xy-handle-border-color,var(--xy-handle-border-color-default));border-radius:100%;width:6px;min-width:5px;height:6px;min-height:5px;position:absolute}.react-flow__handle.connectingfrom{pointer-events:all}.react-flow__handle.connectionindicator{pointer-events:all;cursor:crosshair}.react-flow__handle-bottom{top:auto;bottom:0;left:50%;transform:translate(-50%,50%)}.react-flow__handle-top{top:0;left:50%;transform:translate(-50%,-50%)}.react-flow__handle-left{top:50%;left:0;transform:translate(-50%,-50%)}.react-flow__handle-right{top:50%;right:0;transform:translate(50%,-50%)}.react-flow__edgeupdater{cursor:move;pointer-events:all}.react-flow__pane.selection .react-flow__panel{pointer-events:none}.react-flow__panel{z-index:5;margin:15px;position:absolute}.react-flow__panel.top{top:0}.react-flow__panel.bottom{bottom:0}.react-flow__panel.top.center,.react-flow__panel.bottom.center{left:50%;transform:translate(-15px)translate(-50%)}.react-flow__panel.left{left:0}.react-flow__panel.right{right:0}.react-flow__panel.left.center,.react-flow__panel.right.center{top:50%;transform:translateY(-15px)translateY(-50%)}.react-flow__attribution{background:var(--xy-attribution-background-color,var(--xy-attribution-background-color-default));margin:0;padding:2px 3px;font-size:10px}.react-flow__attribution a{color:#999;text-decoration:none}@keyframes dashdraw{0%{stroke-dashoffset:10px}}.react-flow__edgelabel-renderer{pointer-events:none;-webkit-user-select:none;user-select:none;width:100%;height:100%;position:absolute;top:0;left:0}.react-flow__viewport-portal{-webkit-user-select:none;user-select:none;width:100%;height:100%;position:absolute;top:0;left:0}.react-flow__minimap{background:var(--xy-minimap-background-color-props,var(--xy-minimap-background-color,var(--xy-minimap-background-color-default)))}.react-flow__minimap-svg{display:block}.react-flow__minimap-mask{fill:var(--xy-minimap-mask-background-color-props,var(--xy-minimap-mask-background-color,var(--xy-minimap-mask-background-color-default)));stroke:var(--xy-minimap-mask-stroke-color-props,var(--xy-minimap-mask-stroke-color,var(--xy-minimap-mask-stroke-color-default)));stroke-width:var(--xy-minimap-mask-stroke-width-props,var(--xy-minimap-mask-stroke-width,var(--xy-minimap-mask-stroke-width-default)))}.react-flow__minimap-node{fill:var(--xy-minimap-node-background-color-props,var(--xy-minimap-node-background-color,var(--xy-minimap-node-background-color-default)));stroke:var(--xy-minimap-node-stroke-color-props,var(--xy-minimap-node-stroke-color,var(--xy-minimap-node-stroke-color-default)));stroke-width:var(--xy-minimap-node-stroke-width-props,var(--xy-minimap-node-stroke-width,var(--xy-minimap-node-stroke-width-default)))}.react-flow__background-pattern.dots{fill:var(--xy-background-pattern-color-props,var(--xy-background-pattern-color,var(--xy-background-pattern-dots-color-default)))}.react-flow__background-pattern.lines{stroke:var(--xy-background-pattern-color-props,var(--xy-background-pattern-color,var(--xy-background-pattern-lines-color-default)))}.react-flow__background-pattern.cross{stroke:var(--xy-background-pattern-color-props,var(--xy-background-pattern-color,var(--xy-background-pattern-cross-color-default)))}.react-flow__controls{box-shadow:var(--xy-controls-box-shadow,var(--xy-controls-box-shadow-default));flex-direction:column;display:flex}.react-flow__controls.horizontal{flex-direction:row}.react-flow__controls-button{background:var(--xy-controls-button-background-color,var(--xy-controls-button-background-color-default));border:none;border-bottom:1px solid var(--xy-controls-button-border-color-props,var(--xy-controls-button-border-color,var(--xy-controls-button-border-color-default)));width:26px;height:26px;color:var(--xy-controls-button-color-props,var(--xy-controls-button-color,var(--xy-controls-button-color-default)));cursor:pointer;-webkit-user-select:none;user-select:none;justify-content:center;align-items:center;padding:4px;display:flex}.react-flow__controls-button svg{fill:currentColor;width:100%;max-width:12px;max-height:12px}.react-flow__edge.updating .react-flow__edge-path{stroke:#777}.react-flow__edge-text{font-size:10px}.react-flow__node.selectable:focus,.react-flow__node.selectable:focus-visible{outline:none}.react-flow__node-input,.react-flow__node-default,.react-flow__node-output,.react-flow__node-group{border-radius:var(--xy-node-border-radius,var(--xy-node-border-radius-default));width:150px;color:var(--xy-node-color,var(--xy-node-color-default));text-align:center;border:var(--xy-node-border,var(--xy-node-border-default));background-color:var(--xy-node-background-color,var(--xy-node-background-color-default));padding:10px;font-size:12px}.react-flow__node-input.selectable:hover,.react-flow__node-default.selectable:hover,.react-flow__node-output.selectable:hover,.react-flow__node-group.selectable:hover{box-shadow:var(--xy-node-boxshadow-hover,var(--xy-node-boxshadow-hover-default))}.react-flow__node-input.selectable.selected,.react-flow__node-input.selectable:focus,.react-flow__node-input.selectable:focus-visible,.react-flow__node-default.selectable.selected,.react-flow__node-default.selectable:focus,.react-flow__node-default.selectable:focus-visible,.react-flow__node-output.selectable.selected,.react-flow__node-output.selectable:focus,.react-flow__node-output.selectable:focus-visible,.react-flow__node-group.selectable.selected,.react-flow__node-group.selectable:focus,.react-flow__node-group.selectable:focus-visible{box-shadow:var(--xy-node-boxshadow-selected,var(--xy-node-boxshadow-selected-default))}.react-flow__node-group{background-color:var(--xy-node-group-background-color,var(--xy-node-group-background-color-default))}.react-flow__nodesselection-rect,.react-flow__selection{background:var(--xy-selection-background-color,var(--xy-selection-background-color-default));border:var(--xy-selection-border,var(--xy-selection-border-default))}.react-flow__nodesselection-rect:focus,.react-flow__nodesselection-rect:focus-visible,.react-flow__selection:focus,.react-flow__selection:focus-visible{outline:none}.react-flow__controls-button:hover{background:var(--xy-controls-button-background-color-hover-props,var(--xy-controls-button-background-color-hover,var(--xy-controls-button-background-color-hover-default)));color:var(--xy-controls-button-color-hover-props,var(--xy-controls-button-color-hover,var(--xy-controls-button-color-hover-default)))}.react-flow__controls-button:disabled{pointer-events:none}.react-flow__controls-button:disabled svg{fill-opacity:.4}.react-flow__controls-button:last-child{border-bottom:none}.react-flow__controls.horizontal .react-flow__controls-button{border-bottom:none;border-right:1px solid var(--xy-controls-button-border-color-props,var(--xy-controls-button-border-color,var(--xy-controls-button-border-color-default)))}.react-flow__controls.horizontal .react-flow__controls-button:last-child{border-right:none}.react-flow__resize-control{position:absolute}.react-flow__resize-control.left,.react-flow__resize-control.right{cursor:ew-resize}.react-flow__resize-control.top,.react-flow__resize-control.bottom{cursor:ns-resize}.react-flow__resize-control.top.left,.react-flow__resize-control.bottom.right{cursor:nwse-resize}.react-flow__resize-control.bottom.left,.react-flow__resize-control.top.right{cursor:nesw-resize}.react-flow__resize-control.handle{background-color:var(--xy-resize-background-color,var(--xy-resize-background-color-default));border:1px solid #fff;border-radius:1px;width:5px;height:5px;translate:-50% -50%}.react-flow__resize-control.handle.left{top:50%;left:0}.react-flow__resize-control.handle.right{top:50%;left:100%}.react-flow__resize-control.handle.top{top:0;left:50%}.react-flow__resize-control.handle.bottom{top:100%;left:50%}.react-flow__resize-control.handle.top.left,.react-flow__resize-control.handle.bottom.left{left:0}.react-flow__resize-control.handle.top.right,.react-flow__resize-control.handle.bottom.right{left:100%}.react-flow__resize-control.line{border-color:var(--xy-resize-background-color,var(--xy-resize-background-color-default));border-style:solid;border-width:0}.react-flow__resize-control.line.left,.react-flow__resize-control.line.right{width:1px;height:100%;top:0;transform:translate(-50%)}.react-flow__resize-control.line.left{border-left-width:1px;left:0}.react-flow__resize-control.line.right{border-right-width:1px;left:100%}.react-flow__resize-control.line.top,.react-flow__resize-control.line.bottom{width:100%;height:1px;left:0;transform:translateY(-50%)}.react-flow__resize-control.line.top{border-top-width:1px;top:0}.react-flow__resize-control.line.bottom{border-bottom-width:1px;top:100%}.react-flow__edge-textbg{fill:var(--xy-edge-label-background-color,var(--xy-edge-label-background-color-default))}.react-flow__edge-text{fill:var(--xy-edge-label-color,var(--xy-edge-label-color-default))} diff --git a/dist/assets/DashboardPage-B7P5IANl.js b/dist/assets/DashboardPage-B7P5IANl.js deleted file mode 100644 index 1744929b..00000000 --- a/dist/assets/DashboardPage-B7P5IANl.js +++ /dev/null @@ -1 +0,0 @@ -import{a as e,c as t,o as n,p as r,s as i}from"./client-S4ekmpXx.js";import{t as a}from"./activity-BtAw6juF.js";import{t as o}from"./clock-qFz1Yxfz.js";import{C as s,E as c,_ as l,h as u,m as d,s as f,t as p}from"./index-QB2QUwKm.js";import{t as m}from"./PageHeader-DsNWbEI1.js";import{t as h}from"./EmptyState-BeMA8Ikt.js";import{n as g,t as _}from"./DataSourceBadge-D7dhrb9n.js";import{t as v}from"./status-5ylMtKLG.js";var y=i();function b({label:t,value:n,icon:r,change:i,className:a}){return(0,y.jsxs)(`div`,{className:e(`rounded-xl border border-border bg-card p-4`,a),children:[(0,y.jsxs)(`div`,{className:`flex items-center justify-between mb-3`,children:[(0,y.jsx)(`span`,{className:`text-xs font-medium text-muted-foreground uppercase tracking-wider`,children:t}),(0,y.jsx)(r,{className:`h-4 w-4 text-muted-foreground`})]}),(0,y.jsxs)(`div`,{className:`flex items-end gap-2`,children:[(0,y.jsx)(`span`,{className:`text-2xl font-semibold text-foreground`,children:n}),i&&(0,y.jsx)(`span`,{className:`text-xs text-success mb-0.5`,children:i})]})]})}var x=r(t(),1);function S(){let e=p(),t=(0,x.useMemo)(()=>{let t=e.repositories;return{totalRepositories:t.length,completedRepositories:t.filter(e=>e.status===`completed`).length,totalFiles:t.reduce((e,t)=>e+t.fileCount,0),totalSize:t.reduce((e,t)=>e+t.size,0)}},[e.repositories]);return{...e,metrics:t}}function C(){let e=c(),{repositories:t,metrics:r,selectRepository:i}=S();return t.length===0?(0,y.jsxs)(`div`,{children:[(0,y.jsx)(m,{title:`Dashboard`,description:`Your repository intelligence overview`}),(0,y.jsx)(h,{icon:d,title:`Welcome to PARTHA`,description:`Upload your first repository to start understanding any codebase in minutes. Get architecture insights, dependency graphs, and AI-powered explanations.`,action:{label:`Upload Repository`,onClick:()=>e(`/upload`)}})]}):(0,y.jsxs)(`div`,{children:[(0,y.jsx)(m,{title:`Dashboard`,description:`Your repository intelligence overview`,children:(0,y.jsxs)(`button`,{onClick:()=>e(`/upload`),className:`flex items-center gap-2 rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors`,children:[(0,y.jsx)(f,{className:`h-3.5 w-3.5`}),`Upload`]})}),(0,y.jsxs)(`div`,{className:`grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-8`,children:[(0,y.jsx)(b,{label:`Repositories`,value:r.totalRepositories,icon:l}),(0,y.jsx)(b,{label:`Analysed`,value:r.completedRepositories,icon:a}),(0,y.jsx)(b,{label:`Total Files`,value:r.totalFiles,icon:d}),(0,y.jsx)(b,{label:`Total Size`,value:n(r.totalSize),icon:f})]}),(0,y.jsxs)(`div`,{className:`rounded-xl border border-border bg-card overflow-hidden`,children:[(0,y.jsx)(`div`,{className:`px-5 py-4 border-b border-border`,children:(0,y.jsx)(`h2`,{className:`text-sm font-medium text-foreground`,children:`Repositories`})}),(0,y.jsx)(`div`,{className:`divide-y divide-border`,children:t.map((t,n)=>(0,y.jsxs)(s.div,{initial:{opacity:0,y:4},animate:{opacity:1,y:0},transition:{delay:n*.05},onClick:()=>{i(t),t.status===`analysing`?e(`/analysis/${t.id}`):e(`/repositories/${t.id}`)},className:`flex items-center justify-between px-5 py-3.5 hover:bg-accent/30 cursor-pointer transition-colors`,children:[(0,y.jsxs)(`div`,{className:`flex items-center gap-3`,children:[(0,y.jsx)(`div`,{className:`flex h-9 w-9 items-center justify-center rounded-lg bg-muted`,children:t.source===`github`?(0,y.jsx)(u,{className:`h-4 w-4 text-muted-foreground`}):(0,y.jsx)(l,{className:`h-4 w-4 text-muted-foreground`})}),(0,y.jsxs)(`div`,{children:[(0,y.jsx)(`p`,{className:`text-sm font-medium text-foreground`,children:t.name}),(0,y.jsxs)(`div`,{className:`flex items-center gap-2 mt-0.5`,children:[t.meta?.language&&(0,y.jsx)(`span`,{className:`text-xs text-muted-foreground`,children:t.meta.language}),t.meta?.framework&&(0,y.jsxs)(`span`,{className:`text-xs text-muted-foreground`,children:[`/ `,t.meta.framework]}),!t.meta&&(0,y.jsxs)(`span`,{className:`flex items-center gap-1 text-xs text-muted-foreground`,children:[(0,y.jsx)(o,{className:`h-3 w-3`}),new Date(t.uploadedAt).toLocaleDateString()]})]})]})]}),(0,y.jsxs)(`div`,{className:`flex items-center gap-3`,children:[(0,y.jsx)(_,{source:t.dataSource}),t.meta&&(0,y.jsxs)(`span`,{className:`text-xs text-muted-foreground hidden sm:inline`,children:[t.meta.totalFiles,` files`]}),(0,y.jsx)(g,{variant:v[t.status],children:t.status})]})]},t.id))})]})]})}export{C as DashboardPage}; \ No newline at end of file diff --git a/dist/assets/DataSourceBadge-D7dhrb9n.js b/dist/assets/DataSourceBadge-D7dhrb9n.js deleted file mode 100644 index 528a7d69..00000000 --- a/dist/assets/DataSourceBadge-D7dhrb9n.js +++ /dev/null @@ -1 +0,0 @@ -import{a as e,s as t}from"./client-S4ekmpXx.js";var n=t(),r={default:`bg-muted text-muted-foreground`,success:`bg-success/10 text-success`,warning:`bg-warning/10 text-warning`,error:`bg-destructive/10 text-destructive`,info:`bg-primary/10 text-primary`};function i({children:t,variant:i=`default`,className:a}){return(0,n.jsx)(`span`,{className:e(`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium`,r[i],a),children:t})}function a({source:e}){return e?(0,n.jsx)(i,{variant:`success`,children:`Real data`}):null}export{i as n,a as t}; \ No newline at end of file diff --git a/dist/assets/DependenciesPage-L38XGsJu.js b/dist/assets/DependenciesPage-L38XGsJu.js deleted file mode 100644 index 1f73ba0b..00000000 --- a/dist/assets/DependenciesPage-L38XGsJu.js +++ /dev/null @@ -1,2 +0,0 @@ -import{c as e,i as t,p as n,s as r}from"./client-S4ekmpXx.js";import{t as i}from"./download-BM44DUO2.js";import{t as a}from"./package-Dhq59A-j.js";import{E as o,g as s,n as c,u as l}from"./index-QB2QUwKm.js";import{t as u}from"./PageHeader-DsNWbEI1.js";import{t as d}from"./EmptyState-BeMA8Ikt.js";import{t as f}from"./DataSourceBadge-D7dhrb9n.js";import{t as p}from"./useRepositoryFeatureStatus-BAj4lrBf.js";var m=n(e(),1);function h(){let e=p(),[n,r]=(0,m.useState)(null),[i,a]=(0,m.useState)(!1),[o,s]=(0,m.useState)(null),[l,u]=(0,m.useState)(0),d=(0,m.useCallback)(()=>{s(null),u(e=>e+1)},[]);return(0,m.useEffect)(()=>{if(!e.activeRepository||e.activeRepository.status!==`completed`){r(null),s(null);return}let n=!1;async function i(){if(e.activeRepository){a(!0),s(null);try{let t=await c.fetchDependencyGraph(e.activeRepository.id);n||r(t)}catch(e){n||s(t(e))}finally{n||a(!1)}}}return i(),()=>{n=!0}},[l,e.activeRepository]),{...e,graph:n,loading:e.loading||i,error:e.error||o,retry:d,refresh:d,packageManager:e.activeRepository?.meta?.packageManager||`npm`}}var g=r();function _(){let e=o(),t=h(),n=t.activeRepository,[r,c]=(0,m.useState)(``),p=(0,m.useMemo)(()=>{let e=r.trim().toLowerCase(),n=t.graph?.nodes||[];return e?n.filter(t=>t.name.toLowerCase().includes(e)||t.type.toLowerCase().includes(e)):n},[t.graph?.nodes,r]);return t.emptyReason===`no-completed-repositories`?(0,g.jsxs)(`div`,{children:[(0,g.jsx)(u,{title:`Dependency Graph`,description:`Explore the dependency relationships within your codebase`}),(0,g.jsx)(d,{icon:s,title:`No dependency data`,description:`Upload and analyse a repository to view its dependency graph. Dependencies are extracted during the analysis pipeline.`,action:{label:`Upload Repository`,onClick:()=>e(`/upload`)}})]}):t.emptyReason===`no-active-repository`||!n?(0,g.jsxs)(`div`,{children:[(0,g.jsx)(u,{title:`Dependency Graph`,description:`Explore the dependency relationships within your codebase`}),(0,g.jsx)(d,{icon:s,title:`Select a repository`,description:`Choose an analysed repository from the top bar to explore its dependencies.`})]}):t.loading?(0,g.jsxs)(`div`,{children:[(0,g.jsx)(u,{title:`Dependency Graph`,description:`Dependencies for ${n.name}`,children:(0,g.jsx)(f,{source:t.source})}),(0,g.jsx)(`div`,{className:`rounded-xl border border-border bg-card p-8 text-sm text-muted-foreground`,children:`Loading dependency graph...`})]}):t.error?(0,g.jsxs)(`div`,{children:[(0,g.jsx)(u,{title:`Dependency Graph`,description:`Dependencies for ${n.name}`,children:(0,g.jsx)(f,{source:t.source})}),(0,g.jsxs)(`div`,{className:`rounded-xl border border-destructive/50 bg-destructive/5 p-5`,children:[(0,g.jsx)(`p`,{className:`text-sm text-destructive`,children:t.error}),(0,g.jsx)(`button`,{onClick:t.retry,className:`mt-3 text-xs text-primary hover:underline`,children:`Retry`})]})]}):(0,g.jsxs)(`div`,{children:[(0,g.jsx)(u,{title:`Dependency Graph`,description:`Dependencies for ${n.name}`,children:(0,g.jsx)(f,{source:t.source})}),(0,g.jsxs)(`div`,{className:`grid grid-cols-1 sm:grid-cols-4 gap-4 mb-6`,children:[(0,g.jsx)(v,{label:`Dependencies`,value:t.graph?.totalDependencies??0}),(0,g.jsx)(v,{label:`Relations`,value:t.graph?.edges.length??0}),(0,g.jsx)(v,{label:`Vulnerable`,value:t.graph?.vulnerabilities??0}),(0,g.jsx)(v,{label:`Outdated`,value:t.graph?.outdated??0})]}),(0,g.jsxs)(`div`,{className:`rounded-xl border border-border bg-card overflow-hidden`,children:[(0,g.jsxs)(`div`,{className:`flex flex-col sm:flex-row sm:items-center justify-between gap-3 border-b border-border px-4 py-3`,children:[(0,g.jsxs)(`div`,{className:`relative max-w-sm flex-1`,children:[(0,g.jsx)(l,{className:`absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground`}),(0,g.jsx)(`input`,{value:r,onChange:e=>c(e.target.value),placeholder:`Search dependencies...`,className:`w-full rounded-md border border-border bg-background pl-8 pr-3 py-1.5 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-ring`})]}),(0,g.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,g.jsxs)(`button`,{onClick:()=>{!t.graph||!n||y(`${n.name}-dependencies.json`,JSON.stringify(t.graph,null,2),`application/json`)},className:`flex items-center gap-2 rounded-md border border-border px-3 py-1.5 text-xs text-foreground hover:bg-accent transition-colors`,children:[(0,g.jsx)(i,{className:`h-3.5 w-3.5`}),` JSON`]}),(0,g.jsxs)(`button`,{onClick:()=>{if(!t.graph||!n)return;let e=[`# ${n.name} Dependencies`,``,`Total dependencies: ${t.graph.totalDependencies}`,``,...t.graph.nodes.map(e=>`- ${e.name} ${e.version} (${e.type})`)];y(`${n.name}-dependencies.md`,e.join(` -`),`text/markdown`)},className:`flex items-center gap-2 rounded-md border border-border px-3 py-1.5 text-xs text-foreground hover:bg-accent transition-colors`,children:[(0,g.jsx)(i,{className:`h-3.5 w-3.5`}),` Markdown`]})]})]}),p.length===0?(0,g.jsxs)(`div`,{className:`p-8 text-center`,children:[(0,g.jsx)(s,{className:`h-8 w-8 text-muted-foreground mx-auto mb-3`}),(0,g.jsx)(`p`,{className:`text-sm text-muted-foreground`,children:`No dependencies matched your search.`})]}):(0,g.jsx)(`div`,{className:`divide-y divide-border`,children:p.map(e=>(0,g.jsxs)(`div`,{className:`flex items-center justify-between px-4 py-3`,children:[(0,g.jsxs)(`div`,{className:`flex items-center gap-3 min-w-0`,children:[(0,g.jsx)(`div`,{className:`flex h-8 w-8 items-center justify-center rounded-md bg-muted`,children:(0,g.jsx)(a,{className:`h-4 w-4 text-muted-foreground`})}),(0,g.jsxs)(`div`,{className:`min-w-0`,children:[(0,g.jsx)(`p`,{className:`text-sm font-medium text-foreground truncate`,children:e.name}),(0,g.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:e.version||`unknown version`})]})]}),(0,g.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,g.jsx)(`span`,{className:`rounded-md bg-muted px-2 py-0.5 text-xs text-muted-foreground capitalize`,children:e.type}),e.hasVulnerabilities&&(0,g.jsx)(`span`,{className:`rounded-md bg-destructive/10 px-2 py-0.5 text-xs text-destructive`,children:`vulnerable`}),e.isOutdated&&(0,g.jsx)(`span`,{className:`rounded-md bg-warning/10 px-2 py-0.5 text-xs text-warning`,children:`outdated`})]})]},e.id))}),(0,g.jsxs)(`div`,{className:`border-t border-border px-4 py-3 text-xs text-muted-foreground`,children:[t.packageManager,` detected. Dependency relationships are generated from backend package manifests.`]})]})]})}function v({label:e,value:t}){return(0,g.jsxs)(`div`,{className:`rounded-xl border border-border bg-card p-4`,children:[(0,g.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:e}),(0,g.jsx)(`p`,{className:`mt-1 text-2xl font-semibold text-foreground`,children:t})]})}function y(e,t,n){let r=new Blob([t],{type:n}),i=URL.createObjectURL(r),a=document.createElement(`a`);a.href=i,a.download=e,a.click(),URL.revokeObjectURL(i)}export{_ as DependenciesPage}; \ No newline at end of file diff --git a/dist/assets/DocumentationPage-CP6VAto-.js b/dist/assets/DocumentationPage-CP6VAto-.js deleted file mode 100644 index 4f1354c6..00000000 --- a/dist/assets/DocumentationPage-CP6VAto-.js +++ /dev/null @@ -1 +0,0 @@ -import{c as e,i as t,p as n,s as r,t as i}from"./client-S4ekmpXx.js";import{t as a}from"./download-BM44DUO2.js";import{E as o,S as s,v as c}from"./index-QB2QUwKm.js";import{t as l}from"./PageHeader-DsNWbEI1.js";import{t as u}from"./EmptyState-BeMA8Ikt.js";import{t as d}from"./DataSourceBadge-D7dhrb9n.js";import{t as f}from"./useRepositoryFeatureStatus-BAj4lrBf.js";var p=s(`RefreshCw`,[[`path`,{d:`M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8`,key:`v9h5vc`}],[`path`,{d:`M21 3v5h-5`,key:`1q7to0`}],[`path`,{d:`M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16`,key:`3uifl3`}],[`path`,{d:`M8 16H3v5`,key:`1cv678`}]]),m={generate(e,t){return i.post(`/documentation/generate`,e,t)}},h=n(e(),1);function g(){let e=f(),[n,r]=(0,h.useState)(null),[i,a]=(0,h.useState)([`overview`,`architecture`,`folder-structure`,`api`,`environment`,`deployment`,`contribution`]),[o,s]=(0,h.useState)(`markdown`),[c,l]=(0,h.useState)(!1),[u,d]=(0,h.useState)(null),[p,g]=(0,h.useState)(0),_=(0,h.useCallback)(()=>{d(null),g(e=>e+1)},[]),v=(0,h.useCallback)(e=>{a(t=>t.includes(e)?t.filter(t=>t!==e):[...t,e])},[]);return(0,h.useEffect)(()=>{if(!e.activeRepository||e.activeRepository.status!==`completed`){r(null),d(null);return}let n=!1;async function a(){if(e.activeRepository){l(!0),d(null);try{let t=await m.generate({repositoryId:e.activeRepository.id,format:o,sections:i});n||r(t)}catch(e){n||d(t(e))}finally{n||l(!1)}}}return a(),()=>{n=!0}},[o,p,i,e.activeRepository]),{...e,document:n,sections:i,format:o,setFormat:s,toggleSection:v,loading:e.loading||c,error:e.error||u,retry:_,refresh:_}}var _=r();function v(){let e=o(),t=g(),n=t.activeRepository;return t.emptyReason===`no-completed-repositories`?(0,_.jsxs)(`div`,{children:[(0,_.jsx)(l,{title:`Documentation`,description:`Auto-generated documentation from your codebase`}),(0,_.jsx)(u,{icon:c,title:`No documentation generated`,description:`Upload and analyse a repository first. Documentation generation requires a completed analysis pipeline.`,action:{label:`Upload Repository`,onClick:()=>e(`/upload`)}})]}):t.emptyReason===`no-active-repository`||!n?(0,_.jsxs)(`div`,{children:[(0,_.jsx)(l,{title:`Documentation`,description:`Auto-generated documentation from your codebase`}),(0,_.jsx)(u,{icon:c,title:`Select a repository`,description:`Choose an analysed repository from the top bar to view generated documentation.`})]}):(0,_.jsxs)(`div`,{children:[(0,_.jsx)(l,{title:`Documentation`,description:`Documentation for ${n.name}`,children:(0,_.jsx)(d,{source:t.source})}),(0,_.jsxs)(`div`,{className:`rounded-xl border border-border bg-card overflow-hidden`,children:[(0,_.jsxs)(`div`,{className:`flex flex-col gap-3 border-b border-border px-4 py-3`,children:[(0,_.jsxs)(`div`,{className:`flex flex-col sm:flex-row sm:items-center justify-between gap-3`,children:[(0,_.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,_.jsx)(`button`,{onClick:()=>t.setFormat(`markdown`),className:`rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${t.format===`markdown`?`bg-primary text-primary-foreground`:`bg-muted text-muted-foreground hover:text-foreground`}`,children:`Markdown`}),(0,_.jsx)(`button`,{onClick:()=>t.setFormat(`html`),className:`rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${t.format===`html`?`bg-primary text-primary-foreground`:`bg-muted text-muted-foreground hover:text-foreground`}`,children:`HTML`})]}),(0,_.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,_.jsxs)(`button`,{onClick:t.refresh,className:`flex items-center gap-2 rounded-md border border-border px-3 py-1.5 text-xs text-foreground hover:bg-accent transition-colors`,children:[(0,_.jsx)(p,{className:`h-3.5 w-3.5`}),` Regenerate`]}),(0,_.jsxs)(`button`,{onClick:()=>{if(!t.document||!n)return;let e=t.document.format===`html`?`html`:`md`,r=t.document.format===`html`?`text/html`:`text/markdown`,i=new Blob([t.document.content],{type:r}),a=URL.createObjectURL(i),o=document.createElement(`a`);o.href=a,o.download=`${n.name}-documentation.${e}`,o.click(),URL.revokeObjectURL(a)},disabled:!t.document,className:`flex items-center gap-2 rounded-md border border-border px-3 py-1.5 text-xs text-foreground hover:bg-accent disabled:opacity-50 disabled:cursor-not-allowed transition-colors`,children:[(0,_.jsx)(a,{className:`h-3.5 w-3.5`}),` Export`]})]})]}),(0,_.jsx)(`div`,{className:`flex flex-wrap gap-2`,children:[[`overview`,`Overview`],[`architecture`,`Architecture`],[`folder-structure`,`Folder structure`],[`api`,`API`],[`environment`,`Environment`],[`deployment`,`Deployment`],[`contribution`,`Contribution`]].map(([e,n])=>(0,_.jsx)(`button`,{onClick:()=>t.toggleSection(e),className:`rounded-md border px-2.5 py-1 text-xs transition-colors ${t.sections.includes(e)?`border-primary bg-primary/10 text-primary`:`border-border text-muted-foreground hover:text-foreground`}`,children:n},e))})]}),t.loading?(0,_.jsx)(`div`,{className:`p-8 text-sm text-muted-foreground`,children:`Generating documentation...`}):t.error?(0,_.jsxs)(`div`,{className:`p-8`,children:[(0,_.jsx)(`p`,{className:`text-sm text-destructive`,children:t.error}),(0,_.jsx)(`button`,{onClick:t.retry,className:`mt-3 text-xs text-primary hover:underline`,children:`Retry`})]}):(0,_.jsx)(`pre`,{className:`max-h-[620px] overflow-auto whitespace-pre-wrap p-5 text-sm leading-6 text-foreground font-mono scrollbar-thin`,children:t.document?.content||`No documentation generated.`})]})]})}export{v as DocumentationPage}; \ No newline at end of file diff --git a/dist/assets/EmptyState-BeMA8Ikt.js b/dist/assets/EmptyState-BeMA8Ikt.js deleted file mode 100644 index a96c56e7..00000000 --- a/dist/assets/EmptyState-BeMA8Ikt.js +++ /dev/null @@ -1 +0,0 @@ -import{a as e,s as t}from"./client-S4ekmpXx.js";import{C as n}from"./index-QB2QUwKm.js";var r=t();function i({icon:t,title:i,description:a,action:o,className:s}){return(0,r.jsxs)(n.div,{initial:{opacity:0,y:12},animate:{opacity:1,y:0},transition:{duration:.4},className:e(`flex flex-col items-center justify-center py-16 px-4`,s),children:[(0,r.jsx)(`div`,{className:`flex h-16 w-16 items-center justify-center rounded-2xl bg-muted mb-4`,children:(0,r.jsx)(t,{className:`h-7 w-7 text-muted-foreground`})}),(0,r.jsx)(`h3`,{className:`text-lg font-semibold text-foreground mb-1`,children:i}),(0,r.jsx)(`p`,{className:`text-sm text-muted-foreground text-center max-w-sm mb-6`,children:a}),o&&(0,r.jsx)(`button`,{onClick:o.onClick,className:`rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors`,children:o.label})]})}export{i as t}; \ No newline at end of file diff --git a/dist/assets/EngineeringReviewPage-BpcXtZzj.js b/dist/assets/EngineeringReviewPage-BpcXtZzj.js deleted file mode 100644 index 4d3841c6..00000000 --- a/dist/assets/EngineeringReviewPage-BpcXtZzj.js +++ /dev/null @@ -1,2 +0,0 @@ -import{a as e,c as t,i as n,p as r,s as i}from"./client-S4ekmpXx.js";import{t as a}from"./arrow-right-DpUoK8Nu.js";import{i as o,n as s,r as c,t as l}from"./zap-8B5FG3kN.js";import{t as u}from"./circle-alert-BUYmyF7c.js";import{t as d}from"./clock-qFz1Yxfz.js";import{t as f}from"./download-BM44DUO2.js";import{t as p}from"./file-code-o79iDh0J.js";import{t as m}from"./x-Bf4UPE8b.js";import{C as h,E as g,S as _,c as v,i as y,n as b,t as x,w as S}from"./index-QB2QUwKm.js";import{t as C}from"./PageHeader-DsNWbEI1.js";import{t as w}from"./EmptyState-BeMA8Ikt.js";import{t as T}from"./DataSourceBadge-D7dhrb9n.js";var E=_(`Brain`,[[`path`,{d:`M12 5a3 3 0 1 0-5.997.125 4 4 0 0 0-2.526 5.77 4 4 0 0 0 .556 6.588A4 4 0 1 0 12 18Z`,key:`l5xja`}],[`path`,{d:`M12 5a3 3 0 1 1 5.997.125 4 4 0 0 1 2.526 5.77 4 4 0 0 1-.556 6.588A4 4 0 1 1 12 18Z`,key:`ep3f8r`}],[`path`,{d:`M15 13a4.5 4.5 0 0 1-3-4 4.5 4.5 0 0 1-3 4`,key:`1p4c4q`}],[`path`,{d:`M17.599 6.5a3 3 0 0 0 .399-1.375`,key:`tmeiqw`}],[`path`,{d:`M6.003 5.125A3 3 0 0 0 6.401 6.5`,key:`105sqy`}],[`path`,{d:`M3.477 10.896a4 4 0 0 1 .585-.396`,key:`ql3yin`}],[`path`,{d:`M19.938 10.5a4 4 0 0 1 .585.396`,key:`1qfode`}],[`path`,{d:`M6 18a4 4 0 0 1-1.967-.516`,key:`2e4loj`}],[`path`,{d:`M19.967 17.484A4 4 0 0 1 18 18`,key:`159ez6`}]]),D=_(`CircleCheck`,[[`circle`,{cx:`12`,cy:`12`,r:`10`,key:`1mglay`}],[`path`,{d:`m9 12 2 2 4-4`,key:`dzmm74`}]]),O=_(`Flag`,[[`path`,{d:`M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z`,key:`i9b6wo`}],[`line`,{x1:`4`,x2:`4`,y1:`22`,y2:`15`,key:`1cm3nv`}]]),k=_(`Info`,[[`circle`,{cx:`12`,cy:`12`,r:`10`,key:`1mglay`}],[`path`,{d:`M12 16v-4`,key:`1dtifu`}],[`path`,{d:`M12 8h.01`,key:`e9boi3`}]]),A=_(`Minus`,[[`path`,{d:`M5 12h14`,key:`1ays0h`}]]),j=_(`TrendingDown`,[[`polyline`,{points:`22 17 13.5 8.5 8.5 13.5 2 7`,key:`1r2t7k`}],[`polyline`,{points:`16 17 22 17 22 11`,key:`11uiuu`}]]),M=_(`TrendingUp`,[[`polyline`,{points:`22 7 13.5 15.5 8.5 10.5 2 17`,key:`126l90`}],[`polyline`,{points:`16 7 22 7 22 13`,key:`kwv8wd`}]]),N=r(t(),1),P=y((e,t)=>({review:null,setReview:t=>e({review:t}),selectedFindingId:null,setSelectedFindingId:t=>e({selectedFindingId:t}),filterCategory:`all`,setFilterCategory:t=>e({filterCategory:t}),filterSeverity:`all`,setFilterSeverity:t=>e({filterSeverity:t}),filterStatus:`all`,setFilterStatus:t=>e({filterStatus:t}),filteredFindings:()=>{let e=t();if(!e.review)return[];let n=e.review.findings;return e.filterCategory!==`all`&&(n=n.filter(t=>t.category===e.filterCategory)),e.filterSeverity!==`all`&&(n=n.filter(t=>t.severity===e.filterSeverity)),e.filterStatus!==`all`&&(n=n.filter(t=>t.status===e.filterStatus)),n}}));function F(){let{activeRepository:e,completedRepositories:t}=x(),{review:r,setReview:i}=P(),[a,o]=(0,N.useState)(r),[s,c]=(0,N.useState)(null),[l,u]=(0,N.useState)(`idle`),[d,f]=(0,N.useState)(null),[p,m]=(0,N.useState)(0),h=(0,N.useCallback)(()=>m(e=>e+1),[]);(0,N.useEffect)(()=>{if(t.length===0){u(`empty`),o(null),c(null),f(null);return}if(!e||e.status!==`completed`){u(`empty`),o(null),c(null),f(null);return}let r=!1;async function a(){if(e){u(`loading`),f(null);try{let t=await b.fetchReview(e);if(r)return;o(t),i(t),c(`real`),u(`success`)}catch(e){if(r)return;o(null),c(null),f(n(e)),u(`error`)}}}return a(),()=>{r=!0}},[e,t.length,p,i]);let g=l===`empty`?t.length===0?`no-completed-repositories`:`no-active-repository`:null;return{review:a,data:a,source:s,status:l,loading:l===`loading`,error:d,empty:l===`empty`,success:l===`success`,retry:h,refresh:h,activeRepository:e,completedRepositories:t,emptyReason:g,usingMockData:!1}}var I=i();function L({score:t,onClick:n}){let r=t.trend===`improving`?M:t.trend===`declining`?j:A,i=t.trend===`improving`?`text-green-400`:t.trend===`declining`?`text-red-400`:`text-muted-foreground`;return(0,I.jsxs)(`button`,{onClick:n,className:`rounded-xl border border-border bg-card p-4 text-left hover:border-primary/30 transition-all group`,children:[(0,I.jsxs)(`div`,{className:`flex items-center justify-between mb-3`,children:[(0,I.jsx)(`span`,{className:`text-xs font-medium text-muted-foreground uppercase tracking-wider capitalize`,children:t.category.replace(`-`,` `)}),(0,I.jsx)(`div`,{className:e(`flex items-center gap-1`,i),children:(0,I.jsx)(r,{className:`h-3 w-3`})})]}),(0,I.jsxs)(`div`,{className:`flex items-end justify-between`,children:[(0,I.jsxs)(`div`,{children:[(0,I.jsx)(`p`,{className:e(`text-2xl font-bold`,t.score>=80?`text-green-400`:t.score>=60?`text-amber-400`:t.score>=40?`text-orange-400`:`text-red-400`),children:t.score}),(0,I.jsx)(`p`,{className:`text-[10px] text-muted-foreground mt-0.5`,children:`/100`})]}),(0,I.jsxs)(`div`,{className:`text-right`,children:[(0,I.jsx)(R,{score:t.score}),t.findingsCount>0&&(0,I.jsxs)(`p`,{className:`text-[10px] text-muted-foreground mt-1`,children:[t.findingsCount,` `,t.findingsCount===1?`finding`:`findings`]})]})]})]})}function R({score:t}){return(0,I.jsx)(`div`,{className:`w-16 h-1.5 rounded-full bg-muted overflow-hidden`,children:(0,I.jsx)(`div`,{className:e(`h-full rounded-full transition-all duration-500`,t>=80?`bg-green-400`:t>=60?`bg-amber-400`:t>=40?`bg-orange-400`:`bg-red-400`),style:{width:`${t}%`}})})}function z({score:t,trend:n,totalFindings:r,criticalCount:i,highCount:a}){let o=2*Math.PI*44,s=o-t/100*o;return(0,I.jsxs)(`div`,{className:`flex items-center gap-6`,children:[(0,I.jsxs)(`div`,{className:`relative w-28 h-28`,children:[(0,I.jsxs)(`svg`,{className:`w-28 h-28 -rotate-90`,viewBox:`0 0 100 100`,children:[(0,I.jsx)(`circle`,{cx:`50`,cy:`50`,r:`44`,fill:`none`,stroke:`hsl(var(--muted))`,strokeWidth:`6`}),(0,I.jsx)(`circle`,{cx:`50`,cy:`50`,r:`44`,fill:`none`,stroke:t>=80?`#4ade80`:t>=60?`#fbbf24`:t>=40?`#fb923c`:`#f87171`,strokeWidth:`6`,strokeLinecap:`round`,strokeDasharray:o,strokeDashoffset:s,className:`transition-all duration-1000 ease-out`})]}),(0,I.jsxs)(`div`,{className:`absolute inset-0 flex flex-col items-center justify-center`,children:[(0,I.jsx)(`span`,{className:`text-2xl font-bold text-foreground`,children:t}),(0,I.jsx)(`span`,{className:`text-[10px] text-muted-foreground`,children:`/ 100`})]})]}),(0,I.jsxs)(`div`,{className:`space-y-2`,children:[(0,I.jsxs)(`div`,{children:[(0,I.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`Health Status`}),(0,I.jsx)(`p`,{className:e(`text-sm font-semibold`,t>=80?`text-green-400`:t>=60?`text-amber-400`:t>=40?`text-orange-400`:`text-red-400`),children:t>=80?`Healthy`:t>=60?`Fair`:t>=40?`Needs Attention`:`Critical`})]}),(0,I.jsxs)(`div`,{className:`flex items-center gap-3 text-xs`,children:[(0,I.jsxs)(`span`,{className:`text-muted-foreground`,children:[r,` findings`]}),i>0&&(0,I.jsxs)(`span`,{className:`text-red-400 font-medium`,children:[i,` critical`]}),a>0&&(0,I.jsxs)(`span`,{className:`text-orange-400 font-medium`,children:[a,` high`]})]}),(0,I.jsxs)(`div`,{className:`flex items-center gap-1.5`,children:[n===`improving`?(0,I.jsx)(M,{className:`h-3 w-3 text-green-400`}):n===`declining`?(0,I.jsx)(j,{className:`h-3 w-3 text-red-400`}):(0,I.jsx)(A,{className:`h-3 w-3 text-muted-foreground`}),(0,I.jsx)(`span`,{className:`text-[11px] text-muted-foreground capitalize`,children:n})]})]})]})}var B={critical:{icon:s,color:`text-red-400`,bg:`bg-red-500/10 border-red-500/20`,dot:`bg-red-400`},high:{icon:u,color:`text-orange-400`,bg:`bg-orange-500/10 border-orange-500/20`,dot:`bg-orange-400`},medium:{icon:k,color:`text-amber-400`,bg:`bg-amber-500/10 border-amber-500/20`,dot:`bg-amber-400`},low:{icon:D,color:`text-blue-400`,bg:`bg-blue-500/10 border-blue-500/20`,dot:`bg-blue-400`}};function V({finding:t,isSelected:n,onClick:r}){let i=B[t.severity],a=i.icon;return(0,I.jsx)(`button`,{onClick:r,className:e(`w-full rounded-lg border p-4 text-left transition-all`,n?`border-primary/50 bg-primary/5 shadow-sm`:`border-border bg-card hover:border-primary/20 hover:shadow-sm`),children:(0,I.jsxs)(`div`,{className:`flex items-start gap-3`,children:[(0,I.jsx)(`div`,{className:e(`flex h-8 w-8 shrink-0 items-center justify-center rounded-lg`,i.bg),children:(0,I.jsx)(a,{className:e(`h-4 w-4`,i.color)})}),(0,I.jsxs)(`div`,{className:`min-w-0 flex-1`,children:[(0,I.jsx)(`div`,{className:`flex items-center gap-2 mb-1`,children:(0,I.jsx)(`p`,{className:`text-sm font-medium text-foreground truncate`,children:t.title})}),(0,I.jsx)(`p`,{className:`text-xs text-muted-foreground line-clamp-2`,children:t.problem}),(0,I.jsxs)(`div`,{className:`flex items-center gap-2 mt-2`,children:[(0,I.jsxs)(`span`,{className:e(`inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] font-medium border`,i.bg),children:[(0,I.jsx)(`span`,{className:e(`h-1.5 w-1.5 rounded-full`,i.dot)}),t.severity]}),(0,I.jsx)(`span`,{className:`text-[10px] text-muted-foreground capitalize px-1.5 py-0.5 rounded-md bg-muted`,children:t.category.replace(`-`,` `)}),(0,I.jsx)(`span`,{className:`text-[10px] text-muted-foreground px-1.5 py-0.5 rounded-md bg-muted capitalize`,children:t.estimatedEffort})]})]})]})})}function H({finding:t,onClose:n}){let r={critical:`text-red-400`,high:`text-orange-400`,medium:`text-amber-400`,low:`text-blue-400`}[t.severity];return(0,I.jsxs)(h.div,{initial:{x:20,opacity:0},animate:{x:0,opacity:1},exit:{x:20,opacity:0},transition:{duration:.2},className:`w-96 border-l border-border bg-card flex flex-col h-full overflow-hidden`,children:[(0,I.jsxs)(`div`,{className:`flex items-center justify-between px-4 py-3 border-b border-border shrink-0`,children:[(0,I.jsxs)(`div`,{className:`min-w-0 flex-1`,children:[(0,I.jsx)(`h3`,{className:`text-sm font-medium text-foreground truncate`,children:t.title}),(0,I.jsxs)(`div`,{className:`flex items-center gap-2 mt-0.5`,children:[(0,I.jsx)(`span`,{className:e(`text-[10px] font-medium capitalize`,r),children:t.severity}),(0,I.jsx)(`span`,{className:`text-[10px] text-muted-foreground capitalize`,children:t.category.replace(`-`,` `)})]})]}),(0,I.jsx)(`button`,{onClick:n,className:`flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-accent transition-colors shrink-0`,children:(0,I.jsx)(m,{className:`h-3.5 w-3.5`})})]}),(0,I.jsxs)(`div`,{className:`flex-1 overflow-y-auto scrollbar-thin p-4 space-y-4`,children:[(0,I.jsx)(U,{title:`Problem`,icon:s,children:(0,I.jsx)(`p`,{className:`text-xs text-muted-foreground leading-relaxed`,children:t.problem})}),(0,I.jsx)(U,{title:`Impact`,icon:l,children:(0,I.jsx)(`p`,{className:`text-xs text-muted-foreground leading-relaxed`,children:t.impact})}),(0,I.jsx)(U,{title:`Recommendation`,icon:a,children:(0,I.jsx)(`p`,{className:`text-xs text-foreground leading-relaxed`,children:t.recommendation})}),(0,I.jsx)(U,{title:`Metadata`,icon:c,children:(0,I.jsxs)(`div`,{className:`grid grid-cols-2 gap-2`,children:[(0,I.jsx)(W,{label:`Priority`,value:`#${t.priority}`}),(0,I.jsx)(W,{label:`Effort`,value:t.estimatedEffort}),(0,I.jsx)(W,{label:`Status`,value:t.status}),(0,I.jsx)(W,{label:`Category`,value:t.category.replace(`-`,` `)})]})}),t.affectedFiles.length>0&&(0,I.jsx)(U,{title:`Affected Files`,icon:p,children:(0,I.jsx)(`ul`,{className:`space-y-1`,children:t.affectedFiles.map(e=>(0,I.jsx)(`li`,{className:`text-xs text-muted-foreground font-mono`,children:e},e))})}),t.affectedModules.length>0&&(0,I.jsx)(U,{title:`Affected Modules`,icon:o,children:(0,I.jsx)(`div`,{className:`flex flex-wrap gap-1.5`,children:t.affectedModules.map(e=>(0,I.jsx)(`span`,{className:`text-[11px] px-2 py-0.5 rounded-md bg-muted text-muted-foreground capitalize`,children:e},e))})}),t.tags.length>0&&(0,I.jsx)(U,{title:`Tags`,icon:c,children:(0,I.jsx)(`div`,{className:`flex flex-wrap gap-1.5`,children:t.tags.map(e=>(0,I.jsx)(`span`,{className:`text-[11px] px-2 py-0.5 rounded-md bg-primary/10 text-primary`,children:e},e))})}),(0,I.jsx)(U,{title:`AI Analysis`,icon:E,children:(0,I.jsx)(`div`,{className:`rounded-lg bg-muted/50 border border-border p-3`,children:(0,I.jsx)(`p`,{className:`text-xs text-muted-foreground italic`,children:`AI-powered analysis will provide detailed context, root cause analysis, and automated fix suggestions.`})})})]})]})}function U({title:e,icon:t,children:n}){return(0,I.jsxs)(`div`,{className:`rounded-lg border border-border p-3`,children:[(0,I.jsxs)(`div`,{className:`flex items-center gap-2 mb-2`,children:[(0,I.jsx)(t,{className:`h-3.5 w-3.5 text-muted-foreground`}),(0,I.jsx)(`h4`,{className:`text-xs font-medium text-foreground`,children:e})]}),n]})}function W({label:e,value:t}){return(0,I.jsxs)(`div`,{className:`rounded-md bg-muted/50 px-2.5 py-1.5`,children:[(0,I.jsx)(`p`,{className:`text-[10px] text-muted-foreground`,children:e}),(0,I.jsx)(`p`,{className:`text-xs font-medium text-foreground capitalize`,children:t})]})}var G=[{value:`all`,label:`All`},{value:`architecture`,label:`Architecture`},{value:`security`,label:`Security`},{value:`performance`,label:`Performance`},{value:`maintainability`,label:`Maintainability`},{value:`scalability`,label:`Scalability`},{value:`code-quality`,label:`Code Quality`},{value:`documentation`,label:`Documentation`},{value:`testing`,label:`Testing`},{value:`dependency-health`,label:`Dependencies`},{value:`configuration`,label:`Configuration`}],K=[{value:`all`,label:`All`,color:``},{value:`critical`,label:`Critical`,color:`text-red-400`},{value:`high`,label:`High`,color:`text-orange-400`},{value:`medium`,label:`Medium`,color:`text-amber-400`},{value:`low`,label:`Low`,color:`text-blue-400`}];function q(){let{filterCategory:t,setFilterCategory:n,filterSeverity:r,setFilterSeverity:i}=P();return(0,I.jsxs)(`div`,{className:`flex flex-col sm:flex-row items-start sm:items-center gap-3`,children:[(0,I.jsx)(`div`,{className:`flex items-center gap-1 overflow-x-auto scrollbar-thin pb-1`,children:K.map(({value:t,label:n,color:a})=>(0,I.jsx)(`button`,{onClick:()=>i(t),className:e(`px-2.5 py-1 rounded-md text-[11px] font-medium whitespace-nowrap transition-colors`,r===t?`bg-accent text-foreground`:`text-muted-foreground hover:text-foreground hover:bg-accent/50`),children:(0,I.jsx)(`span`,{className:e(r===t&&a),children:n})},t))}),(0,I.jsx)(`div`,{className:`w-px h-5 bg-border hidden sm:block`}),(0,I.jsx)(`div`,{className:`flex items-center gap-1 overflow-x-auto scrollbar-thin pb-1`,children:G.map(({value:r,label:i})=>(0,I.jsx)(`button`,{onClick:()=>n(r),className:e(`px-2.5 py-1 rounded-md text-[11px] font-medium whitespace-nowrap transition-colors`,t===r?`bg-accent text-foreground`:`text-muted-foreground hover:text-foreground hover:bg-accent/50`),children:i},r))})]})}function J({steps:t}){return t.length===0?null:(0,I.jsxs)(`div`,{className:`rounded-xl border border-border bg-card p-5`,children:[(0,I.jsxs)(`h3`,{className:`text-sm font-medium text-foreground mb-4 flex items-center gap-2`,children:[(0,I.jsx)(O,{className:`h-4 w-4 text-muted-foreground`}),`Improvement Roadmap`]}),(0,I.jsx)(`div`,{className:`space-y-4`,children:t.map((n,r)=>{let i={critical:`border-red-500/30 bg-red-500/5`,high:`border-orange-500/30 bg-orange-500/5`,medium:`border-amber-500/30 bg-amber-500/5`,low:`border-blue-500/30 bg-blue-500/5`}[n.priority];return(0,I.jsxs)(`div`,{className:`flex gap-4`,children:[(0,I.jsxs)(`div`,{className:`flex flex-col items-center`,children:[(0,I.jsx)(`div`,{className:e(`flex h-7 w-7 shrink-0 items-center justify-center rounded-full border text-xs font-bold`,i),children:r+1}),r0&&(0,I.jsxs)(`p`,{className:`text-[10px] text-muted-foreground mt-2`,children:[n.relatedFindings.length,` related findings`]})]})]},n.id)})})]})}function Y({review:e}){let[t,n]=(0,N.useState)(!1);return(0,I.jsxs)(`div`,{className:`relative`,children:[(0,I.jsxs)(`button`,{onClick:()=>n(!t),className:`flex items-center gap-2 px-3 py-1.5 rounded-md border border-border text-xs font-medium text-foreground hover:bg-accent transition-colors`,children:[(0,I.jsx)(f,{className:`h-3.5 w-3.5`}),`Export`]}),t&&(0,I.jsxs)(I.Fragment,{children:[(0,I.jsx)(`div`,{className:`fixed inset-0 z-40`,onClick:()=>n(!1)}),(0,I.jsxs)(`div`,{className:`absolute top-full right-0 mt-1 w-40 rounded-lg border border-border bg-popover shadow-lg z-50 p-1`,children:[(0,I.jsx)(`button`,{onClick:()=>{let t=Z(e);X(new Blob([t],{type:`text/markdown`}),`${e.repositoryName}-review.md`),n(!1)},className:`w-full px-3 py-1.5 text-xs text-left rounded-md hover:bg-accent transition-colors`,children:`Markdown`}),(0,I.jsx)(`button`,{onClick:()=>{X(new Blob([JSON.stringify(e,null,2)],{type:`application/json`}),`${e.repositoryName}-review.json`),n(!1)},className:`w-full px-3 py-1.5 text-xs text-left rounded-md hover:bg-accent transition-colors`,children:`JSON`}),(0,I.jsx)(`button`,{disabled:!0,className:`w-full px-3 py-1.5 text-xs text-left rounded-md text-muted-foreground cursor-not-allowed`,children:`PDF Coming Soon`}),(0,I.jsx)(`button`,{disabled:!0,className:`w-full px-3 py-1.5 text-xs text-left rounded-md text-muted-foreground cursor-not-allowed`,children:`Share Coming Soon`})]})]})]})}function X(e,t){let n=URL.createObjectURL(e),r=document.createElement(`a`);r.href=n,r.download=t,r.click(),URL.revokeObjectURL(n)}function Z(e){let t=[`# Engineering Review: ${e.repositoryName}`,``,`**Generated:** ${new Date(e.generatedAt).toLocaleString()}`,``,`## Executive Summary`,``,`| Metric | Value |`,`| --- | --- |`,`| Overall Score | ${e.summary.overallScore}/100 |`,`| Total Findings | ${e.summary.totalFindings} |`,`| Critical | ${e.summary.criticalCount} |`,`| High | ${e.summary.highCount} |`,`| Medium | ${e.summary.mediumCount} |`,`| Low | ${e.summary.lowCount} |`,``,`## Health Scores`,``,`| Category | Score | Risk | Findings |`,`| --- | --- | --- | --- |`];for(let n of e.scores)t.push(`| ${n.category.replace(`-`,` `)} | ${n.score}/100 | ${n.riskLevel} | ${n.findingsCount} |`);t.push(``,`## Findings`,``);for(let n of e.findings)t.push(`### ${n.severity.toUpperCase()}: ${n.title}`),t.push(``),t.push(`**Category:** ${n.category.replace(`-`,` `)}`),t.push(`**Effort:** ${n.estimatedEffort}`),t.push(``),t.push(`**Problem:** ${n.problem}`),t.push(``),t.push(`**Impact:** ${n.impact}`),t.push(``),t.push(`**Recommendation:** ${n.recommendation}`),t.push(``),n.affectedFiles.length>0&&(t.push(`**Affected Files:** ${n.affectedFiles.join(`, `)}`),t.push(``));if(e.roadmap.length>0){t.push(`## Improvement Roadmap`,``);for(let n of e.roadmap)t.push(`${e.roadmap.indexOf(n)+1}. **${n.title}** (${n.estimatedEffort})`),t.push(` ${n.description}`),t.push(``)}return t.join(` -`)}function Q(){let e=g(),t=F(),{review:n,selectedFindingId:r,setSelectedFindingId:i,setFilterCategory:a,setFilterSeverity:o,filteredFindings:c}=P(),l=t.review||n,d=c(),f=(0,N.useMemo)(()=>l?.findings.find(e=>e.id===r)||null,[l,r]);return t.emptyReason===`no-completed-repositories`?(0,I.jsx)(`div`,{className:`h-full flex flex-col`,children:(0,I.jsx)(w,{icon:v,title:`No engineering review available`,description:`Upload and analyse a repository to generate its engineering review. The review is built from repository analysis data.`,action:{label:`Upload Repository`,onClick:()=>e(`/upload`)}})}):t.emptyReason===`no-active-repository`?(0,I.jsx)(`div`,{className:`h-full flex flex-col`,children:(0,I.jsx)(w,{icon:v,title:`Select a repository`,description:`Choose an analysed repository from the top bar to view its engineering review.`})}):t.error?(0,I.jsx)(`div`,{className:`h-full flex items-center justify-center`,children:(0,I.jsxs)(`div`,{className:`text-center`,children:[(0,I.jsx)(`p`,{className:`text-sm text-destructive mb-2`,children:t.error}),(0,I.jsx)(`button`,{onClick:t.retry,className:`text-xs text-primary hover:underline`,children:`Retry`})]})}):t.loading||!l?(0,I.jsx)(`div`,{className:`h-full flex items-center justify-center`,children:(0,I.jsxs)(`div`,{className:`flex items-center gap-3`,children:[(0,I.jsx)(`div`,{className:`h-4 w-4 rounded-full border-2 border-primary border-t-transparent animate-spin`}),(0,I.jsx)(`span`,{className:`text-sm text-muted-foreground`,children:`Loading engineering review...`})]})}):(0,I.jsxs)(`div`,{className:`flex h-[calc(100vh-8rem)] -m-6`,children:[(0,I.jsx)(`div`,{className:`flex-1 overflow-y-auto scrollbar-thin p-6`,children:(0,I.jsxs)(`div`,{className:`max-w-5xl`,children:[(0,I.jsxs)(`div`,{className:`flex items-center justify-between mb-6`,children:[(0,I.jsx)(C,{title:`Engineering Review`,description:`Automated audit for ${l.repositoryName}`,children:(0,I.jsx)(T,{source:t.source})}),(0,I.jsx)(Y,{review:l})]}),(0,I.jsx)(`section`,{className:`mb-8`,children:(0,I.jsx)(`div`,{className:`rounded-xl border border-border bg-card p-6`,children:(0,I.jsxs)(`div`,{className:`flex flex-col lg:flex-row items-start lg:items-center gap-6`,children:[(0,I.jsx)(z,{score:l.summary.overallScore,trend:l.summary.overallTrend,totalFindings:l.summary.totalFindings,criticalCount:l.summary.criticalCount,highCount:l.summary.highCount}),(0,I.jsxs)(`div`,{className:`flex-1 grid grid-cols-2 sm:grid-cols-4 gap-3`,children:[(0,I.jsx)($,{icon:s,label:`Critical`,count:l.summary.criticalCount,color:`text-red-400`,bg:`bg-red-500/10`}),(0,I.jsx)($,{icon:u,label:`High`,count:l.summary.highCount,color:`text-orange-400`,bg:`bg-orange-500/10`}),(0,I.jsx)($,{icon:k,label:`Medium`,count:l.summary.mediumCount,color:`text-amber-400`,bg:`bg-amber-500/10`}),(0,I.jsx)($,{icon:D,label:`Low`,count:l.summary.lowCount,color:`text-blue-400`,bg:`bg-blue-500/10`})]})]})})}),(0,I.jsxs)(`section`,{className:`mb-8`,children:[(0,I.jsx)(`h2`,{className:`text-sm font-medium text-foreground mb-3`,children:`Health Scores`}),(0,I.jsx)(`div`,{className:`grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3`,children:l.scores.map(e=>(0,I.jsx)(L,{score:e,onClick:()=>{a(e.category),o(`all`)}},e.category))})]}),(0,I.jsxs)(`section`,{className:`mb-8`,children:[(0,I.jsxs)(`div`,{className:`flex items-center justify-between mb-3`,children:[(0,I.jsx)(`h2`,{className:`text-sm font-medium text-foreground`,children:`Findings`}),(0,I.jsxs)(`span`,{className:`text-xs text-muted-foreground`,children:[d.length,` results`]})]}),(0,I.jsx)(q,{}),(0,I.jsx)(`div`,{className:`mt-4 space-y-2`,children:d.length===0?(0,I.jsxs)(`div`,{className:`rounded-lg border border-border bg-card p-8 text-center`,children:[(0,I.jsx)(D,{className:`h-8 w-8 text-green-400 mx-auto mb-2`}),(0,I.jsx)(`p`,{className:`text-sm text-muted-foreground`,children:`No findings match the current filters`})]}):d.map(e=>(0,I.jsx)(h.div,{initial:{opacity:0,y:4},animate:{opacity:1,y:0},transition:{duration:.15},children:(0,I.jsx)(V,{finding:e,isSelected:r===e.id,onClick:()=>i(r===e.id?null:e.id)})},e.id))})]}),(0,I.jsx)(`section`,{className:`mb-8`,children:(0,I.jsx)(J,{steps:l.roadmap})})]})}),(0,I.jsx)(S,{children:f&&(0,I.jsx)(H,{finding:f,onClose:()=>i(null)})})]})}function $({icon:t,label:n,count:r,color:i,bg:a}){return(0,I.jsxs)(`div`,{className:e(`rounded-lg border border-border p-3 text-center`,r>0&&a),children:[(0,I.jsx)(t,{className:e(`h-4 w-4 mx-auto mb-1`,r>0?i:`text-muted-foreground/50`)}),(0,I.jsx)(`p`,{className:e(`text-lg font-bold`,r>0?i:`text-muted-foreground/50`),children:r}),(0,I.jsx)(`p`,{className:`text-[10px] text-muted-foreground`,children:n})]})}export{Q as EngineeringReviewPage}; \ No newline at end of file diff --git a/dist/assets/InsightsPage-BlJ2erOS.js b/dist/assets/InsightsPage-BlJ2erOS.js deleted file mode 100644 index 469589af..00000000 --- a/dist/assets/InsightsPage-BlJ2erOS.js +++ /dev/null @@ -1 +0,0 @@ -import{s as e}from"./client-S4ekmpXx.js";import{E as t,S as n,p as r}from"./index-QB2QUwKm.js";import{t as i}from"./PageHeader-DsNWbEI1.js";import{t as a}from"./EmptyState-BeMA8Ikt.js";import{t as o}from"./DataSourceBadge-D7dhrb9n.js";import{t as s}from"./useRepositoryFeatureStatus-BAj4lrBf.js";var c=n(`Lock`,[[`rect`,{width:`18`,height:`11`,x:`3`,y:`11`,rx:`2`,ry:`2`,key:`1w4ew1`}],[`path`,{d:`M7 11V7a5 5 0 0 1 10 0v4`,key:`fwvmzm`}]]);function l(){return s()}var u=e();function d(){let e=t(),n=l(),s=n.activeRepository;return n.emptyReason===`no-completed-repositories`?(0,u.jsxs)(`div`,{children:[(0,u.jsx)(i,{title:`Insights`,description:`Code quality insights and recommendations`}),(0,u.jsx)(a,{icon:r,title:`No insights available`,description:`Upload and analyse a repository first. Insights are generated from a completed analysis pipeline.`,action:{label:`Upload Repository`,onClick:()=>e(`/upload`)}})]}):n.emptyReason===`no-active-repository`||!s?(0,u.jsxs)(`div`,{children:[(0,u.jsx)(i,{title:`Insights`,description:`Code quality insights and recommendations`}),(0,u.jsx)(a,{icon:r,title:`Select a repository`,description:`Choose an analysed repository from the top bar to view code insights.`})]}):(0,u.jsxs)(`div`,{children:[(0,u.jsx)(i,{title:`Insights`,description:`Insights for ${s.name}`,children:(0,u.jsx)(o,{source:n.source})}),(0,u.jsxs)(`div`,{className:`rounded-xl border border-border bg-card p-8 min-h-[400px] flex flex-col items-center justify-center`,children:[(0,u.jsx)(`div`,{className:`flex h-16 w-16 items-center justify-center rounded-2xl bg-muted mb-5`,children:(0,u.jsx)(c,{className:`h-7 w-7 text-muted-foreground`})}),(0,u.jsx)(`h3`,{className:`text-base font-medium text-foreground mb-2`,children:`Code Insights Coming Soon`}),(0,u.jsxs)(`p`,{className:`text-sm text-muted-foreground text-center max-w-md`,children:[`Repository `,(0,u.jsx)(`span`,{className:`font-medium text-foreground`,children:s.name}),` is ready for insight generation. This workflow is intentionally disabled until the insights endpoint is implemented.`]})]})]})}export{d as InsightsPage}; \ No newline at end of file diff --git a/dist/assets/PageHeader-DsNWbEI1.js b/dist/assets/PageHeader-DsNWbEI1.js deleted file mode 100644 index 9c12c918..00000000 --- a/dist/assets/PageHeader-DsNWbEI1.js +++ /dev/null @@ -1 +0,0 @@ -import{a as e,s as t}from"./client-S4ekmpXx.js";var n=t();function r({title:t,description:r,children:i,className:a}){return(0,n.jsxs)(`div`,{className:e(`flex items-start justify-between mb-6`,a),children:[(0,n.jsxs)(`div`,{children:[(0,n.jsx)(`h1`,{className:`text-2xl font-semibold text-foreground tracking-tight`,children:t}),r&&(0,n.jsx)(`p`,{className:`mt-1 text-sm text-muted-foreground`,children:r})]}),i&&(0,n.jsx)(`div`,{className:`flex items-center gap-2`,children:i})]})}export{r as t}; \ No newline at end of file diff --git a/dist/assets/RepositoriesPage-GMDpbLMw.js b/dist/assets/RepositoriesPage-GMDpbLMw.js deleted file mode 100644 index aaf0b519..00000000 --- a/dist/assets/RepositoriesPage-GMDpbLMw.js +++ /dev/null @@ -1 +0,0 @@ -import{s as e}from"./client-S4ekmpXx.js";import{C as t,E as n,S as r,_ as i,h as a,s as o,t as s}from"./index-QB2QUwKm.js";import{t as c}from"./PageHeader-DsNWbEI1.js";import{t as l}from"./EmptyState-BeMA8Ikt.js";import{n as u,t as d}from"./DataSourceBadge-D7dhrb9n.js";import{t as f}from"./status-5ylMtKLG.js";var p=r(`ExternalLink`,[[`path`,{d:`M15 3h6v6`,key:`1q9fwt`}],[`path`,{d:`M10 14 21 3`,key:`gplh6r`}],[`path`,{d:`M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6`,key:`a6xqqp`}]]),m=r(`Trash2`,[[`path`,{d:`M3 6h18`,key:`d0wm0j`}],[`path`,{d:`M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6`,key:`4alrt4`}],[`path`,{d:`M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2`,key:`v07s0e`}],[`line`,{x1:`10`,x2:`10`,y1:`11`,y2:`17`,key:`1uufr5`}],[`line`,{x1:`14`,x2:`14`,y1:`11`,y2:`17`,key:`xtxkd`}]]),h=e();function g(){let e=n(),{repositories:r,removeRepository:g,selectRepository:_}=s();return r.length===0?(0,h.jsxs)(`div`,{children:[(0,h.jsx)(c,{title:`Repositories`,description:`Manage your uploaded repositories`}),(0,h.jsx)(l,{icon:i,title:`No repositories yet`,description:`Upload a repository or import from GitHub to begin exploring its architecture, dependencies, and documentation.`,action:{label:`Upload Repository`,onClick:()=>e(`/upload`)}})]}):(0,h.jsxs)(`div`,{children:[(0,h.jsx)(c,{title:`Repositories`,description:`Manage your uploaded repositories`,children:(0,h.jsx)(`button`,{onClick:()=>e(`/upload`),className:`rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors`,children:`Upload New`})}),(0,h.jsx)(`div`,{className:`rounded-xl border border-border bg-card overflow-hidden`,children:(0,h.jsxs)(`table`,{className:`w-full`,children:[(0,h.jsx)(`thead`,{children:(0,h.jsxs)(`tr`,{className:`border-b border-border`,children:[(0,h.jsx)(`th`,{className:`px-4 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider`,children:`Name`}),(0,h.jsx)(`th`,{className:`px-4 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider hidden sm:table-cell`,children:`Source`}),(0,h.jsx)(`th`,{className:`px-4 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider hidden md:table-cell`,children:`Language`}),(0,h.jsx)(`th`,{className:`px-4 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider hidden lg:table-cell`,children:`Files`}),(0,h.jsx)(`th`,{className:`px-4 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider`,children:`Status`}),(0,h.jsx)(`th`,{className:`px-4 py-3 text-right text-xs font-medium text-muted-foreground uppercase tracking-wider`,children:`Actions`})]})}),(0,h.jsx)(`tbody`,{className:`divide-y divide-border`,children:r.map((n,r)=>(0,h.jsxs)(t.tr,{initial:{opacity:0},animate:{opacity:1},transition:{delay:r*.03},className:`hover:bg-accent/30 transition-colors`,children:[(0,h.jsx)(`td`,{className:`px-4 py-3`,children:(0,h.jsx)(`button`,{onClick:()=>{_(n),n.status===`analysing`?e(`/analysis/${n.id}`):e(`/repositories/${n.id}`)},className:`text-sm font-medium text-foreground hover:text-primary transition-colors`,children:n.name})}),(0,h.jsx)(`td`,{className:`px-4 py-3 hidden sm:table-cell`,children:(0,h.jsx)(`span`,{className:`flex items-center gap-1.5 text-xs text-muted-foreground`,children:n.source===`github`?(0,h.jsxs)(h.Fragment,{children:[(0,h.jsx)(a,{className:`h-3.5 w-3.5`}),` GitHub`]}):(0,h.jsxs)(h.Fragment,{children:[(0,h.jsx)(o,{className:`h-3.5 w-3.5`}),` Upload`]})})}),(0,h.jsx)(`td`,{className:`px-4 py-3 text-sm text-muted-foreground hidden md:table-cell`,children:n.meta?.language||`-`}),(0,h.jsx)(`td`,{className:`px-4 py-3 text-sm text-muted-foreground hidden lg:table-cell`,children:n.meta?.totalFiles||`-`}),(0,h.jsx)(`td`,{className:`px-4 py-3`,children:(0,h.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,h.jsx)(u,{variant:f[n.status],children:n.status}),(0,h.jsx)(d,{source:n.dataSource})]})}),(0,h.jsx)(`td`,{className:`px-4 py-3 text-right`,children:(0,h.jsxs)(`div`,{className:`flex items-center justify-end gap-1`,children:[(0,h.jsx)(`button`,{onClick:()=>{_(n),n.status===`analysing`?e(`/analysis/${n.id}`):e(`/repositories/${n.id}`)},className:`p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-accent transition-colors`,children:(0,h.jsx)(p,{className:`h-3.5 w-3.5`})}),(0,h.jsx)(`button`,{onClick:()=>g(n.id),className:`p-1.5 rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors`,children:(0,h.jsx)(m,{className:`h-3.5 w-3.5`})})]})})]},n.id))})]})})]})}export{g as RepositoriesPage}; \ No newline at end of file diff --git a/dist/assets/RepositoryDetailPage-DG0b2Pf1.js b/dist/assets/RepositoryDetailPage-DG0b2Pf1.js deleted file mode 100644 index f42032f3..00000000 --- a/dist/assets/RepositoryDetailPage-DG0b2Pf1.js +++ /dev/null @@ -1,80 +0,0 @@ -import{a as e,c as t,o as n,p as r,s as i}from"./client-S4ekmpXx.js";import{t as a}from"./arrow-left-BeIPd3Ya.js";import{a as o,c as s,i as c,n as l,o as u,r as d,s as f,t as p}from"./panel-left-open-DLIMLp_X.js";import{t as m}from"./clock-qFz1Yxfz.js";import{t as h}from"./file-code-o79iDh0J.js";import{t as g}from"./file-BFCfRogd.js";import{t as _}from"./hard-drive-B0dUssIC.js";import{t as v}from"./package-Dhq59A-j.js";import{t as y}from"./x-Bf4UPE8b.js";import{C as b,D as x,E as S,S as C,T as w,_ as T,b as E,i as D,l as O,s as k,t as A,u as ee,v as j,w as te}from"./index-QB2QUwKm.js";import{t as M}from"./PageHeader-DsNWbEI1.js";import{t as ne}from"./EmptyState-BeMA8Ikt.js";import{n as re,t as ie}from"./DataSourceBadge-D7dhrb9n.js";import{t as ae}from"./status-5ylMtKLG.js";var oe=C(`FileJson`,[[`path`,{d:`M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z`,key:`1rqfz7`}],[`path`,{d:`M14 2v4a2 2 0 0 0 2 2h4`,key:`tnqrlb`}],[`path`,{d:`M10 12a1 1 0 0 0-1 1v1a1 1 0 0 1-1 1 1 1 0 0 1 1 1v1a1 1 0 0 0 1 1`,key:`1oajmo`}],[`path`,{d:`M14 18a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1 1 1 0 0 1-1-1v-1a1 1 0 0 0-1-1`,key:`mpwhp6`}]]),se=C(`FileType`,[[`path`,{d:`M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z`,key:`1rqfz7`}],[`path`,{d:`M14 2v4a2 2 0 0 0 2 2h4`,key:`tnqrlb`}],[`path`,{d:`M9 13v-1h6v1`,key:`1bb014`}],[`path`,{d:`M12 12v6`,key:`3ahymv`}],[`path`,{d:`M11 18h2`,key:`12mj7e`}]]),ce=C(`FolderMinus`,[[`path`,{d:`M9 13h6`,key:`1uhe8q`}],[`path`,{d:`M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z`,key:`1kt360`}]]),le=C(`FolderOpen`,[[`path`,{d:`m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2`,key:`usdka0`}]]),ue=C(`FolderPlus`,[[`path`,{d:`M12 10v6`,key:`1bos4e`}],[`path`,{d:`M9 13h6`,key:`1uhe8q`}],[`path`,{d:`M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z`,key:`1kt360`}]]),de=C(`FolderTree`,[[`path`,{d:`M20 10a1 1 0 0 0 1-1V6a1 1 0 0 0-1-1h-2.5a1 1 0 0 1-.8-.4l-.9-1.2A1 1 0 0 0 15 3h-2a1 1 0 0 0-1 1v5a1 1 0 0 0 1 1Z`,key:`hod4my`}],[`path`,{d:`M20 21a1 1 0 0 0 1-1v-3a1 1 0 0 0-1-1h-2.9a1 1 0 0 1-.88-.55l-.42-.85a1 1 0 0 0-.92-.6H13a1 1 0 0 0-1 1v5a1 1 0 0 0 1 1Z`,key:`w4yl2u`}],[`path`,{d:`M3 5a2 2 0 0 0 2 2h3`,key:`f2jnh7`}],[`path`,{d:`M3 3v13a2 2 0 0 0 2 2h3`,key:`k8epm1`}]]),fe=C(`Folder`,[[`path`,{d:`M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z`,key:`1kt360`}]]),pe=C(`Hash`,[[`line`,{x1:`4`,x2:`20`,y1:`9`,y2:`9`,key:`4lhtct`}],[`line`,{x1:`4`,x2:`20`,y1:`15`,y2:`15`,key:`vyu0kd`}],[`line`,{x1:`10`,x2:`8`,y1:`3`,y2:`21`,key:`1ggp8o`}],[`line`,{x1:`16`,x2:`14`,y1:`3`,y2:`21`,key:`weycgp`}]]),me=C(`House`,[[`path`,{d:`M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8`,key:`5wwlr5`}],[`path`,{d:`M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z`,key:`1d0kgt`}]]),he=C(`Image`,[[`rect`,{width:`18`,height:`18`,x:`3`,y:`3`,rx:`2`,ry:`2`,key:`1m3agn`}],[`circle`,{cx:`9`,cy:`9`,r:`2`,key:`af1f0g`}],[`path`,{d:`m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21`,key:`1xmnt7`}]]),ge=C(`Scale`,[[`path`,{d:`m16 16 3-8 3 8c-.87.65-1.92 1-3 1s-2.13-.35-3-1Z`,key:`7g6ntu`}],[`path`,{d:`m2 16 3-8 3 8c-.87.65-1.92 1-3 1s-2.13-.35-3-1Z`,key:`ijws7r`}],[`path`,{d:`M7 21h10`,key:`1b0cd5`}],[`path`,{d:`M12 3v18`,key:`108xh3`}],[`path`,{d:`M3 7h2c2 0 5-1 7-2 2 1 5 2 7 2h2`,key:`3gwbw2`}]]),_e=C(`Settings2`,[[`path`,{d:`M20 7h-9`,key:`3s1dr2`}],[`path`,{d:`M14 17H5`,key:`gfn3mx`}],[`circle`,{cx:`17`,cy:`17`,r:`3`,key:`18b49y`}],[`circle`,{cx:`7`,cy:`7`,r:`3`,key:`dfmy0x`}]]),ve=C(`Terminal`,[[`polyline`,{points:`4 17 10 11 4 5`,key:`akl6gq`}],[`line`,{x1:`12`,x2:`20`,y1:`19`,y2:`19`,key:`q2wloq`}]]),N=r(t(),1),P=D(e=>({expandedFolders:new Set,toggleFolder:t=>e(e=>{let n=new Set(e.expandedFolders);return n.has(t)?n.delete(t):n.add(t),{expandedFolders:n}}),expandFolder:t=>e(e=>{let n=new Set(e.expandedFolders);return n.add(t),{expandedFolders:n}}),collapseAll:()=>e({expandedFolders:new Set}),expandAll:t=>{let n=new Set;function r(e){for(let t of e)t.type===`folder`&&(n.add(t.id),t.children&&r(t.children))}r(t),e({expandedFolders:n})},selectedFileId:null,selectedNode:null,selectFile:t=>e({selectedFileId:t.id,selectedNode:t}),clearSelection:()=>e({selectedFileId:null,selectedNode:null}),focusedId:null,setFocusedId:t=>e({focusedId:t}),searchQuery:``,setSearchQuery:t=>e({searchQuery:t}),explorerWidth:280,setExplorerWidth:t=>e({explorerWidth:t}),detailsTab:`details`,setDetailsTab:t=>e({detailsTab:t})}));function ye({initialWidth:e,minWidth:t,maxWidth:n,direction:r}){let[i,a]=(0,N.useState)(e),o=(0,N.useRef)(!1),s=(0,N.useRef)(0),c=(0,N.useRef)(0),l=(0,N.useCallback)(e=>{o.current=!0,s.current=e.clientX,c.current=i,document.body.style.cursor=`col-resize`,document.body.style.userSelect=`none`},[i]);return(0,N.useEffect)(()=>{let e=e=>{if(!o.current)return;let i=r===`left`?e.clientX-s.current:s.current-e.clientX,l=Math.min(n,Math.max(t,c.current+i));a(l)},i=()=>{o.current=!1,document.body.style.cursor=``,document.body.style.userSelect=``};return document.addEventListener(`mousemove`,e),document.addEventListener(`mouseup`,i),()=>{document.removeEventListener(`mousemove`,e),document.removeEventListener(`mouseup`,i)}},[t,n,r]),{width:i,onMouseDown:l}}function be(e,t){let n=e.extension||null,r=e.language||xe(n);return{name:e.name,path:e.path,extension:n,language:r,estimatedSize:e.size||0,imports:Se(e,t),exports:we(e),relatedModules:Te(e),dependencies:Ee(e)}}function xe(e){return e&&{ts:`TypeScript`,tsx:`TypeScript (React)`,js:`JavaScript`,jsx:`JavaScript (React)`,py:`Python`,rb:`Ruby`,go:`Go`,rs:`Rust`,java:`Java`,kt:`Kotlin`,swift:`Swift`,cs:`C#`,cpp:`C++`,c:`C`,html:`HTML`,css:`CSS`,scss:`SCSS`,json:`JSON`,yaml:`YAML`,yml:`YAML`,md:`Markdown`,sql:`SQL`,sh:`Shell`,toml:`TOML`,xml:`XML`,svg:`SVG`,graphql:`GraphQL`,prisma:`Prisma`}[e]||null}function Se(e,t){if(!e.extension||![`ts`,`tsx`,`js`,`jsx`,`py`,`go`,`rs`,`java`].includes(e.extension))return[];let n=e.path.substring(0,e.path.lastIndexOf(`/`));return De(t).filter(t=>t.type===`file`&&t.path!==e.path&&t.path.startsWith(n)).slice(0,4).map(e=>`./${e.name.replace(/\.[^.]+$/,``)}`).concat(Ce(e))}function Ce(e){if(!e.extension)return[];let t=e.extension;if([`ts`,`tsx`,`js`,`jsx`].includes(t)){let n=e.name.toLowerCase(),r=[];return(n.includes(`component`)||t===`tsx`||t===`jsx`)&&r.push(`react`),n.includes(`store`)&&r.push(`zustand`),(n.includes(`api`)||n.includes(`service`))&&r.push(`fetch`),n.includes(`test`)&&r.push(`vitest`),r}return[]}function we(e){if(!e.extension||![`ts`,`tsx`,`js`,`jsx`].includes(e.extension))return[];let t=e.name.replace(/\.[^.]+$/,``),n=[];if([`tsx`,`jsx`].includes(e.extension)){let e=t.charAt(0).toUpperCase()+t.slice(1);n.push(e)}else if(e.name===`index.ts`||e.name===`index.js`)n.push(`* (barrel export)`);else{let e=t.replace(/[-.](.)/g,(e,t)=>t.toUpperCase());n.push(e)}return n}function Te(e){let t=e.path.substring(0,e.path.lastIndexOf(`/`)).split(`/`).filter(Boolean);return t.length>0?[t[t.length-1]]:[]}function Ee(e){if(!e.extension)return[];let t=e.extension;if(![`ts`,`tsx`,`js`,`jsx`].includes(t))return[];let n=[],r=e.name.toLowerCase();return(r.includes(`page`)||r.includes(`view`))&&n.push(`router`),r.includes(`store`)&&n.push(`state-management`),(r.includes(`api`)||r.includes(`service`))&&n.push(`network`),[`tsx`,`jsx`].includes(t)&&n.push(`ui-framework`),n}function De(e){let t=[];function n(e){for(let r of e)t.push(r),r.children&&n(r.children)}return n(e),t}function Oe(e){return e&&{ts:`typescript`,tsx:`typescript`,js:`javascript`,jsx:`javascript`,json:`json`,css:`css`,scss:`scss`,html:`html`,md:`markdown`,py:`python`,go:`go`,rs:`rust`,java:`java`,yaml:`yaml`,yml:`yaml`,xml:`xml`,sql:`sql`,sh:`shell`,graphql:`graphql`}[e]||`plaintext`}function ke(e){if(e.type===`folder`)return``;let t=e.extension;if(!t)return`// ${e.name}\n`;let n=e.name.replace(/\.[^.]+$/,``);if([`tsx`,`jsx`].includes(t)){let e=n.charAt(0).toUpperCase()+n.slice(1).replace(/[-_.]/g,``);return`import { useState } from 'react';\n\ninterface ${e}Props {\n className?: string;\n}\n\nexport function ${e}({ className }: ${e}Props) {\n const [state, setState] = useState(null);\n\n return (\n
    \n

    ${e}

    \n {/* Component implementation */}\n
    \n );\n}\n`}return[`ts`,`js`].includes(t)?n.includes(`store`)||n.includes(`Store`)?`import { create } from 'zustand';\n\ninterface ${n}State {\n data: unknown[];\n loading: boolean;\n fetch: () => Promise;\n}\n\nexport const use${n.charAt(0).toUpperCase()+n.slice(1)} = create<${n}State>((set) => ({\n data: [],\n loading: false,\n fetch: async () => {\n set({ loading: true });\n // Implementation\n set({ loading: false });\n },\n}));\n`:n.includes(`service`)||n.includes(`Service`)?`const BASE_URL = import.meta.env.VITE_API_URL; - -export async function fetchData(endpoint: string): Promise { - const response = await fetch(\`\${BASE_URL}/\${endpoint}\`); - if (!response.ok) throw new Error(response.statusText); - return response.json(); -} - -export async function postData(endpoint: string, body: unknown): Promise { - const response = await fetch(\`\${BASE_URL}/\${endpoint}\`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }); - if (!response.ok) throw new Error(response.statusText); - return response.json(); -} -`:n===`index`?`export * from './types'; -export * from './utils'; -`:`export function ${n}() {\n // Implementation\n}\n`:t===`json`?n===`package`?`{ - "name": "project", - "version": "1.0.0", - "private": true, - "type": "module", - "scripts": { - "dev": "vite", - "build": "tsc -b && vite build" - }, - "dependencies": { - "react": "^18.3.1", - "react-dom": "^18.3.1" - } -} -`:n===`tsconfig`?`{ - "compilerOptions": { - "target": "ES2020", - "module": "ESNext", - "strict": true, - "jsx": "react-jsx", - "moduleResolution": "bundler" - }, - "include": ["src"] -} -`:`{ - "key": "value" -} -`:t===`css`||t===`scss`?`:root { - --primary: #0066ff; - --background: #ffffff; - --foreground: #111111; -} - -.container { - max-width: 1200px; - margin: 0 auto; - padding: 0 1rem; -} -`:t===`md`?`# ${n}\n\nProject documentation.\n\n## Getting Started\n\n\`\`\`bash\nnpm install\nnpm run dev\n\`\`\`\n\n## Features\n\n- Feature 1\n- Feature 2\n`:t===`yaml`||t===`yml`?`name: CI -on: - push: - branches: [main] -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - - run: npm ci - - run: npm run build -`:`// ${e.name}\n// Content will be available after backend integration\n`}var F=i(),Ae={typescript:h,react:h,javascript:h,json:oe,css:se,html:se,markdown:j,python:h,go:h,rust:h,java:h,yaml:O,config:O,terminal:ve,database:o,image:he,text:j,file:g};function je(e){if(e.type===`folder`)return fe;let t=e.extension;return t&&Ae[{ts:`typescript`,tsx:`react`,js:`javascript`,jsx:`react`,json:`json`,css:`css`,scss:`css`,html:`html`,md:`markdown`,py:`python`,go:`go`,rs:`rust`,java:`java`,yaml:`yaml`,yml:`yaml`,toml:`config`,sh:`terminal`,sql:`database`,svg:`image`,png:`image`}[t]||`file`]||g}function Me(e){if(e.type===`folder`)return``;let t=e.extension;return t&&{ts:`text-blue-400`,tsx:`text-blue-400`,js:`text-yellow-400`,jsx:`text-yellow-400`,json:`text-yellow-600`,css:`text-sky-400`,scss:`text-pink-400`,html:`text-orange-400`,md:`text-gray-400`,py:`text-green-400`,go:`text-cyan-400`,rs:`text-orange-500`,java:`text-red-400`,yaml:`text-rose-400`,yml:`text-rose-400`,sh:`text-green-500`,sql:`text-violet-400`}[t]||`text-muted-foreground`}function Ne({node:t,depth:n,searchQuery:r}){let{expandedFolders:i,toggleFolder:a,selectedFileId:o,selectFile:c,focusedId:l,setFocusedId:u}=P(),d=i.has(t.id),f=o===t.id,p=l===t.id,m=t.type===`folder`,h=je(t),g=Me(t),_=(0,N.useMemo)(()=>!r||t.name.toLowerCase().includes(r.toLowerCase()),[r,t.name]),v=(0,N.useMemo)(()=>{if(!r||!t.children)return!1;function e(t){return t.some(t=>t.name.toLowerCase().includes(r.toLowerCase())||t.children&&e(t.children))}return e(t.children)},[r,t.children]);return r&&!_&&!v?null:(0,F.jsxs)(`div`,{children:[(0,F.jsxs)(`button`,{onClick:()=>{u(t.id),m?a(t.id):c(t)},"data-node-id":t.id,className:e(`w-full flex items-center gap-1.5 py-[3px] px-1.5 rounded text-left text-[13px] transition-colors group`,f&&`bg-primary/10 text-primary`,p&&!f&&`bg-accent/60`,!f&&!p&&`hover:bg-accent/40`),style:{paddingLeft:`${n*14+6}px`},children:[m?(0,F.jsx)(s,{className:e(`h-3 w-3 text-muted-foreground shrink-0 transition-transform duration-100`,d&&`rotate-90`)}):(0,F.jsx)(`span`,{className:`w-3 shrink-0`}),m?d?(0,F.jsx)(le,{className:`h-4 w-4 text-primary/70 shrink-0`}):(0,F.jsx)(fe,{className:`h-4 w-4 text-muted-foreground shrink-0`}):(0,F.jsx)(h,{className:e(`h-4 w-4 shrink-0`,g)}),(0,F.jsx)(`span`,{className:e(`truncate`,r&&_&&`font-medium text-foreground`),children:t.name}),t.size!==void 0&&t.type===`file`&&(0,F.jsx)(`span`,{className:`ml-auto text-[10px] text-muted-foreground/60 opacity-0 group-hover:opacity-100 transition-opacity shrink-0`,children:Pe(t.size)})]}),(0,F.jsx)(te,{children:m&&d&&t.children&&(0,F.jsx)(b.div,{initial:{height:0,opacity:0},animate:{height:`auto`,opacity:1},exit:{height:0,opacity:0},transition:{duration:.12},className:`overflow-hidden`,children:I(t.children).map(e=>(0,F.jsx)(Ne,{node:e,depth:n+1,searchQuery:r},e.id))})})]})}function I(e){return[...e].sort((e,t)=>e.type===`folder`&&t.type===`file`?-1:e.type===`file`&&t.type===`folder`?1:e.name.localeCompare(t.name))}function Pe(e){return e<1024?`${e}B`:`${(e/1024).toFixed(1)}K`}function Fe({fileTree:e}){let t=(0,N.useRef)(null),{searchQuery:n,focusedId:r,setFocusedId:i,expandedFolders:a,toggleFolder:o,selectFile:s,expandFolder:c}=P(),l=(0,N.useMemo)(()=>{let t=[];function r(e){for(let i of I(e)){if(n&&!i.name.toLowerCase().includes(n.toLowerCase()))if(i.children){if(!De(i.children).some(e=>e.name.toLowerCase().includes(n.toLowerCase())))continue}else continue;t.push(i),i.type===`folder`&&a.has(i.id)&&i.children&&r(i.children)}}return r(e),t},[e,a,n]),u=(0,N.useCallback)(e=>{if(!r){l.length>0&&i(l[0].id);return}let t=l.findIndex(e=>e.id===r);if(t===-1)return;let n=l[t];switch(e.key){case`ArrowDown`:e.preventDefault(),t0&&(i(l[t-1].id),d(l[t-1].id));break;case`ArrowRight`:if(e.preventDefault(),n.type===`folder`){if(!a.has(n.id))c(n.id);else if(n.children&&n.children.length>0){let e=I(n.children)[0];i(e.id)}}break;case`ArrowLeft`:e.preventDefault(),n.type===`folder`&&a.has(n.id)&&o(n.id);break;case`Enter`:case` `:e.preventDefault(),n.type===`folder`?o(n.id):s(n);break}},[r,l,a,i,c,o,s]);function d(e){(t.current?.querySelector(`[data-node-id="${e}"]`))?.scrollIntoView({block:`nearest`})}return(0,F.jsx)(`div`,{ref:t,className:`flex-1 overflow-y-auto scrollbar-thin p-1 outline-none`,tabIndex:0,onKeyDown:u,children:e.length===0?(0,F.jsx)(`div`,{className:`flex items-center justify-center h-32 text-xs text-muted-foreground`,children:`No files`}):I(e).map(e=>(0,F.jsx)(Ne,{node:e,depth:0,searchQuery:n},e.id))})}function Ie({fileTree:e}){let{searchQuery:t,setSearchQuery:n,collapseAll:r,expandAll:i}=P();return(0,F.jsxs)(`div`,{className:`p-2 border-b border-border space-y-2`,children:[(0,F.jsxs)(`div`,{className:`relative`,children:[(0,F.jsx)(ee,{className:`absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground`}),(0,F.jsx)(`input`,{type:`text`,placeholder:`Search files, folders...`,value:t,onChange:e=>n(e.target.value),className:`w-full rounded-md border border-border bg-background pl-8 pr-8 py-1.5 text-xs text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring`}),t&&(0,F.jsx)(`button`,{onClick:()=>n(``),className:`absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground`,children:(0,F.jsx)(y,{className:`h-3.5 w-3.5`})})]}),(0,F.jsxs)(`div`,{className:`flex items-center gap-1`,children:[(0,F.jsx)(`button`,{onClick:()=>i(e),title:`Expand All`,className:`flex h-6 w-6 items-center justify-center rounded text-muted-foreground hover:text-foreground hover:bg-accent transition-colors`,children:(0,F.jsx)(ue,{className:`h-3.5 w-3.5`})}),(0,F.jsx)(`button`,{onClick:r,title:`Collapse All`,className:`flex h-6 w-6 items-center justify-center rounded text-muted-foreground hover:text-foreground hover:bg-accent transition-colors`,children:(0,F.jsx)(ce,{className:`h-3.5 w-3.5`})})]})]})}function Le({details:e}){return(0,F.jsx)(`div`,{className:`space-y-4 p-4 overflow-y-auto scrollbar-thin h-full`,children:(0,F.jsxs)(`div`,{className:`space-y-3`,children:[(0,F.jsxs)(L,{title:`File Info`,children:[(0,F.jsx)(R,{icon:h,label:`Name`,value:e.name}),(0,F.jsx)(R,{icon:de,label:`Path`,value:e.path,mono:!0}),e.extension&&(0,F.jsx)(R,{icon:pe,label:`Extension`,value:`.${e.extension}`}),e.language&&(0,F.jsx)(R,{icon:c,label:`Language`,value:e.language}),(0,F.jsx)(R,{icon:_,label:`Est. Size`,value:n(e.estimatedSize)})]}),e.imports.length>0&&(0,F.jsx)(L,{title:`Imports`,children:(0,F.jsx)(`div`,{className:`flex flex-wrap gap-1.5`,children:e.imports.map((e,t)=>(0,F.jsx)(z,{variant:`import`,children:e},t))})}),e.exports.length>0&&(0,F.jsx)(L,{title:`Exports`,children:(0,F.jsx)(`div`,{className:`flex flex-wrap gap-1.5`,children:e.exports.map((e,t)=>(0,F.jsx)(z,{variant:`export`,children:e},t))})}),e.relatedModules.length>0&&(0,F.jsx)(L,{title:`Related Modules`,children:(0,F.jsx)(`div`,{className:`flex flex-wrap gap-1.5`,children:e.relatedModules.map((e,t)=>(0,F.jsx)(z,{variant:`module`,children:e},t))})}),e.dependencies.length>0&&(0,F.jsx)(L,{title:`Dependencies`,children:(0,F.jsx)(`div`,{className:`flex flex-wrap gap-1.5`,children:e.dependencies.map((e,t)=>(0,F.jsx)(z,{variant:`dep`,children:e},t))})})]})})}function L({title:e,children:t}){return(0,F.jsxs)(`div`,{className:`rounded-lg border border-border p-3`,children:[(0,F.jsx)(`h4`,{className:`text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2.5`,children:e}),t]})}function R({icon:t,label:n,value:r,mono:i}){return(0,F.jsxs)(`div`,{className:`flex items-center justify-between py-1`,children:[(0,F.jsxs)(`span`,{className:`flex items-center gap-2 text-xs text-muted-foreground`,children:[(0,F.jsx)(t,{className:`h-3.5 w-3.5`}),n]}),(0,F.jsx)(`span`,{className:e(`text-xs text-foreground max-w-[60%] truncate text-right`,i&&`font-mono text-[11px]`),children:r})]})}function z({children:t,variant:n}){return(0,F.jsx)(`span`,{className:e(`inline-flex items-center rounded-md border px-2 py-0.5 text-[11px] font-medium`,{import:`bg-blue-500/10 text-blue-400 border-blue-500/20`,export:`bg-emerald-500/10 text-emerald-400 border-emerald-500/20`,module:`bg-amber-500/10 text-amber-400 border-amber-500/20`,dep:`bg-violet-500/10 text-violet-400 border-violet-500/20`}[n]),children:t})}function Re(e,t){(t==null||t>e.length)&&(t=e.length);for(var n=0,r=Array(t);n=e.length?e.apply(this,r):function(){var e=[...arguments];return t.apply(n,[].concat(r,e))}}}function V(e){return{}.toString.call(e).includes(`Object`)}function tt(e){return!Object.keys(e).length}function H(e){return typeof e==`function`}function nt(e,t){return Object.prototype.hasOwnProperty.call(e,t)}function rt(e,t){return V(t)||U(`changeType`),Object.keys(t).some(function(t){return!nt(e,t)})&&U(`changeField`),t}function it(e){H(e)||U(`selectorType`)}function at(e){H(e)||V(e)||U(`handlerType`),V(e)&&Object.values(e).some(function(e){return!H(e)})&&U(`handlersType`)}function ot(e){e||U(`initialIsRequired`),V(e)||U(`initialType`),tt(e)&&U(`initialContent`)}function st(e,t){throw Error(e[t]||e.default)}var U=B(st)({initialIsRequired:`initial state is required`,initialType:`initial state should be an object`,initialContent:`initial state shouldn't be an empty object`,handlerType:`handler should be an object or a function`,handlersType:`all handlers should be a functions`,selectorType:`selector should be a function`,changeType:`provided value of changes should be an object`,changeField:`it seams you want to change a field in the state which is not specified in the "initial" state`,default:"an unknown error accured in `state-local` package"}),W={changes:rt,selector:it,handler:at,initial:ot};function ct(e){var t=arguments.length>1&&arguments[1]!==void 0?arguments[1]:{};W.initial(e),W.handler(t);var n={current:e},r=B(dt)(n,t),i=B(ut)(n),a=B(W.changes)(e),o=B(lt)(n);function s(){var e=arguments.length>0&&arguments[0]!==void 0?arguments[0]:function(e){return e};return W.selector(e),e(n.current)}function c(e){et(r,i,a,o)(e)}return[s,c]}function lt(e,t){return H(t)?t(e.current):t}function ut(e,t){return e.current=$e($e({},e.current),t),t}function dt(e,t,n){return H(t)?t(e.current):Object.keys(n).forEach(function(n){return t[n]?.call(t,e.current[n])}),n}var ft={create:ct},pt={paths:{vs:`https://cdn.jsdelivr.net/npm/monaco-editor@0.55.1/min/vs`}};function mt(e){return function t(){var n=this,r=[...arguments];return r.length>=e.length?e.apply(this,r):function(){var e=[...arguments];return t.apply(n,[].concat(r,e))}}}function ht(e){return{}.toString.call(e).includes(`Object`)}function gt(e){return e||bt(`configIsRequired`),ht(e)||bt(`configType`),e.urls?(_t(),{paths:{vs:e.urls.monacoBase}}):e}function _t(){console.warn(yt.deprecation)}function vt(e,t){throw Error(e[t]||e.default)}var yt={configIsRequired:`the configuration object is required`,configType:`the configuration object should be an object`,default:"an unknown error accured in `@monaco-editor/loader` package",deprecation:`Deprecation warning! - You are using deprecated way of configuration. - - Instead of using - monaco.config({ urls: { monacoBase: '...' } }) - use - monaco.config({ paths: { vs: '...' } }) - - For more please check the link https://github.com/suren-atoyan/monaco-loader#config - `},bt=mt(vt)(yt),xt={config:gt},St=function(){var e=[...arguments];return function(t){return e.reduceRight(function(e,t){return t(e)},t)}};function Ct(e,t){return Object.keys(t).forEach(function(n){t[n]instanceof Object&&e[n]&&Object.assign(t[n],Ct(e[n],t[n]))}),We(We({},e),t)}var wt={type:`cancelation`,msg:`operation is manually canceled`};function G(e){var t=!1,n=new Promise(function(n,r){e.then(function(e){return t?r(wt):n(e)}),e.catch(r)});return n.cancel=function(){return t=!0},n}var Tt=[`monaco`],Et=qe(ft.create({config:pt,isInitialized:!1,resolve:null,reject:null,monaco:null}),2),K=Et[0],q=Et[1];function Dt(e){var t=xt.config(e),n=t.monaco,r=Ge(t,Tt);q(function(e){return{config:Ct(e.config,r),monaco:n}})}function Ot(){var e=K(function(e){return{monaco:e.monaco,isInitialized:e.isInitialized,resolve:e.resolve}});if(!e.isInitialized){if(q({isInitialized:!0}),e.monaco)return e.resolve(e.monaco),G(Ft);if(window.monaco&&window.monaco.editor)return Nt(window.monaco),e.resolve(window.monaco),G(Ft);St(kt,jt)(Mt)}return G(Ft)}function kt(e){return document.body.appendChild(e)}function At(e){var t=document.createElement(`script`);return e&&(t.src=e),t}function jt(e){var t=K(function(e){return{config:e.config,reject:e.reject}}),n=At(`${t.config.paths.vs}/loader.js`);return n.onload=function(){return e()},n.onerror=t.reject,n}function Mt(){var e=K(function(e){return{config:e.config,resolve:e.resolve,reject:e.reject}}),t=window.require;t.config(e.config),t([`vs/editor/editor.main`],function(t){var n=t.m||t;Nt(n),e.resolve(n)},function(t){e.reject(t)})}function Nt(e){K().monaco||q({monaco:e})}function Pt(){return K(function(e){return e.monaco})}var Ft=new Promise(function(e,t){return q({resolve:e,reject:t})}),It={config:Dt,init:Ot,__getMonacoInstance:Pt},Lt={wrapper:{display:`flex`,position:`relative`,textAlign:`initial`},fullWidth:{width:`100%`},hide:{display:`none`}},Rt={container:{display:`flex`,height:`100%`,width:`100%`,justifyContent:`center`,alignItems:`center`}};function zt({children:e}){return N.createElement(`div`,{style:Rt.container},e)}var Bt=zt;function Vt({width:e,height:t,isEditorReady:n,loading:r,_ref:i,className:a,wrapperProps:o}){return N.createElement(`section`,{style:{...Lt.wrapper,width:e,height:t},...o},!n&&N.createElement(Bt,null,r),N.createElement(`div`,{ref:i,style:{...Lt.fullWidth,...!n&&Lt.hide},className:a}))}var Ht=(0,N.memo)(Vt);function Ut(e){(0,N.useEffect)(e,[])}var Wt=Ut;function Gt(e,t,n=!0){let r=(0,N.useRef)(!0);(0,N.useEffect)(r.current||!n?()=>{r.current=!1}:e,t)}var J=Gt;function Y(){}function X(e,t,n,r){return Kt(e,r)||qt(e,t,n,r)}function Kt(e,t){return e.editor.getModel(Jt(e,t))}function qt(e,t,n,r){return e.editor.createModel(t,n,r?Jt(e,r):void 0)}function Jt(e,t){return e.Uri.parse(t)}function Yt({original:e,modified:t,language:n,originalLanguage:r,modifiedLanguage:i,originalModelPath:a,modifiedModelPath:o,keepCurrentOriginalModel:s=!1,keepCurrentModifiedModel:c=!1,theme:l=`light`,loading:u=`Loading...`,options:d={},height:f=`100%`,width:p=`100%`,className:m,wrapperProps:h={},beforeMount:g=Y,onMount:_=Y}){let[v,y]=(0,N.useState)(!1),[b,x]=(0,N.useState)(!0),S=(0,N.useRef)(null),C=(0,N.useRef)(null),w=(0,N.useRef)(null),T=(0,N.useRef)(_),E=(0,N.useRef)(g),D=(0,N.useRef)(!1);Wt(()=>{let e=It.init();return e.then(e=>(C.current=e)&&x(!1)).catch(e=>e?.type!==`cancelation`&&console.error(`Monaco initialization: error:`,e)),()=>S.current?A():e.cancel()}),J(()=>{if(S.current&&C.current){let t=S.current.getOriginalEditor(),i=X(C.current,e||``,r||n||`text`,a||``);i!==t.getModel()&&t.setModel(i)}},[a],v),J(()=>{if(S.current&&C.current){let e=S.current.getModifiedEditor(),r=X(C.current,t||``,i||n||`text`,o||``);r!==e.getModel()&&e.setModel(r)}},[o],v),J(()=>{let e=S.current.getModifiedEditor();e.getOption(C.current.editor.EditorOption.readOnly)?e.setValue(t||``):t!==e.getValue()&&(e.executeEdits(``,[{range:e.getModel().getFullModelRange(),text:t||``,forceMoveMarkers:!0}]),e.pushUndoStop())},[t],v),J(()=>{S.current?.getModel()?.original.setValue(e||``)},[e],v),J(()=>{let{original:e,modified:t}=S.current.getModel();C.current.editor.setModelLanguage(e,r||n||`text`),C.current.editor.setModelLanguage(t,i||n||`text`)},[n,r,i],v),J(()=>{C.current?.editor.setTheme(l)},[l],v),J(()=>{S.current?.updateOptions(d)},[d],v);let O=(0,N.useCallback)(()=>{if(!C.current)return;E.current(C.current);let s=X(C.current,e||``,r||n||`text`,a||``),c=X(C.current,t||``,i||n||`text`,o||``);S.current?.setModel({original:s,modified:c})},[n,t,i,e,r,a,o]),k=(0,N.useCallback)(()=>{!D.current&&w.current&&(S.current=C.current.editor.createDiffEditor(w.current,{automaticLayout:!0,...d}),O(),C.current?.editor.setTheme(l),y(!0),D.current=!0)},[d,l,O]);(0,N.useEffect)(()=>{v&&T.current(S.current,C.current)},[v]),(0,N.useEffect)(()=>{!b&&!v&&k()},[b,v,k]);function A(){let e=S.current?.getModel();s||e?.original?.dispose(),c||e?.modified?.dispose(),S.current?.dispose()}return N.createElement(Ht,{width:p,height:f,isEditorReady:v,loading:u,_ref:w,className:m,wrapperProps:h})}(0,N.memo)(Yt);function Xt(e){let t=(0,N.useRef)();return(0,N.useEffect)(()=>{t.current=e},[e]),t.current}var Zt=Xt,Z=new Map;function Qt({defaultValue:e,defaultLanguage:t,defaultPath:n,value:r,language:i,path:a,theme:o=`light`,line:s,loading:c=`Loading...`,options:l={},overrideServices:u={},saveViewState:d=!0,keepCurrentModel:f=!1,width:p=`100%`,height:m=`100%`,className:h,wrapperProps:g={},beforeMount:_=Y,onMount:v=Y,onChange:y,onValidate:b=Y}){let[x,S]=(0,N.useState)(!1),[C,w]=(0,N.useState)(!0),T=(0,N.useRef)(null),E=(0,N.useRef)(null),D=(0,N.useRef)(null),O=(0,N.useRef)(v),k=(0,N.useRef)(_),A=(0,N.useRef)(),ee=(0,N.useRef)(r),j=Zt(a),te=(0,N.useRef)(!1),M=(0,N.useRef)(!1);Wt(()=>{let e=It.init();return e.then(e=>(T.current=e)&&w(!1)).catch(e=>e?.type!==`cancelation`&&console.error(`Monaco initialization: error:`,e)),()=>E.current?re():e.cancel()}),J(()=>{let o=X(T.current,e||r||``,t||i||``,a||n||``);o!==E.current?.getModel()&&(d&&Z.set(j,E.current?.saveViewState()),E.current?.setModel(o),d&&E.current?.restoreViewState(Z.get(a)))},[a],x),J(()=>{E.current?.updateOptions(l)},[l],x),J(()=>{!E.current||r===void 0||(E.current.getOption(T.current.editor.EditorOption.readOnly)?E.current.setValue(r):r!==E.current.getValue()&&(M.current=!0,E.current.executeEdits(``,[{range:E.current.getModel().getFullModelRange(),text:r,forceMoveMarkers:!0}]),E.current.pushUndoStop(),M.current=!1))},[r],x),J(()=>{let e=E.current?.getModel();e&&i&&T.current?.editor.setModelLanguage(e,i)},[i],x),J(()=>{s!==void 0&&E.current?.revealLine(s)},[s],x),J(()=>{T.current?.editor.setTheme(o)},[o],x);let ne=(0,N.useCallback)(()=>{if(!(!D.current||!T.current)&&!te.current){k.current(T.current);let c=a||n,f=X(T.current,r||e||``,t||i||``,c||``);E.current=T.current?.editor.create(D.current,{model:f,automaticLayout:!0,...l},u),d&&E.current.restoreViewState(Z.get(c)),T.current.editor.setTheme(o),s!==void 0&&E.current.revealLine(s),S(!0),te.current=!0}},[e,t,n,r,i,a,l,u,d,o,s]);(0,N.useEffect)(()=>{x&&O.current(E.current,T.current)},[x]),(0,N.useEffect)(()=>{!C&&!x&&ne()},[C,x,ne]),ee.current=r,(0,N.useEffect)(()=>{x&&y&&(A.current?.dispose(),A.current=E.current?.onDidChangeModelContent(e=>{M.current||y(E.current.getValue(),e)}))},[x,y]),(0,N.useEffect)(()=>{if(x){let e=T.current.editor.onDidChangeMarkers(e=>{let t=E.current.getModel()?.uri;if(t&&e.find(e=>e.path===t.path)){let e=T.current.editor.getModelMarkers({resource:t});b?.(e)}});return()=>{e?.dispose()}}return()=>{}},[x,b]);function re(){A.current?.dispose(),f?d&&Z.set(a,E.current.saveViewState()):E.current.getModel()?.dispose(),E.current.dispose()}return N.createElement(Ht,{width:p,height:m,isEditorReady:x,loading:c,_ref:D,className:h,wrapperProps:g})}var $t=(0,N.memo)(Qt);function en({node:e}){let[t,n]=(0,N.useState)(!1),r=(0,N.useRef)(null),i=ke(e),a=Oe(e.extension),o=e=>{r.current=e},s=(0,N.useCallback)(()=>{navigator.clipboard.writeText(i),n(!0),setTimeout(()=>n(!1),2e3)},[i]);return(0,F.jsxs)(`div`,{className:`flex flex-col h-full`,children:[(0,F.jsxs)(`div`,{className:`flex items-center justify-between px-3 py-2 border-b border-border bg-card/50`,children:[(0,F.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,F.jsx)(`span`,{className:`text-xs font-mono text-muted-foreground`,children:e.name}),(0,F.jsx)(`span`,{className:`text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground`,children:a})]}),(0,F.jsxs)(`button`,{onClick:s,className:`flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors`,children:[t?(0,F.jsx)(E,{className:`h-3.5 w-3.5 text-green-400`}):(0,F.jsx)(u,{className:`h-3.5 w-3.5`}),t?`Copied`:`Copy`]})]}),(0,F.jsx)(`div`,{className:`flex-1 min-h-0`,children:(0,F.jsx)($t,{height:`100%`,language:a,value:i,theme:`vs-dark`,onMount:o,options:{readOnly:!0,minimap:{enabled:!1},fontSize:13,lineNumbers:`on`,scrollBeyondLastLine:!1,wordWrap:`on`,padding:{top:12},renderLineHighlight:`none`,overviewRulerLanes:0,hideCursorInOverviewRuler:!0,scrollbar:{vertical:`auto`,horizontal:`auto`,verticalScrollbarSize:8},guides:{indentation:!0,bracketPairs:!0},find:{addExtraSpaceOnTop:!1}}})})]})}function tn({path:t,onNavigate:n}){let r=t.split(`/`).filter(Boolean);return(0,F.jsxs)(`div`,{className:`flex items-center gap-1 text-xs text-muted-foreground overflow-x-auto scrollbar-thin`,children:[(0,F.jsx)(`button`,{onClick:()=>n?.(`/`),className:`flex items-center gap-1 hover:text-foreground transition-colors shrink-0`,children:(0,F.jsx)(me,{className:`h-3 w-3`})}),r.map((t,i)=>{let a=`/`+r.slice(0,i+1).join(`/`),o=i===r.length-1;return(0,F.jsxs)(`div`,{className:`flex items-center gap-1 shrink-0`,children:[(0,F.jsx)(s,{className:`h-3 w-3 text-muted-foreground/50`}),(0,F.jsx)(`button`,{onClick:()=>!o&&n?.(a),className:e(`transition-colors`,o?`text-foreground font-medium`:`hover:text-foreground`),children:t})]},a)})]})}function nn({fileTree:e}){let{selectedNode:t,detailsTab:n,setDetailsTab:r,expandedFolders:i,expandFolder:a}=P(),{width:o,onMouseDown:s}=ye({initialWidth:280,minWidth:200,maxWidth:420,direction:`left`}),[c,u]=(0,N.useState)(!1),d=(0,N.useMemo)(()=>e.filter(e=>e.type===`folder`),[e]);(0,N.useEffect)(()=>{i.size===0&&d.length>0&&d.forEach(e=>a(e.id))},[i.size,a,d]);let f=(0,N.useMemo)(()=>!t||t.type===`folder`?null:be(t,e),[t,e]);return(0,F.jsxs)(`div`,{className:`flex h-[calc(100vh-14rem)] rounded-xl border border-border bg-card overflow-hidden`,children:[!c&&(0,F.jsxs)(`div`,{className:`flex flex-col border-r border-border`,style:{width:o},children:[(0,F.jsxs)(`div`,{className:`flex items-center justify-between px-3 py-2 border-b border-border`,children:[(0,F.jsx)(`span`,{className:`text-xs font-medium text-foreground`,children:`Explorer`}),(0,F.jsx)(`button`,{onClick:()=>u(!0),className:`flex h-5 w-5 items-center justify-center rounded text-muted-foreground hover:text-foreground hover:bg-accent transition-colors`,children:(0,F.jsx)(l,{className:`h-3.5 w-3.5`})})]}),(0,F.jsx)(Ie,{fileTree:e}),(0,F.jsx)(Fe,{fileTree:e})]}),!c&&(0,F.jsx)(`div`,{className:`w-1 cursor-col-resize hover:bg-primary/20 active:bg-primary/30 transition-colors shrink-0`,onMouseDown:s}),(0,F.jsxs)(`div`,{className:`flex-1 flex flex-col min-w-0`,children:[(0,F.jsxs)(`div`,{className:`flex items-center gap-3 px-4 py-2 border-b border-border`,children:[c&&(0,F.jsx)(`button`,{onClick:()=>u(!1),className:`flex h-6 w-6 items-center justify-center rounded text-muted-foreground hover:text-foreground hover:bg-accent transition-colors`,children:(0,F.jsx)(p,{className:`h-3.5 w-3.5`})}),t?(0,F.jsx)(tn,{path:t.path}):(0,F.jsx)(`span`,{className:`text-xs text-muted-foreground`,children:`Select a file to view`}),t&&t.type===`file`&&(0,F.jsxs)(`div`,{className:`ml-auto flex items-center gap-1`,children:[(0,F.jsx)(rn,{active:n===`preview`,onClick:()=>r(`preview`),children:`Preview`}),(0,F.jsx)(rn,{active:n===`details`,onClick:()=>r(`details`),children:`Details`})]})]}),(0,F.jsx)(`div`,{className:`flex-1 min-h-0`,children:t?t.type===`folder`?(0,F.jsxs)(`div`,{className:`flex flex-col items-center justify-center h-full text-center px-6`,children:[(0,F.jsx)(g,{className:`h-10 w-10 text-muted-foreground/30 mb-3`}),(0,F.jsxs)(`p`,{className:`text-sm text-muted-foreground`,children:[`Folder: `,t.name]}),(0,F.jsxs)(`p`,{className:`text-xs text-muted-foreground/70 mt-1`,children:[t.children?.length||0,` items`]})]}):n===`preview`?(0,F.jsx)(en,{node:t}):f?(0,F.jsx)(Le,{details:f}):null:(0,F.jsxs)(`div`,{className:`flex flex-col items-center justify-center h-full text-center px-6`,children:[(0,F.jsx)(g,{className:`h-10 w-10 text-muted-foreground/30 mb-3`}),(0,F.jsx)(`p`,{className:`text-sm text-muted-foreground`,children:`Select a file from the explorer`}),(0,F.jsx)(`p`,{className:`text-xs text-muted-foreground/70 mt-1`,children:`Use arrow keys to navigate, Enter to select`})]})})]})]})}function rn({active:t,onClick:n,children:r}){return(0,F.jsx)(`button`,{onClick:n,className:e(`px-2.5 py-1 rounded text-xs font-medium transition-colors`,t?`bg-accent text-foreground`:`text-muted-foreground hover:text-foreground`),children:r})}var an=[`Overview`,`Explorer`];function on(e){let t=A(),[n,r]=(0,N.useState)(`Overview`),i=(0,N.useMemo)(()=>t.repositories.find(t=>t.id===e)||null,[e,t.repositories]);return{...t,repository:i,tabs:an,activeTab:n,setActiveTab:r,notFound:!i,redirectToAnalysis:i?.status===`analysing`?`/analysis/${i.id}`:null}}function sn(e){let t=!!e?.fileTree.length;return{repository:e,fileTree:e?.fileTree||[],meta:e?.meta||null,loading:!1,error:null,empty:!t,success:t,retry:()=>void 0,refresh:()=>void 0,source:e?.dataSource||null}}function cn(){let{id:t}=x(),r=S(),{repository:i,tabs:o,activeTab:s,setActiveTab:l,redirectToAnalysis:u}=on(t),p=sn(i);return i?u?(0,F.jsx)(w,{to:u,replace:!0}):(0,F.jsxs)(`div`,{children:[(0,F.jsxs)(`button`,{onClick:()=>r(`/repositories`),className:`flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground mb-4 transition-colors`,children:[(0,F.jsx)(a,{className:`h-4 w-4`}),` Back to Repositories`]}),(0,F.jsxs)(M,{title:i.name,description:i.description||`${i.source===`github`?`Imported from GitHub`:`Uploaded archive`}`,children:[(0,F.jsx)(ie,{source:p.source}),(0,F.jsx)(re,{variant:ae[i.status],children:i.status})]}),i.status===`error`?(0,F.jsxs)(`div`,{className:`rounded-xl border border-destructive/50 bg-destructive/5 p-6`,children:[(0,F.jsx)(`p`,{className:`text-sm text-destructive`,children:i.errorMessage||`An error occurred during analysis.`}),(0,F.jsx)(`button`,{onClick:()=>r(`/upload`),className:`mt-3 text-sm text-primary hover:underline`,children:`Try uploading again`})]}):i.status===`completed`&&i.meta?(0,F.jsxs)(F.Fragment,{children:[(0,F.jsx)(`div`,{className:`flex items-center gap-1 border-b border-border mb-6`,children:o.map(t=>(0,F.jsxs)(`button`,{onClick:()=>l(t),className:e(`px-4 py-2.5 text-sm font-medium transition-colors relative`,s===t?`text-foreground`:`text-muted-foreground hover:text-foreground`),children:[t,s===t&&(0,F.jsx)(b.div,{layoutId:`repo-tab`,className:`absolute bottom-0 left-0 right-0 h-0.5 bg-primary rounded-full`})]},t))}),s===`Overview`&&(0,F.jsxs)(b.div,{initial:{opacity:0,y:8},animate:{opacity:1,y:0},className:`space-y-6`,children:[(0,F.jsxs)(`div`,{className:`grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4`,children:[(0,F.jsx)(Q,{icon:f,label:`Language`,value:i.meta.language}),(0,F.jsx)(Q,{icon:d,label:`Framework`,value:i.meta.framework}),(0,F.jsx)(Q,{icon:h,label:`Files`,value:String(i.meta.totalFiles)}),(0,F.jsx)(Q,{icon:de,label:`Folders`,value:String(i.meta.totalFolders)})]}),(0,F.jsxs)(`div`,{className:`grid grid-cols-1 lg:grid-cols-2 gap-6`,children:[(0,F.jsxs)(`div`,{className:`rounded-xl border border-border bg-card p-5`,children:[(0,F.jsx)(`h3`,{className:`text-sm font-medium text-foreground mb-4`,children:`Repository Information`}),(0,F.jsxs)(`div`,{className:`space-y-3`,children:[(0,F.jsx)($,{icon:T,label:`Name`,value:i.name}),(0,F.jsx)($,{icon:i.source===`github`?c:k,label:`Source`,value:i.source===`github`?`GitHub`:`File Upload`}),i.sourceUrl&&(0,F.jsx)($,{icon:c,label:`URL`,value:i.sourceUrl,mono:!0}),(0,F.jsx)($,{icon:m,label:`Uploaded`,value:new Date(i.uploadedAt).toLocaleString()}),i.analysedAt&&(0,F.jsx)($,{icon:m,label:`Analysed`,value:new Date(i.analysedAt).toLocaleString()}),i.size>0&&(0,F.jsx)($,{icon:v,label:`Size`,value:n(i.size)})]})]}),(0,F.jsxs)(`div`,{className:`rounded-xl border border-border bg-card p-5`,children:[(0,F.jsx)(`h3`,{className:`text-sm font-medium text-foreground mb-4`,children:`Detected Configuration`}),(0,F.jsxs)(`div`,{className:`space-y-3`,children:[i.meta.entryPoint&&(0,F.jsx)($,{icon:h,label:`Entry Point`,value:i.meta.entryPoint,mono:!0}),i.meta.packageManager&&(0,F.jsx)($,{icon:v,label:`Package Manager`,value:i.meta.packageManager}),(0,F.jsx)($,{icon:j,label:`README`,value:i.meta.hasReadme?`Found`:`Not found`}),(0,F.jsx)($,{icon:ge,label:`License`,value:i.meta.licenseName||`None detected`})]}),i.meta.configFiles.length>0&&(0,F.jsxs)(`div`,{className:`mt-4 pt-4 border-t border-border`,children:[(0,F.jsxs)(`p`,{className:`text-xs font-medium text-muted-foreground mb-2 flex items-center gap-1.5`,children:[(0,F.jsx)(_e,{className:`h-3.5 w-3.5`}),`Configuration Files`]}),(0,F.jsx)(`div`,{className:`flex flex-wrap gap-1.5`,children:i.meta.configFiles.map(e=>(0,F.jsx)(`span`,{className:`inline-flex items-center rounded-md bg-muted px-2 py-0.5 text-xs font-mono text-muted-foreground`,children:e},e))})]})]})]})]}),s===`Explorer`&&(0,F.jsx)(b.div,{initial:{opacity:0,y:8},animate:{opacity:1,y:0},children:(0,F.jsx)(nn,{fileTree:p.fileTree})})]}):null]}):(0,F.jsxs)(`div`,{children:[(0,F.jsxs)(`button`,{onClick:()=>r(`/repositories`),className:`flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground mb-4 transition-colors`,children:[(0,F.jsx)(a,{className:`h-4 w-4`}),` Back to Repositories`]}),(0,F.jsx)(ne,{icon:T,title:`Repository not found`,description:`The repository you're looking for doesn't exist or has been removed.`,action:{label:`View All Repositories`,onClick:()=>r(`/repositories`)}})]})}function Q({icon:e,label:t,value:n}){return(0,F.jsxs)(`div`,{className:`rounded-xl border border-border bg-card p-4`,children:[(0,F.jsxs)(`div`,{className:`flex items-center gap-2 mb-2`,children:[(0,F.jsx)(e,{className:`h-4 w-4 text-muted-foreground`}),(0,F.jsx)(`span`,{className:`text-xs font-medium text-muted-foreground uppercase tracking-wider`,children:t})]}),(0,F.jsx)(`p`,{className:`text-lg font-semibold text-foreground`,children:n})]})}function $({icon:t,label:n,value:r,mono:i}){return(0,F.jsxs)(`div`,{className:`flex items-center justify-between`,children:[(0,F.jsxs)(`span`,{className:`flex items-center gap-2 text-sm text-muted-foreground`,children:[(0,F.jsx)(t,{className:`h-3.5 w-3.5`}),n]}),(0,F.jsx)(`span`,{className:e(`text-sm text-foreground`,i&&`font-mono text-xs`),children:r})]})}export{cn as RepositoryDetailPage}; \ No newline at end of file diff --git a/dist/assets/SettingsPage-vKuDSF0h.js b/dist/assets/SettingsPage-vKuDSF0h.js deleted file mode 100644 index cf7d96ce..00000000 --- a/dist/assets/SettingsPage-vKuDSF0h.js +++ /dev/null @@ -1 +0,0 @@ -import{a as e,c as t,i as n,p as r,s as i}from"./client-S4ekmpXx.js";import{t as a}from"./PageHeader-DsNWbEI1.js";import{t as o}from"./ai-DpySo4Gb.js";var s=r(t(),1),c=[`General`,`AI Providers`,`Appearance`,`Notifications`,`API Keys`],l={openai:`gpt-4o-mini`,anthropic:`claude-3-5-haiku-latest`,gemini:`gemini-1.5-flash`,openrouter:`openai/gpt-4o-mini`,ollama:`llama3.2`};function u(){let[e,t]=(0,s.useState)(`General`),[r,i]=(0,s.useState)(null),[a,u]=(0,s.useState)(`openai`),[d,f]=(0,s.useState)(``),[p,m]=(0,s.useState)(l.openai),[h,g]=(0,s.useState)(``),[_,v]=(0,s.useState)(!1),[y,b]=(0,s.useState)(!1),[x,S]=(0,s.useState)(null),[C,w]=(0,s.useState)(null);return(0,s.useEffect)(()=>{let e=!1;async function t(){try{let t=await o.getConfig();if(e)return;i(t),t.provider&&(u(t.provider),m(t.model||l[t.provider]),g(t.baseUrl||``))}catch(t){e||w(n(t))}}return t(),()=>{e=!0}},[]),{tabs:c,activeTab:e,setActiveTab:t,aiConfig:r,provider:a,setProvider:(0,s.useCallback)(e=>{u(e),m(l[e]),S(null),w(null)},[]),apiKey:d,setApiKey:f,model:p,setModel:m,baseUrl:h,setBaseUrl:g,saveAiConfig:(0,s.useCallback)(async()=>{v(!0),w(null),S(null);try{let e=await o.saveConfig({provider:a,apiKey:d.trim()||void 0,model:p.trim()||l[a],baseUrl:h.trim()||void 0});i(e),f(``),S(`AI provider saved.`)}catch(e){w(n(e))}finally{v(!1)}},[d,h,p,a]),testAiConfig:(0,s.useCallback)(async()=>{b(!0),w(null),S(null);try{let e=await o.testConfig({provider:a,apiKey:d.trim()||void 0,model:p.trim()||l[a],baseUrl:h.trim()||void 0});S(e.message)}catch(e){w(n(e))}finally{b(!1)}},[d,h,p,a]),testing:y,statusMessage:x,loading:_,error:C,empty:!1,success:!0,retry:()=>void 0,refresh:()=>void 0}}var d=i();function f(){let t=u(),{tabs:n,activeTab:r,setActiveTab:i}=t;return(0,d.jsxs)(`div`,{className:`max-w-3xl`,children:[(0,d.jsx)(a,{title:`Settings`,description:`Manage your account and preferences`}),(0,d.jsx)(`div`,{className:`flex items-center gap-1 border-b border-border mb-6`,children:n.map(t=>(0,d.jsxs)(`button`,{onClick:()=>i(t),className:e(`px-4 py-2.5 text-sm font-medium transition-colors relative`,r===t?`text-foreground`:`text-muted-foreground hover:text-foreground`),children:[t,r===t&&(0,d.jsx)(`div`,{className:`absolute bottom-0 left-0 right-0 h-0.5 bg-primary rounded-full`})]},t))}),(0,d.jsxs)(`div`,{className:`space-y-6`,children:[r===`General`&&(0,d.jsxs)(`div`,{className:`space-y-6`,children:[(0,d.jsxs)(`div`,{className:`rounded-xl border border-border bg-card p-6`,children:[(0,d.jsx)(`h3`,{className:`text-sm font-medium text-foreground mb-4`,children:`Profile`}),(0,d.jsxs)(`div`,{className:`space-y-4`,children:[(0,d.jsxs)(`div`,{children:[(0,d.jsx)(`label`,{className:`block text-xs font-medium text-muted-foreground mb-1.5`,children:`Display Name`}),(0,d.jsx)(`input`,{type:`text`,defaultValue:`Developer`,className:`w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-ring`})]}),(0,d.jsxs)(`div`,{children:[(0,d.jsx)(`label`,{className:`block text-xs font-medium text-muted-foreground mb-1.5`,children:`Email`}),(0,d.jsx)(`input`,{type:`email`,placeholder:`you@example.com`,className:`w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-ring`})]})]}),(0,d.jsx)(`div`,{className:`mt-4 flex justify-end`,children:(0,d.jsx)(`button`,{disabled:!0,className:`rounded-md bg-muted px-4 py-2 text-sm font-medium text-muted-foreground cursor-not-allowed`,children:`Coming Soon`})})]}),(0,d.jsxs)(`div`,{className:`rounded-xl border border-destructive/50 bg-card p-6`,children:[(0,d.jsx)(`h3`,{className:`text-sm font-medium text-destructive mb-2`,children:`Danger Zone`}),(0,d.jsx)(`p`,{className:`text-xs text-muted-foreground mb-4`,children:`Permanently delete your account and all data.`}),(0,d.jsx)(`button`,{disabled:!0,className:`rounded-md border border-border px-4 py-2 text-sm font-medium text-muted-foreground cursor-not-allowed`,children:`Coming Soon`})]})]}),r===`AI Providers`&&(0,d.jsxs)(`div`,{className:`rounded-xl border border-border bg-card p-6`,children:[(0,d.jsx)(`h3`,{className:`text-sm font-medium text-foreground mb-2`,children:`AI Provider`}),(0,d.jsx)(`p`,{className:`text-xs text-muted-foreground mb-4`,children:`Keys are stored by the local backend and are never shown again after saving.`}),(0,d.jsx)(`div`,{className:`grid grid-cols-2 sm:grid-cols-5 gap-2 mb-5`,children:[[`openai`,`OpenAI`],[`anthropic`,`Anthropic`],[`gemini`,`Google Gemini`],[`openrouter`,`OpenRouter`],[`ollama`,`Ollama`]].map(([n,r])=>(0,d.jsx)(`button`,{onClick:()=>t.setProvider(n),className:e(`rounded-md border px-3 py-2 text-xs font-medium transition-colors`,t.provider===n?`border-primary bg-primary/10 text-primary`:`border-border text-muted-foreground hover:text-foreground`),children:r},n))}),(0,d.jsxs)(`div`,{className:`space-y-4`,children:[(0,d.jsxs)(`div`,{children:[(0,d.jsx)(`label`,{className:`block text-xs font-medium text-muted-foreground mb-1.5`,children:`Model`}),(0,d.jsx)(`input`,{value:t.model,onChange:e=>t.setModel(e.target.value),className:`w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-ring`})]}),t.provider===`ollama`&&(0,d.jsxs)(`div`,{children:[(0,d.jsx)(`label`,{className:`block text-xs font-medium text-muted-foreground mb-1.5`,children:`Ollama Base URL`}),(0,d.jsx)(`input`,{value:t.baseUrl,onChange:e=>t.setBaseUrl(e.target.value),placeholder:`http://localhost:11434`,className:`w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-ring`})]}),t.provider!==`ollama`&&(0,d.jsxs)(`div`,{children:[(0,d.jsx)(`label`,{className:`block text-xs font-medium text-muted-foreground mb-1.5`,children:`API Key`}),(0,d.jsx)(`input`,{type:`password`,value:t.apiKey,onChange:e=>t.setApiKey(e.target.value),placeholder:t.aiConfig?.provider===t.provider&&t.aiConfig.hasApiKey?`Saved key configured`:`Enter provider API key`,className:`w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-ring`})]})]}),t.error&&(0,d.jsx)(`p`,{className:`mt-4 text-sm text-destructive`,children:t.error}),t.statusMessage&&(0,d.jsx)(`p`,{className:`mt-4 text-sm text-success`,children:t.statusMessage}),(0,d.jsxs)(`div`,{className:`mt-5 flex justify-end gap-2`,children:[(0,d.jsx)(`button`,{onClick:t.testAiConfig,disabled:t.testing,className:`rounded-md border border-border px-4 py-2 text-sm font-medium text-foreground hover:bg-accent disabled:opacity-50 transition-colors`,children:t.testing?`Testing...`:`Test Connection`}),(0,d.jsx)(`button`,{onClick:t.saveAiConfig,disabled:t.loading,className:`rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50 transition-colors`,children:t.loading?`Saving...`:`Save Provider`})]})]}),r===`Appearance`&&(0,d.jsxs)(`div`,{className:`rounded-xl border border-border bg-card p-6`,children:[(0,d.jsx)(`h3`,{className:`text-sm font-medium text-foreground mb-4`,children:`Theme`}),(0,d.jsxs)(`div`,{className:`flex items-center gap-3`,children:[(0,d.jsxs)(`button`,{className:`flex items-center gap-2 rounded-md border-2 border-primary bg-card px-4 py-3 text-sm font-medium`,children:[(0,d.jsx)(`div`,{className:`h-4 w-4 rounded-full bg-[#0a0e1a]`}),`Dark`]}),(0,d.jsxs)(`button`,{disabled:!0,className:`flex items-center gap-2 rounded-md border border-border bg-card px-4 py-3 text-sm font-medium text-muted-foreground cursor-not-allowed`,children:[(0,d.jsx)(`div`,{className:`h-4 w-4 rounded-full bg-white border border-border`}),`Light (Coming Soon)`]})]})]}),r===`Notifications`&&(0,d.jsxs)(`div`,{className:`rounded-xl border border-border bg-card p-6`,children:[(0,d.jsx)(`h3`,{className:`text-sm font-medium text-foreground mb-4`,children:`Notification Preferences`}),(0,d.jsx)(`div`,{className:`space-y-4`,children:[`Analysis complete`,`Error alerts`,`New insights available`].map(e=>(0,d.jsxs)(`div`,{className:`flex items-center justify-between`,children:[(0,d.jsx)(`span`,{className:`text-sm text-foreground`,children:e}),(0,d.jsx)(`button`,{disabled:!0,className:`relative h-5 w-9 rounded-full bg-muted transition-colors cursor-not-allowed`,children:(0,d.jsx)(`div`,{className:`absolute right-0.5 top-0.5 h-4 w-4 rounded-full bg-primary-foreground transition-transform`})})]},e))})]}),r===`API Keys`&&(0,d.jsxs)(`div`,{className:`rounded-xl border border-border bg-card p-6`,children:[(0,d.jsx)(`h3`,{className:`text-sm font-medium text-foreground mb-2`,children:`API Keys`}),(0,d.jsx)(`p`,{className:`text-xs text-muted-foreground mb-4`,children:`Manage API keys for programmatic access.`}),(0,d.jsx)(`div`,{className:`flex items-center justify-center py-8`,children:(0,d.jsx)(`p`,{className:`text-sm text-muted-foreground`,children:`No API keys configured.`})}),(0,d.jsx)(`div`,{className:`flex justify-end`,children:(0,d.jsx)(`button`,{disabled:!0,className:`rounded-md bg-muted px-4 py-2 text-sm font-medium text-muted-foreground cursor-not-allowed`,children:`Coming Soon`})})]})]})]})}export{f as SettingsPage}; \ No newline at end of file diff --git a/dist/assets/UploadPage-04HmGrk-.js b/dist/assets/UploadPage-04HmGrk-.js deleted file mode 100644 index 6d2d0e5e..00000000 --- a/dist/assets/UploadPage-04HmGrk-.js +++ /dev/null @@ -1,5 +0,0 @@ -import{a as e,c as t,i as n,l as r,o as i,p as a,s as o}from"./client-S4ekmpXx.js";import{t as s}from"./arrow-right-DpUoK8Nu.js";import{t as c}from"./circle-alert-BUYmyF7c.js";import{t as l}from"./file-BFCfRogd.js";import{t as u}from"./hard-drive-B0dUssIC.js";import{t as d}from"./x-Bf4UPE8b.js";import{C as f,E as p,S as m,h,n as g,r as _,s as v,t as y,w as b}from"./index-QB2QUwKm.js";import{t as x}from"./PageHeader-DsNWbEI1.js";import{t as ee}from"./DataSourceBadge-D7dhrb9n.js";var S=m(`Calendar`,[[`path`,{d:`M8 2v4`,key:`1cmpym`}],[`path`,{d:`M16 2v4`,key:`4m81vk`}],[`rect`,{width:`18`,height:`18`,x:`3`,y:`4`,rx:`2`,key:`1hopcy`}],[`path`,{d:`M3 10h18`,key:`8toen8`}]]),te=m(`FileArchive`,[[`path`,{d:`M10 12v-1`,key:`v7bkov`}],[`path`,{d:`M10 18v-2`,key:`1cjy8d`}],[`path`,{d:`M10 7V6`,key:`dljcrl`}],[`path`,{d:`M14 2v4a2 2 0 0 0 2 2h4`,key:`tnqrlb`}],[`path`,{d:`M15.5 22H18a2 2 0 0 0 2-2V7l-5-5H6a2 2 0 0 0-2 2v16a2 2 0 0 0 .274 1.01`,key:`gkbcor`}],[`circle`,{cx:`10`,cy:`20`,r:`2`,key:`1xzdoj`}]]),C=r(((e,t)=>{t.exports=`SECRET_DO_NOT_PASS_THIS_OR_YOU_WILL_BE_FIRED`})),w=r(((e,t)=>{var n=C();function r(){}function i(){}i.resetWarningCache=r,t.exports=function(){function e(e,t,r,i,a,o){if(o!==n){var s=Error("Calling PropTypes validators directly is not supported by the `prop-types` package. Use PropTypes.checkPropTypes() to call them. Read more at http://fb.me/use-check-prop-types");throw s.name=`Invariant Violation`,s}}e.isRequired=e;function t(){return e}var a={array:e,bigint:e,bool:e,func:e,number:e,object:e,string:e,symbol:e,any:e,arrayOf:t,element:e,elementType:e,instanceOf:t,node:e,objectOf:t,oneOf:t,oneOfType:t,shape:t,exact:t,checkPropTypes:i,resetWarningCache:r};return a.PropTypes=a,a}})),ne=r(((e,t)=>{t.exports=w()()}));function T(e,t,n,r){function i(e){return e instanceof n?e:new n(function(t){t(e)})}return new(n||=Promise)(function(n,a){function o(e){try{c(r.next(e))}catch(e){a(e)}}function s(e){try{c(r.throw(e))}catch(e){a(e)}}function c(e){e.done?n(e.value):i(e.value).then(o,s)}c((r=r.apply(e,t||[])).next())})}var E=new Map([[`1km`,`application/vnd.1000minds.decision-model+xml`],[`3dml`,`text/vnd.in3d.3dml`],[`3ds`,`image/x-3ds`],[`3g2`,`video/3gpp2`],[`3gp`,`video/3gp`],[`3gpp`,`video/3gpp`],[`3mf`,`model/3mf`],[`7z`,`application/x-7z-compressed`],[`7zip`,`application/x-7z-compressed`],[`123`,`application/vnd.lotus-1-2-3`],[`aab`,`application/x-authorware-bin`],[`aac`,`audio/x-acc`],[`aam`,`application/x-authorware-map`],[`aas`,`application/x-authorware-seg`],[`abw`,`application/x-abiword`],[`ac`,`application/vnd.nokia.n-gage.ac+xml`],[`ac3`,`audio/ac3`],[`acc`,`application/vnd.americandynamics.acc`],[`ace`,`application/x-ace-compressed`],[`acu`,`application/vnd.acucobol`],[`acutc`,`application/vnd.acucorp`],[`adp`,`audio/adpcm`],[`aep`,`application/vnd.audiograph`],[`afm`,`application/x-font-type1`],[`afp`,`application/vnd.ibm.modcap`],[`ahead`,`application/vnd.ahead.space`],[`ai`,`application/pdf`],[`aif`,`audio/x-aiff`],[`aifc`,`audio/x-aiff`],[`aiff`,`audio/x-aiff`],[`air`,`application/vnd.adobe.air-application-installer-package+zip`],[`ait`,`application/vnd.dvb.ait`],[`ami`,`application/vnd.amiga.ami`],[`amr`,`audio/amr`],[`apk`,`application/vnd.android.package-archive`],[`apng`,`image/apng`],[`appcache`,`text/cache-manifest`],[`application`,`application/x-ms-application`],[`apr`,`application/vnd.lotus-approach`],[`arc`,`application/x-freearc`],[`arj`,`application/x-arj`],[`asc`,`application/pgp-signature`],[`asf`,`video/x-ms-asf`],[`asm`,`text/x-asm`],[`aso`,`application/vnd.accpac.simply.aso`],[`asx`,`video/x-ms-asf`],[`atc`,`application/vnd.acucorp`],[`atom`,`application/atom+xml`],[`atomcat`,`application/atomcat+xml`],[`atomdeleted`,`application/atomdeleted+xml`],[`atomsvc`,`application/atomsvc+xml`],[`atx`,`application/vnd.antix.game-component`],[`au`,`audio/x-au`],[`avi`,`video/x-msvideo`],[`avif`,`image/avif`],[`aw`,`application/applixware`],[`azf`,`application/vnd.airzip.filesecure.azf`],[`azs`,`application/vnd.airzip.filesecure.azs`],[`azv`,`image/vnd.airzip.accelerator.azv`],[`azw`,`application/vnd.amazon.ebook`],[`b16`,`image/vnd.pco.b16`],[`bat`,`application/x-msdownload`],[`bcpio`,`application/x-bcpio`],[`bdf`,`application/x-font-bdf`],[`bdm`,`application/vnd.syncml.dm+wbxml`],[`bdoc`,`application/x-bdoc`],[`bed`,`application/vnd.realvnc.bed`],[`bh2`,`application/vnd.fujitsu.oasysprs`],[`bin`,`application/octet-stream`],[`blb`,`application/x-blorb`],[`blorb`,`application/x-blorb`],[`bmi`,`application/vnd.bmi`],[`bmml`,`application/vnd.balsamiq.bmml+xml`],[`bmp`,`image/bmp`],[`book`,`application/vnd.framemaker`],[`box`,`application/vnd.previewsystems.box`],[`boz`,`application/x-bzip2`],[`bpk`,`application/octet-stream`],[`bpmn`,`application/octet-stream`],[`bsp`,`model/vnd.valve.source.compiled-map`],[`btif`,`image/prs.btif`],[`buffer`,`application/octet-stream`],[`bz`,`application/x-bzip`],[`bz2`,`application/x-bzip2`],[`c`,`text/x-c`],[`c4d`,`application/vnd.clonk.c4group`],[`c4f`,`application/vnd.clonk.c4group`],[`c4g`,`application/vnd.clonk.c4group`],[`c4p`,`application/vnd.clonk.c4group`],[`c4u`,`application/vnd.clonk.c4group`],[`c11amc`,`application/vnd.cluetrust.cartomobile-config`],[`c11amz`,`application/vnd.cluetrust.cartomobile-config-pkg`],[`cab`,`application/vnd.ms-cab-compressed`],[`caf`,`audio/x-caf`],[`cap`,`application/vnd.tcpdump.pcap`],[`car`,`application/vnd.curl.car`],[`cat`,`application/vnd.ms-pki.seccat`],[`cb7`,`application/x-cbr`],[`cba`,`application/x-cbr`],[`cbr`,`application/x-cbr`],[`cbt`,`application/x-cbr`],[`cbz`,`application/x-cbr`],[`cc`,`text/x-c`],[`cco`,`application/x-cocoa`],[`cct`,`application/x-director`],[`ccxml`,`application/ccxml+xml`],[`cdbcmsg`,`application/vnd.contact.cmsg`],[`cda`,`application/x-cdf`],[`cdf`,`application/x-netcdf`],[`cdfx`,`application/cdfx+xml`],[`cdkey`,`application/vnd.mediastation.cdkey`],[`cdmia`,`application/cdmi-capability`],[`cdmic`,`application/cdmi-container`],[`cdmid`,`application/cdmi-domain`],[`cdmio`,`application/cdmi-object`],[`cdmiq`,`application/cdmi-queue`],[`cdr`,`application/cdr`],[`cdx`,`chemical/x-cdx`],[`cdxml`,`application/vnd.chemdraw+xml`],[`cdy`,`application/vnd.cinderella`],[`cer`,`application/pkix-cert`],[`cfs`,`application/x-cfs-compressed`],[`cgm`,`image/cgm`],[`chat`,`application/x-chat`],[`chm`,`application/vnd.ms-htmlhelp`],[`chrt`,`application/vnd.kde.kchart`],[`cif`,`chemical/x-cif`],[`cii`,`application/vnd.anser-web-certificate-issue-initiation`],[`cil`,`application/vnd.ms-artgalry`],[`cjs`,`application/node`],[`cla`,`application/vnd.claymore`],[`class`,`application/octet-stream`],[`clkk`,`application/vnd.crick.clicker.keyboard`],[`clkp`,`application/vnd.crick.clicker.palette`],[`clkt`,`application/vnd.crick.clicker.template`],[`clkw`,`application/vnd.crick.clicker.wordbank`],[`clkx`,`application/vnd.crick.clicker`],[`clp`,`application/x-msclip`],[`cmc`,`application/vnd.cosmocaller`],[`cmdf`,`chemical/x-cmdf`],[`cml`,`chemical/x-cml`],[`cmp`,`application/vnd.yellowriver-custom-menu`],[`cmx`,`image/x-cmx`],[`cod`,`application/vnd.rim.cod`],[`coffee`,`text/coffeescript`],[`com`,`application/x-msdownload`],[`conf`,`text/plain`],[`cpio`,`application/x-cpio`],[`cpp`,`text/x-c`],[`cpt`,`application/mac-compactpro`],[`crd`,`application/x-mscardfile`],[`crl`,`application/pkix-crl`],[`crt`,`application/x-x509-ca-cert`],[`crx`,`application/x-chrome-extension`],[`cryptonote`,`application/vnd.rig.cryptonote`],[`csh`,`application/x-csh`],[`csl`,`application/vnd.citationstyles.style+xml`],[`csml`,`chemical/x-csml`],[`csp`,`application/vnd.commonspace`],[`csr`,`application/octet-stream`],[`css`,`text/css`],[`cst`,`application/x-director`],[`csv`,`text/csv`],[`cu`,`application/cu-seeme`],[`curl`,`text/vnd.curl`],[`cww`,`application/prs.cww`],[`cxt`,`application/x-director`],[`cxx`,`text/x-c`],[`dae`,`model/vnd.collada+xml`],[`daf`,`application/vnd.mobius.daf`],[`dart`,`application/vnd.dart`],[`dataless`,`application/vnd.fdsn.seed`],[`davmount`,`application/davmount+xml`],[`dbf`,`application/vnd.dbf`],[`dbk`,`application/docbook+xml`],[`dcr`,`application/x-director`],[`dcurl`,`text/vnd.curl.dcurl`],[`dd2`,`application/vnd.oma.dd2+xml`],[`ddd`,`application/vnd.fujixerox.ddd`],[`ddf`,`application/vnd.syncml.dmddf+xml`],[`dds`,`image/vnd.ms-dds`],[`deb`,`application/x-debian-package`],[`def`,`text/plain`],[`deploy`,`application/octet-stream`],[`der`,`application/x-x509-ca-cert`],[`dfac`,`application/vnd.dreamfactory`],[`dgc`,`application/x-dgc-compressed`],[`dic`,`text/x-c`],[`dir`,`application/x-director`],[`dis`,`application/vnd.mobius.dis`],[`disposition-notification`,`message/disposition-notification`],[`dist`,`application/octet-stream`],[`distz`,`application/octet-stream`],[`djv`,`image/vnd.djvu`],[`djvu`,`image/vnd.djvu`],[`dll`,`application/octet-stream`],[`dmg`,`application/x-apple-diskimage`],[`dmn`,`application/octet-stream`],[`dmp`,`application/vnd.tcpdump.pcap`],[`dms`,`application/octet-stream`],[`dna`,`application/vnd.dna`],[`doc`,`application/msword`],[`docm`,`application/vnd.ms-word.template.macroEnabled.12`],[`docx`,`application/vnd.openxmlformats-officedocument.wordprocessingml.document`],[`dot`,`application/msword`],[`dotm`,`application/vnd.ms-word.template.macroEnabled.12`],[`dotx`,`application/vnd.openxmlformats-officedocument.wordprocessingml.template`],[`dp`,`application/vnd.osgi.dp`],[`dpg`,`application/vnd.dpgraph`],[`dra`,`audio/vnd.dra`],[`drle`,`image/dicom-rle`],[`dsc`,`text/prs.lines.tag`],[`dssc`,`application/dssc+der`],[`dtb`,`application/x-dtbook+xml`],[`dtd`,`application/xml-dtd`],[`dts`,`audio/vnd.dts`],[`dtshd`,`audio/vnd.dts.hd`],[`dump`,`application/octet-stream`],[`dvb`,`video/vnd.dvb.file`],[`dvi`,`application/x-dvi`],[`dwd`,`application/atsc-dwd+xml`],[`dwf`,`model/vnd.dwf`],[`dwg`,`image/vnd.dwg`],[`dxf`,`image/vnd.dxf`],[`dxp`,`application/vnd.spotfire.dxp`],[`dxr`,`application/x-director`],[`ear`,`application/java-archive`],[`ecelp4800`,`audio/vnd.nuera.ecelp4800`],[`ecelp7470`,`audio/vnd.nuera.ecelp7470`],[`ecelp9600`,`audio/vnd.nuera.ecelp9600`],[`ecma`,`application/ecmascript`],[`edm`,`application/vnd.novadigm.edm`],[`edx`,`application/vnd.novadigm.edx`],[`efif`,`application/vnd.picsel`],[`ei6`,`application/vnd.pg.osasli`],[`elc`,`application/octet-stream`],[`emf`,`image/emf`],[`eml`,`message/rfc822`],[`emma`,`application/emma+xml`],[`emotionml`,`application/emotionml+xml`],[`emz`,`application/x-msmetafile`],[`eol`,`audio/vnd.digital-winds`],[`eot`,`application/vnd.ms-fontobject`],[`eps`,`application/postscript`],[`epub`,`application/epub+zip`],[`es`,`application/ecmascript`],[`es3`,`application/vnd.eszigno3+xml`],[`esa`,`application/vnd.osgi.subsystem`],[`esf`,`application/vnd.epson.esf`],[`et3`,`application/vnd.eszigno3+xml`],[`etx`,`text/x-setext`],[`eva`,`application/x-eva`],[`evy`,`application/x-envoy`],[`exe`,`application/octet-stream`],[`exi`,`application/exi`],[`exp`,`application/express`],[`exr`,`image/aces`],[`ext`,`application/vnd.novadigm.ext`],[`ez`,`application/andrew-inset`],[`ez2`,`application/vnd.ezpix-album`],[`ez3`,`application/vnd.ezpix-package`],[`f`,`text/x-fortran`],[`f4v`,`video/mp4`],[`f77`,`text/x-fortran`],[`f90`,`text/x-fortran`],[`fbs`,`image/vnd.fastbidsheet`],[`fcdt`,`application/vnd.adobe.formscentral.fcdt`],[`fcs`,`application/vnd.isac.fcs`],[`fdf`,`application/vnd.fdf`],[`fdt`,`application/fdt+xml`],[`fe_launch`,`application/vnd.denovo.fcselayout-link`],[`fg5`,`application/vnd.fujitsu.oasysgp`],[`fgd`,`application/x-director`],[`fh`,`image/x-freehand`],[`fh4`,`image/x-freehand`],[`fh5`,`image/x-freehand`],[`fh7`,`image/x-freehand`],[`fhc`,`image/x-freehand`],[`fig`,`application/x-xfig`],[`fits`,`image/fits`],[`flac`,`audio/x-flac`],[`fli`,`video/x-fli`],[`flo`,`application/vnd.micrografx.flo`],[`flv`,`video/x-flv`],[`flw`,`application/vnd.kde.kivio`],[`flx`,`text/vnd.fmi.flexstor`],[`fly`,`text/vnd.fly`],[`fm`,`application/vnd.framemaker`],[`fnc`,`application/vnd.frogans.fnc`],[`fo`,`application/vnd.software602.filler.form+xml`],[`for`,`text/x-fortran`],[`fpx`,`image/vnd.fpx`],[`frame`,`application/vnd.framemaker`],[`fsc`,`application/vnd.fsc.weblaunch`],[`fst`,`image/vnd.fst`],[`ftc`,`application/vnd.fluxtime.clip`],[`fti`,`application/vnd.anser-web-funds-transfer-initiation`],[`fvt`,`video/vnd.fvt`],[`fxp`,`application/vnd.adobe.fxp`],[`fxpl`,`application/vnd.adobe.fxp`],[`fzs`,`application/vnd.fuzzysheet`],[`g2w`,`application/vnd.geoplan`],[`g3`,`image/g3fax`],[`g3w`,`application/vnd.geospace`],[`gac`,`application/vnd.groove-account`],[`gam`,`application/x-tads`],[`gbr`,`application/rpki-ghostbusters`],[`gca`,`application/x-gca-compressed`],[`gdl`,`model/vnd.gdl`],[`gdoc`,`application/vnd.google-apps.document`],[`geo`,`application/vnd.dynageo`],[`geojson`,`application/geo+json`],[`gex`,`application/vnd.geometry-explorer`],[`ggb`,`application/vnd.geogebra.file`],[`ggt`,`application/vnd.geogebra.tool`],[`ghf`,`application/vnd.groove-help`],[`gif`,`image/gif`],[`gim`,`application/vnd.groove-identity-message`],[`glb`,`model/gltf-binary`],[`gltf`,`model/gltf+json`],[`gml`,`application/gml+xml`],[`gmx`,`application/vnd.gmx`],[`gnumeric`,`application/x-gnumeric`],[`gpg`,`application/gpg-keys`],[`gph`,`application/vnd.flographit`],[`gpx`,`application/gpx+xml`],[`gqf`,`application/vnd.grafeq`],[`gqs`,`application/vnd.grafeq`],[`gram`,`application/srgs`],[`gramps`,`application/x-gramps-xml`],[`gre`,`application/vnd.geometry-explorer`],[`grv`,`application/vnd.groove-injector`],[`grxml`,`application/srgs+xml`],[`gsf`,`application/x-font-ghostscript`],[`gsheet`,`application/vnd.google-apps.spreadsheet`],[`gslides`,`application/vnd.google-apps.presentation`],[`gtar`,`application/x-gtar`],[`gtm`,`application/vnd.groove-tool-message`],[`gtw`,`model/vnd.gtw`],[`gv`,`text/vnd.graphviz`],[`gxf`,`application/gxf`],[`gxt`,`application/vnd.geonext`],[`gz`,`application/gzip`],[`gzip`,`application/gzip`],[`h`,`text/x-c`],[`h261`,`video/h261`],[`h263`,`video/h263`],[`h264`,`video/h264`],[`hal`,`application/vnd.hal+xml`],[`hbci`,`application/vnd.hbci`],[`hbs`,`text/x-handlebars-template`],[`hdd`,`application/x-virtualbox-hdd`],[`hdf`,`application/x-hdf`],[`heic`,`image/heic`],[`heics`,`image/heic-sequence`],[`heif`,`image/heif`],[`heifs`,`image/heif-sequence`],[`hej2`,`image/hej2k`],[`held`,`application/atsc-held+xml`],[`hh`,`text/x-c`],[`hjson`,`application/hjson`],[`hlp`,`application/winhlp`],[`hpgl`,`application/vnd.hp-hpgl`],[`hpid`,`application/vnd.hp-hpid`],[`hps`,`application/vnd.hp-hps`],[`hqx`,`application/mac-binhex40`],[`hsj2`,`image/hsj2`],[`htc`,`text/x-component`],[`htke`,`application/vnd.kenameaapp`],[`htm`,`text/html`],[`html`,`text/html`],[`hvd`,`application/vnd.yamaha.hv-dic`],[`hvp`,`application/vnd.yamaha.hv-voice`],[`hvs`,`application/vnd.yamaha.hv-script`],[`i2g`,`application/vnd.intergeo`],[`icc`,`application/vnd.iccprofile`],[`ice`,`x-conference/x-cooltalk`],[`icm`,`application/vnd.iccprofile`],[`ico`,`image/x-icon`],[`ics`,`text/calendar`],[`ief`,`image/ief`],[`ifb`,`text/calendar`],[`ifm`,`application/vnd.shana.informed.formdata`],[`iges`,`model/iges`],[`igl`,`application/vnd.igloader`],[`igm`,`application/vnd.insors.igm`],[`igs`,`model/iges`],[`igx`,`application/vnd.micrografx.igx`],[`iif`,`application/vnd.shana.informed.interchange`],[`img`,`application/octet-stream`],[`imp`,`application/vnd.accpac.simply.imp`],[`ims`,`application/vnd.ms-ims`],[`in`,`text/plain`],[`ini`,`text/plain`],[`ink`,`application/inkml+xml`],[`inkml`,`application/inkml+xml`],[`install`,`application/x-install-instructions`],[`iota`,`application/vnd.astraea-software.iota`],[`ipfix`,`application/ipfix`],[`ipk`,`application/vnd.shana.informed.package`],[`irm`,`application/vnd.ibm.rights-management`],[`irp`,`application/vnd.irepository.package+xml`],[`iso`,`application/x-iso9660-image`],[`itp`,`application/vnd.shana.informed.formtemplate`],[`its`,`application/its+xml`],[`ivp`,`application/vnd.immervision-ivp`],[`ivu`,`application/vnd.immervision-ivu`],[`jad`,`text/vnd.sun.j2me.app-descriptor`],[`jade`,`text/jade`],[`jam`,`application/vnd.jam`],[`jar`,`application/java-archive`],[`jardiff`,`application/x-java-archive-diff`],[`java`,`text/x-java-source`],[`jhc`,`image/jphc`],[`jisp`,`application/vnd.jisp`],[`jls`,`image/jls`],[`jlt`,`application/vnd.hp-jlyt`],[`jng`,`image/x-jng`],[`jnlp`,`application/x-java-jnlp-file`],[`joda`,`application/vnd.joost.joda-archive`],[`jp2`,`image/jp2`],[`jpe`,`image/jpeg`],[`jpeg`,`image/jpeg`],[`jpf`,`image/jpx`],[`jpg`,`image/jpeg`],[`jpg2`,`image/jp2`],[`jpgm`,`video/jpm`],[`jpgv`,`video/jpeg`],[`jph`,`image/jph`],[`jpm`,`video/jpm`],[`jpx`,`image/jpx`],[`js`,`application/javascript`],[`json`,`application/json`],[`json5`,`application/json5`],[`jsonld`,`application/ld+json`],[`jsonl`,`application/jsonl`],[`jsonml`,`application/jsonml+json`],[`jsx`,`text/jsx`],[`jxr`,`image/jxr`],[`jxra`,`image/jxra`],[`jxrs`,`image/jxrs`],[`jxs`,`image/jxs`],[`jxsc`,`image/jxsc`],[`jxsi`,`image/jxsi`],[`jxss`,`image/jxss`],[`kar`,`audio/midi`],[`karbon`,`application/vnd.kde.karbon`],[`kdb`,`application/octet-stream`],[`kdbx`,`application/x-keepass2`],[`key`,`application/x-iwork-keynote-sffkey`],[`kfo`,`application/vnd.kde.kformula`],[`kia`,`application/vnd.kidspiration`],[`kml`,`application/vnd.google-earth.kml+xml`],[`kmz`,`application/vnd.google-earth.kmz`],[`kne`,`application/vnd.kinar`],[`knp`,`application/vnd.kinar`],[`kon`,`application/vnd.kde.kontour`],[`kpr`,`application/vnd.kde.kpresenter`],[`kpt`,`application/vnd.kde.kpresenter`],[`kpxx`,`application/vnd.ds-keypoint`],[`ksp`,`application/vnd.kde.kspread`],[`ktr`,`application/vnd.kahootz`],[`ktx`,`image/ktx`],[`ktx2`,`image/ktx2`],[`ktz`,`application/vnd.kahootz`],[`kwd`,`application/vnd.kde.kword`],[`kwt`,`application/vnd.kde.kword`],[`lasxml`,`application/vnd.las.las+xml`],[`latex`,`application/x-latex`],[`lbd`,`application/vnd.llamagraphics.life-balance.desktop`],[`lbe`,`application/vnd.llamagraphics.life-balance.exchange+xml`],[`les`,`application/vnd.hhe.lesson-player`],[`less`,`text/less`],[`lgr`,`application/lgr+xml`],[`lha`,`application/octet-stream`],[`link66`,`application/vnd.route66.link66+xml`],[`list`,`text/plain`],[`list3820`,`application/vnd.ibm.modcap`],[`listafp`,`application/vnd.ibm.modcap`],[`litcoffee`,`text/coffeescript`],[`lnk`,`application/x-ms-shortcut`],[`log`,`text/plain`],[`lostxml`,`application/lost+xml`],[`lrf`,`application/octet-stream`],[`lrm`,`application/vnd.ms-lrm`],[`ltf`,`application/vnd.frogans.ltf`],[`lua`,`text/x-lua`],[`luac`,`application/x-lua-bytecode`],[`lvp`,`audio/vnd.lucent.voice`],[`lwp`,`application/vnd.lotus-wordpro`],[`lzh`,`application/octet-stream`],[`m1v`,`video/mpeg`],[`m2a`,`audio/mpeg`],[`m2v`,`video/mpeg`],[`m3a`,`audio/mpeg`],[`m3u`,`text/plain`],[`m3u8`,`application/vnd.apple.mpegurl`],[`m4a`,`audio/x-m4a`],[`m4p`,`application/mp4`],[`m4s`,`video/iso.segment`],[`m4u`,`application/vnd.mpegurl`],[`m4v`,`video/x-m4v`],[`m13`,`application/x-msmediaview`],[`m14`,`application/x-msmediaview`],[`m21`,`application/mp21`],[`ma`,`application/mathematica`],[`mads`,`application/mads+xml`],[`maei`,`application/mmt-aei+xml`],[`mag`,`application/vnd.ecowin.chart`],[`maker`,`application/vnd.framemaker`],[`man`,`text/troff`],[`manifest`,`text/cache-manifest`],[`map`,`application/json`],[`mar`,`application/octet-stream`],[`markdown`,`text/markdown`],[`mathml`,`application/mathml+xml`],[`mb`,`application/mathematica`],[`mbk`,`application/vnd.mobius.mbk`],[`mbox`,`application/mbox`],[`mc1`,`application/vnd.medcalcdata`],[`mcd`,`application/vnd.mcd`],[`mcurl`,`text/vnd.curl.mcurl`],[`md`,`text/markdown`],[`mdb`,`application/x-msaccess`],[`mdi`,`image/vnd.ms-modi`],[`mdx`,`text/mdx`],[`me`,`text/troff`],[`mesh`,`model/mesh`],[`meta4`,`application/metalink4+xml`],[`metalink`,`application/metalink+xml`],[`mets`,`application/mets+xml`],[`mfm`,`application/vnd.mfmp`],[`mft`,`application/rpki-manifest`],[`mgp`,`application/vnd.osgeo.mapguide.package`],[`mgz`,`application/vnd.proteus.magazine`],[`mid`,`audio/midi`],[`midi`,`audio/midi`],[`mie`,`application/x-mie`],[`mif`,`application/vnd.mif`],[`mime`,`message/rfc822`],[`mj2`,`video/mj2`],[`mjp2`,`video/mj2`],[`mjs`,`application/javascript`],[`mk3d`,`video/x-matroska`],[`mka`,`audio/x-matroska`],[`mkd`,`text/x-markdown`],[`mks`,`video/x-matroska`],[`mkv`,`video/x-matroska`],[`mlp`,`application/vnd.dolby.mlp`],[`mmd`,`application/vnd.chipnuts.karaoke-mmd`],[`mmf`,`application/vnd.smaf`],[`mml`,`text/mathml`],[`mmr`,`image/vnd.fujixerox.edmics-mmr`],[`mng`,`video/x-mng`],[`mny`,`application/x-msmoney`],[`mobi`,`application/x-mobipocket-ebook`],[`mods`,`application/mods+xml`],[`mov`,`video/quicktime`],[`movie`,`video/x-sgi-movie`],[`mp2`,`audio/mpeg`],[`mp2a`,`audio/mpeg`],[`mp3`,`audio/mpeg`],[`mp4`,`video/mp4`],[`mp4a`,`audio/mp4`],[`mp4s`,`application/mp4`],[`mp4v`,`video/mp4`],[`mp21`,`application/mp21`],[`mpc`,`application/vnd.mophun.certificate`],[`mpd`,`application/dash+xml`],[`mpe`,`video/mpeg`],[`mpeg`,`video/mpeg`],[`mpg`,`video/mpeg`],[`mpg4`,`video/mp4`],[`mpga`,`audio/mpeg`],[`mpkg`,`application/vnd.apple.installer+xml`],[`mpm`,`application/vnd.blueice.multipass`],[`mpn`,`application/vnd.mophun.application`],[`mpp`,`application/vnd.ms-project`],[`mpt`,`application/vnd.ms-project`],[`mpy`,`application/vnd.ibm.minipay`],[`mqy`,`application/vnd.mobius.mqy`],[`mrc`,`application/marc`],[`mrcx`,`application/marcxml+xml`],[`ms`,`text/troff`],[`mscml`,`application/mediaservercontrol+xml`],[`mseed`,`application/vnd.fdsn.mseed`],[`mseq`,`application/vnd.mseq`],[`msf`,`application/vnd.epson.msf`],[`msg`,`application/vnd.ms-outlook`],[`msh`,`model/mesh`],[`msi`,`application/x-msdownload`],[`msl`,`application/vnd.mobius.msl`],[`msm`,`application/octet-stream`],[`msp`,`application/octet-stream`],[`msty`,`application/vnd.muvee.style`],[`mtl`,`model/mtl`],[`mts`,`model/vnd.mts`],[`mus`,`application/vnd.musician`],[`musd`,`application/mmt-usd+xml`],[`musicxml`,`application/vnd.recordare.musicxml+xml`],[`mvb`,`application/x-msmediaview`],[`mvt`,`application/vnd.mapbox-vector-tile`],[`mwf`,`application/vnd.mfer`],[`mxf`,`application/mxf`],[`mxl`,`application/vnd.recordare.musicxml`],[`mxmf`,`audio/mobile-xmf`],[`mxml`,`application/xv+xml`],[`mxs`,`application/vnd.triscape.mxs`],[`mxu`,`video/vnd.mpegurl`],[`n-gage`,`application/vnd.nokia.n-gage.symbian.install`],[`n3`,`text/n3`],[`nb`,`application/mathematica`],[`nbp`,`application/vnd.wolfram.player`],[`nc`,`application/x-netcdf`],[`ncx`,`application/x-dtbncx+xml`],[`nfo`,`text/x-nfo`],[`ngdat`,`application/vnd.nokia.n-gage.data`],[`nitf`,`application/vnd.nitf`],[`nlu`,`application/vnd.neurolanguage.nlu`],[`nml`,`application/vnd.enliven`],[`nnd`,`application/vnd.noblenet-directory`],[`nns`,`application/vnd.noblenet-sealer`],[`nnw`,`application/vnd.noblenet-web`],[`npx`,`image/vnd.net-fpx`],[`nq`,`application/n-quads`],[`nsc`,`application/x-conference`],[`nsf`,`application/vnd.lotus-notes`],[`nt`,`application/n-triples`],[`ntf`,`application/vnd.nitf`],[`numbers`,`application/x-iwork-numbers-sffnumbers`],[`nzb`,`application/x-nzb`],[`oa2`,`application/vnd.fujitsu.oasys2`],[`oa3`,`application/vnd.fujitsu.oasys3`],[`oas`,`application/vnd.fujitsu.oasys`],[`obd`,`application/x-msbinder`],[`obgx`,`application/vnd.openblox.game+xml`],[`obj`,`model/obj`],[`oda`,`application/oda`],[`odb`,`application/vnd.oasis.opendocument.database`],[`odc`,`application/vnd.oasis.opendocument.chart`],[`odf`,`application/vnd.oasis.opendocument.formula`],[`odft`,`application/vnd.oasis.opendocument.formula-template`],[`odg`,`application/vnd.oasis.opendocument.graphics`],[`odi`,`application/vnd.oasis.opendocument.image`],[`odm`,`application/vnd.oasis.opendocument.text-master`],[`odp`,`application/vnd.oasis.opendocument.presentation`],[`ods`,`application/vnd.oasis.opendocument.spreadsheet`],[`odt`,`application/vnd.oasis.opendocument.text`],[`oga`,`audio/ogg`],[`ogex`,`model/vnd.opengex`],[`ogg`,`audio/ogg`],[`ogv`,`video/ogg`],[`ogx`,`application/ogg`],[`omdoc`,`application/omdoc+xml`],[`onepkg`,`application/onenote`],[`onetmp`,`application/onenote`],[`onetoc`,`application/onenote`],[`onetoc2`,`application/onenote`],[`opf`,`application/oebps-package+xml`],[`opml`,`text/x-opml`],[`oprc`,`application/vnd.palm`],[`opus`,`audio/ogg`],[`org`,`text/x-org`],[`osf`,`application/vnd.yamaha.openscoreformat`],[`osfpvg`,`application/vnd.yamaha.openscoreformat.osfpvg+xml`],[`osm`,`application/vnd.openstreetmap.data+xml`],[`otc`,`application/vnd.oasis.opendocument.chart-template`],[`otf`,`font/otf`],[`otg`,`application/vnd.oasis.opendocument.graphics-template`],[`oth`,`application/vnd.oasis.opendocument.text-web`],[`oti`,`application/vnd.oasis.opendocument.image-template`],[`otp`,`application/vnd.oasis.opendocument.presentation-template`],[`ots`,`application/vnd.oasis.opendocument.spreadsheet-template`],[`ott`,`application/vnd.oasis.opendocument.text-template`],[`ova`,`application/x-virtualbox-ova`],[`ovf`,`application/x-virtualbox-ovf`],[`owl`,`application/rdf+xml`],[`oxps`,`application/oxps`],[`oxt`,`application/vnd.openofficeorg.extension`],[`p`,`text/x-pascal`],[`p7a`,`application/x-pkcs7-signature`],[`p7b`,`application/x-pkcs7-certificates`],[`p7c`,`application/pkcs7-mime`],[`p7m`,`application/pkcs7-mime`],[`p7r`,`application/x-pkcs7-certreqresp`],[`p7s`,`application/pkcs7-signature`],[`p8`,`application/pkcs8`],[`p10`,`application/x-pkcs10`],[`p12`,`application/x-pkcs12`],[`pac`,`application/x-ns-proxy-autoconfig`],[`pages`,`application/x-iwork-pages-sffpages`],[`pas`,`text/x-pascal`],[`paw`,`application/vnd.pawaafile`],[`pbd`,`application/vnd.powerbuilder6`],[`pbm`,`image/x-portable-bitmap`],[`pcap`,`application/vnd.tcpdump.pcap`],[`pcf`,`application/x-font-pcf`],[`pcl`,`application/vnd.hp-pcl`],[`pclxl`,`application/vnd.hp-pclxl`],[`pct`,`image/x-pict`],[`pcurl`,`application/vnd.curl.pcurl`],[`pcx`,`image/x-pcx`],[`pdb`,`application/x-pilot`],[`pde`,`text/x-processing`],[`pdf`,`application/pdf`],[`pem`,`application/x-x509-user-cert`],[`pfa`,`application/x-font-type1`],[`pfb`,`application/x-font-type1`],[`pfm`,`application/x-font-type1`],[`pfr`,`application/font-tdpfr`],[`pfx`,`application/x-pkcs12`],[`pgm`,`image/x-portable-graymap`],[`pgn`,`application/x-chess-pgn`],[`pgp`,`application/pgp`],[`php`,`application/x-httpd-php`],[`php3`,`application/x-httpd-php`],[`php4`,`application/x-httpd-php`],[`phps`,`application/x-httpd-php-source`],[`phtml`,`application/x-httpd-php`],[`pic`,`image/x-pict`],[`pkg`,`application/octet-stream`],[`pki`,`application/pkixcmp`],[`pkipath`,`application/pkix-pkipath`],[`pkpass`,`application/vnd.apple.pkpass`],[`pl`,`application/x-perl`],[`plb`,`application/vnd.3gpp.pic-bw-large`],[`plc`,`application/vnd.mobius.plc`],[`plf`,`application/vnd.pocketlearn`],[`pls`,`application/pls+xml`],[`pm`,`application/x-perl`],[`pml`,`application/vnd.ctc-posml`],[`png`,`image/png`],[`pnm`,`image/x-portable-anymap`],[`portpkg`,`application/vnd.macports.portpkg`],[`pot`,`application/vnd.ms-powerpoint`],[`potm`,`application/vnd.ms-powerpoint.presentation.macroEnabled.12`],[`potx`,`application/vnd.openxmlformats-officedocument.presentationml.template`],[`ppa`,`application/vnd.ms-powerpoint`],[`ppam`,`application/vnd.ms-powerpoint.addin.macroEnabled.12`],[`ppd`,`application/vnd.cups-ppd`],[`ppm`,`image/x-portable-pixmap`],[`pps`,`application/vnd.ms-powerpoint`],[`ppsm`,`application/vnd.ms-powerpoint.slideshow.macroEnabled.12`],[`ppsx`,`application/vnd.openxmlformats-officedocument.presentationml.slideshow`],[`ppt`,`application/powerpoint`],[`pptm`,`application/vnd.ms-powerpoint.presentation.macroEnabled.12`],[`pptx`,`application/vnd.openxmlformats-officedocument.presentationml.presentation`],[`pqa`,`application/vnd.palm`],[`prc`,`application/x-pilot`],[`pre`,`application/vnd.lotus-freelance`],[`prf`,`application/pics-rules`],[`provx`,`application/provenance+xml`],[`ps`,`application/postscript`],[`psb`,`application/vnd.3gpp.pic-bw-small`],[`psd`,`application/x-photoshop`],[`psf`,`application/x-font-linux-psf`],[`pskcxml`,`application/pskc+xml`],[`pti`,`image/prs.pti`],[`ptid`,`application/vnd.pvi.ptid1`],[`pub`,`application/x-mspublisher`],[`pvb`,`application/vnd.3gpp.pic-bw-var`],[`pwn`,`application/vnd.3m.post-it-notes`],[`pya`,`audio/vnd.ms-playready.media.pya`],[`pyv`,`video/vnd.ms-playready.media.pyv`],[`qam`,`application/vnd.epson.quickanime`],[`qbo`,`application/vnd.intu.qbo`],[`qfx`,`application/vnd.intu.qfx`],[`qps`,`application/vnd.publishare-delta-tree`],[`qt`,`video/quicktime`],[`qwd`,`application/vnd.quark.quarkxpress`],[`qwt`,`application/vnd.quark.quarkxpress`],[`qxb`,`application/vnd.quark.quarkxpress`],[`qxd`,`application/vnd.quark.quarkxpress`],[`qxl`,`application/vnd.quark.quarkxpress`],[`qxt`,`application/vnd.quark.quarkxpress`],[`ra`,`audio/x-realaudio`],[`ram`,`audio/x-pn-realaudio`],[`raml`,`application/raml+yaml`],[`rapd`,`application/route-apd+xml`],[`rar`,`application/x-rar`],[`ras`,`image/x-cmu-raster`],[`rcprofile`,`application/vnd.ipunplugged.rcprofile`],[`rdf`,`application/rdf+xml`],[`rdz`,`application/vnd.data-vision.rdz`],[`relo`,`application/p2p-overlay+xml`],[`rep`,`application/vnd.businessobjects`],[`res`,`application/x-dtbresource+xml`],[`rgb`,`image/x-rgb`],[`rif`,`application/reginfo+xml`],[`rip`,`audio/vnd.rip`],[`ris`,`application/x-research-info-systems`],[`rl`,`application/resource-lists+xml`],[`rlc`,`image/vnd.fujixerox.edmics-rlc`],[`rld`,`application/resource-lists-diff+xml`],[`rm`,`audio/x-pn-realaudio`],[`rmi`,`audio/midi`],[`rmp`,`audio/x-pn-realaudio-plugin`],[`rms`,`application/vnd.jcp.javame.midlet-rms`],[`rmvb`,`application/vnd.rn-realmedia-vbr`],[`rnc`,`application/relax-ng-compact-syntax`],[`rng`,`application/xml`],[`roa`,`application/rpki-roa`],[`roff`,`text/troff`],[`rp9`,`application/vnd.cloanto.rp9`],[`rpm`,`audio/x-pn-realaudio-plugin`],[`rpss`,`application/vnd.nokia.radio-presets`],[`rpst`,`application/vnd.nokia.radio-preset`],[`rq`,`application/sparql-query`],[`rs`,`application/rls-services+xml`],[`rsa`,`application/x-pkcs7`],[`rsat`,`application/atsc-rsat+xml`],[`rsd`,`application/rsd+xml`],[`rsheet`,`application/urc-ressheet+xml`],[`rss`,`application/rss+xml`],[`rtf`,`text/rtf`],[`rtx`,`text/richtext`],[`run`,`application/x-makeself`],[`rusd`,`application/route-usd+xml`],[`rv`,`video/vnd.rn-realvideo`],[`s`,`text/x-asm`],[`s3m`,`audio/s3m`],[`saf`,`application/vnd.yamaha.smaf-audio`],[`sass`,`text/x-sass`],[`sbml`,`application/sbml+xml`],[`sc`,`application/vnd.ibm.secure-container`],[`scd`,`application/x-msschedule`],[`scm`,`application/vnd.lotus-screencam`],[`scq`,`application/scvp-cv-request`],[`scs`,`application/scvp-cv-response`],[`scss`,`text/x-scss`],[`scurl`,`text/vnd.curl.scurl`],[`sda`,`application/vnd.stardivision.draw`],[`sdc`,`application/vnd.stardivision.calc`],[`sdd`,`application/vnd.stardivision.impress`],[`sdkd`,`application/vnd.solent.sdkm+xml`],[`sdkm`,`application/vnd.solent.sdkm+xml`],[`sdp`,`application/sdp`],[`sdw`,`application/vnd.stardivision.writer`],[`sea`,`application/octet-stream`],[`see`,`application/vnd.seemail`],[`seed`,`application/vnd.fdsn.seed`],[`sema`,`application/vnd.sema`],[`semd`,`application/vnd.semd`],[`semf`,`application/vnd.semf`],[`senmlx`,`application/senml+xml`],[`sensmlx`,`application/sensml+xml`],[`ser`,`application/java-serialized-object`],[`setpay`,`application/set-payment-initiation`],[`setreg`,`application/set-registration-initiation`],[`sfd-hdstx`,`application/vnd.hydrostatix.sof-data`],[`sfs`,`application/vnd.spotfire.sfs`],[`sfv`,`text/x-sfv`],[`sgi`,`image/sgi`],[`sgl`,`application/vnd.stardivision.writer-global`],[`sgm`,`text/sgml`],[`sgml`,`text/sgml`],[`sh`,`application/x-sh`],[`shar`,`application/x-shar`],[`shex`,`text/shex`],[`shf`,`application/shf+xml`],[`shtml`,`text/html`],[`sid`,`image/x-mrsid-image`],[`sieve`,`application/sieve`],[`sig`,`application/pgp-signature`],[`sil`,`audio/silk`],[`silo`,`model/mesh`],[`sis`,`application/vnd.symbian.install`],[`sisx`,`application/vnd.symbian.install`],[`sit`,`application/x-stuffit`],[`sitx`,`application/x-stuffitx`],[`siv`,`application/sieve`],[`skd`,`application/vnd.koan`],[`skm`,`application/vnd.koan`],[`skp`,`application/vnd.koan`],[`skt`,`application/vnd.koan`],[`sldm`,`application/vnd.ms-powerpoint.slide.macroenabled.12`],[`sldx`,`application/vnd.openxmlformats-officedocument.presentationml.slide`],[`slim`,`text/slim`],[`slm`,`text/slim`],[`sls`,`application/route-s-tsid+xml`],[`slt`,`application/vnd.epson.salt`],[`sm`,`application/vnd.stepmania.stepchart`],[`smf`,`application/vnd.stardivision.math`],[`smi`,`application/smil`],[`smil`,`application/smil`],[`smv`,`video/x-smv`],[`smzip`,`application/vnd.stepmania.package`],[`snd`,`audio/basic`],[`snf`,`application/x-font-snf`],[`so`,`application/octet-stream`],[`spc`,`application/x-pkcs7-certificates`],[`spdx`,`text/spdx`],[`spf`,`application/vnd.yamaha.smaf-phrase`],[`spl`,`application/x-futuresplash`],[`spot`,`text/vnd.in3d.spot`],[`spp`,`application/scvp-vp-response`],[`spq`,`application/scvp-vp-request`],[`spx`,`audio/ogg`],[`sql`,`application/x-sql`],[`src`,`application/x-wais-source`],[`srt`,`application/x-subrip`],[`sru`,`application/sru+xml`],[`srx`,`application/sparql-results+xml`],[`ssdl`,`application/ssdl+xml`],[`sse`,`application/vnd.kodak-descriptor`],[`ssf`,`application/vnd.epson.ssf`],[`ssml`,`application/ssml+xml`],[`sst`,`application/octet-stream`],[`st`,`application/vnd.sailingtracker.track`],[`stc`,`application/vnd.sun.xml.calc.template`],[`std`,`application/vnd.sun.xml.draw.template`],[`stf`,`application/vnd.wt.stf`],[`sti`,`application/vnd.sun.xml.impress.template`],[`stk`,`application/hyperstudio`],[`stl`,`model/stl`],[`stpx`,`model/step+xml`],[`stpxz`,`model/step-xml+zip`],[`stpz`,`model/step+zip`],[`str`,`application/vnd.pg.format`],[`stw`,`application/vnd.sun.xml.writer.template`],[`styl`,`text/stylus`],[`stylus`,`text/stylus`],[`sub`,`text/vnd.dvb.subtitle`],[`sus`,`application/vnd.sus-calendar`],[`susp`,`application/vnd.sus-calendar`],[`sv4cpio`,`application/x-sv4cpio`],[`sv4crc`,`application/x-sv4crc`],[`svc`,`application/vnd.dvb.service`],[`svd`,`application/vnd.svd`],[`svg`,`image/svg+xml`],[`svgz`,`image/svg+xml`],[`swa`,`application/x-director`],[`swf`,`application/x-shockwave-flash`],[`swi`,`application/vnd.aristanetworks.swi`],[`swidtag`,`application/swid+xml`],[`sxc`,`application/vnd.sun.xml.calc`],[`sxd`,`application/vnd.sun.xml.draw`],[`sxg`,`application/vnd.sun.xml.writer.global`],[`sxi`,`application/vnd.sun.xml.impress`],[`sxm`,`application/vnd.sun.xml.math`],[`sxw`,`application/vnd.sun.xml.writer`],[`t`,`text/troff`],[`t3`,`application/x-t3vm-image`],[`t38`,`image/t38`],[`taglet`,`application/vnd.mynfc`],[`tao`,`application/vnd.tao.intent-module-archive`],[`tap`,`image/vnd.tencent.tap`],[`tar`,`application/x-tar`],[`tcap`,`application/vnd.3gpp2.tcap`],[`tcl`,`application/x-tcl`],[`td`,`application/urc-targetdesc+xml`],[`teacher`,`application/vnd.smart.teacher`],[`tei`,`application/tei+xml`],[`teicorpus`,`application/tei+xml`],[`tex`,`application/x-tex`],[`texi`,`application/x-texinfo`],[`texinfo`,`application/x-texinfo`],[`text`,`text/plain`],[`tfi`,`application/thraud+xml`],[`tfm`,`application/x-tex-tfm`],[`tfx`,`image/tiff-fx`],[`tga`,`image/x-tga`],[`tgz`,`application/x-tar`],[`thmx`,`application/vnd.ms-officetheme`],[`tif`,`image/tiff`],[`tiff`,`image/tiff`],[`tk`,`application/x-tcl`],[`tmo`,`application/vnd.tmobile-livetv`],[`toml`,`application/toml`],[`torrent`,`application/x-bittorrent`],[`tpl`,`application/vnd.groove-tool-template`],[`tpt`,`application/vnd.trid.tpt`],[`tr`,`text/troff`],[`tra`,`application/vnd.trueapp`],[`trig`,`application/trig`],[`trm`,`application/x-msterminal`],[`ts`,`video/mp2t`],[`tsd`,`application/timestamped-data`],[`tsv`,`text/tab-separated-values`],[`ttc`,`font/collection`],[`ttf`,`font/ttf`],[`ttl`,`text/turtle`],[`ttml`,`application/ttml+xml`],[`twd`,`application/vnd.simtech-mindmapper`],[`twds`,`application/vnd.simtech-mindmapper`],[`txd`,`application/vnd.genomatix.tuxedo`],[`txf`,`application/vnd.mobius.txf`],[`txt`,`text/plain`],[`u8dsn`,`message/global-delivery-status`],[`u8hdr`,`message/global-headers`],[`u8mdn`,`message/global-disposition-notification`],[`u8msg`,`message/global`],[`u32`,`application/x-authorware-bin`],[`ubj`,`application/ubjson`],[`udeb`,`application/x-debian-package`],[`ufd`,`application/vnd.ufdl`],[`ufdl`,`application/vnd.ufdl`],[`ulx`,`application/x-glulx`],[`umj`,`application/vnd.umajin`],[`unityweb`,`application/vnd.unity`],[`uoml`,`application/vnd.uoml+xml`],[`uri`,`text/uri-list`],[`uris`,`text/uri-list`],[`urls`,`text/uri-list`],[`usdz`,`model/vnd.usdz+zip`],[`ustar`,`application/x-ustar`],[`utz`,`application/vnd.uiq.theme`],[`uu`,`text/x-uuencode`],[`uva`,`audio/vnd.dece.audio`],[`uvd`,`application/vnd.dece.data`],[`uvf`,`application/vnd.dece.data`],[`uvg`,`image/vnd.dece.graphic`],[`uvh`,`video/vnd.dece.hd`],[`uvi`,`image/vnd.dece.graphic`],[`uvm`,`video/vnd.dece.mobile`],[`uvp`,`video/vnd.dece.pd`],[`uvs`,`video/vnd.dece.sd`],[`uvt`,`application/vnd.dece.ttml+xml`],[`uvu`,`video/vnd.uvvu.mp4`],[`uvv`,`video/vnd.dece.video`],[`uvva`,`audio/vnd.dece.audio`],[`uvvd`,`application/vnd.dece.data`],[`uvvf`,`application/vnd.dece.data`],[`uvvg`,`image/vnd.dece.graphic`],[`uvvh`,`video/vnd.dece.hd`],[`uvvi`,`image/vnd.dece.graphic`],[`uvvm`,`video/vnd.dece.mobile`],[`uvvp`,`video/vnd.dece.pd`],[`uvvs`,`video/vnd.dece.sd`],[`uvvt`,`application/vnd.dece.ttml+xml`],[`uvvu`,`video/vnd.uvvu.mp4`],[`uvvv`,`video/vnd.dece.video`],[`uvvx`,`application/vnd.dece.unspecified`],[`uvvz`,`application/vnd.dece.zip`],[`uvx`,`application/vnd.dece.unspecified`],[`uvz`,`application/vnd.dece.zip`],[`vbox`,`application/x-virtualbox-vbox`],[`vbox-extpack`,`application/x-virtualbox-vbox-extpack`],[`vcard`,`text/vcard`],[`vcd`,`application/x-cdlink`],[`vcf`,`text/x-vcard`],[`vcg`,`application/vnd.groove-vcard`],[`vcs`,`text/x-vcalendar`],[`vcx`,`application/vnd.vcx`],[`vdi`,`application/x-virtualbox-vdi`],[`vds`,`model/vnd.sap.vds`],[`vhd`,`application/x-virtualbox-vhd`],[`vis`,`application/vnd.visionary`],[`viv`,`video/vnd.vivo`],[`vlc`,`application/videolan`],[`vmdk`,`application/x-virtualbox-vmdk`],[`vob`,`video/x-ms-vob`],[`vor`,`application/vnd.stardivision.writer`],[`vox`,`application/x-authorware-bin`],[`vrml`,`model/vrml`],[`vsd`,`application/vnd.visio`],[`vsf`,`application/vnd.vsf`],[`vss`,`application/vnd.visio`],[`vst`,`application/vnd.visio`],[`vsw`,`application/vnd.visio`],[`vtf`,`image/vnd.valve.source.texture`],[`vtt`,`text/vtt`],[`vtu`,`model/vnd.vtu`],[`vxml`,`application/voicexml+xml`],[`w3d`,`application/x-director`],[`wad`,`application/x-doom`],[`wadl`,`application/vnd.sun.wadl+xml`],[`war`,`application/java-archive`],[`wasm`,`application/wasm`],[`wav`,`audio/x-wav`],[`wax`,`audio/x-ms-wax`],[`wbmp`,`image/vnd.wap.wbmp`],[`wbs`,`application/vnd.criticaltools.wbs+xml`],[`wbxml`,`application/wbxml`],[`wcm`,`application/vnd.ms-works`],[`wdb`,`application/vnd.ms-works`],[`wdp`,`image/vnd.ms-photo`],[`weba`,`audio/webm`],[`webapp`,`application/x-web-app-manifest+json`],[`webm`,`video/webm`],[`webmanifest`,`application/manifest+json`],[`webp`,`image/webp`],[`wg`,`application/vnd.pmi.widget`],[`wgt`,`application/widget`],[`wks`,`application/vnd.ms-works`],[`wm`,`video/x-ms-wm`],[`wma`,`audio/x-ms-wma`],[`wmd`,`application/x-ms-wmd`],[`wmf`,`image/wmf`],[`wml`,`text/vnd.wap.wml`],[`wmlc`,`application/wmlc`],[`wmls`,`text/vnd.wap.wmlscript`],[`wmlsc`,`application/vnd.wap.wmlscriptc`],[`wmv`,`video/x-ms-wmv`],[`wmx`,`video/x-ms-wmx`],[`wmz`,`application/x-msmetafile`],[`woff`,`font/woff`],[`woff2`,`font/woff2`],[`word`,`application/msword`],[`wpd`,`application/vnd.wordperfect`],[`wpl`,`application/vnd.ms-wpl`],[`wps`,`application/vnd.ms-works`],[`wqd`,`application/vnd.wqd`],[`wri`,`application/x-mswrite`],[`wrl`,`model/vrml`],[`wsc`,`message/vnd.wfa.wsc`],[`wsdl`,`application/wsdl+xml`],[`wspolicy`,`application/wspolicy+xml`],[`wtb`,`application/vnd.webturbo`],[`wvx`,`video/x-ms-wvx`],[`x3d`,`model/x3d+xml`],[`x3db`,`model/x3d+fastinfoset`],[`x3dbz`,`model/x3d+binary`],[`x3dv`,`model/x3d-vrml`],[`x3dvz`,`model/x3d+vrml`],[`x3dz`,`model/x3d+xml`],[`x32`,`application/x-authorware-bin`],[`x_b`,`model/vnd.parasolid.transmit.binary`],[`x_t`,`model/vnd.parasolid.transmit.text`],[`xaml`,`application/xaml+xml`],[`xap`,`application/x-silverlight-app`],[`xar`,`application/vnd.xara`],[`xav`,`application/xcap-att+xml`],[`xbap`,`application/x-ms-xbap`],[`xbd`,`application/vnd.fujixerox.docuworks.binder`],[`xbm`,`image/x-xbitmap`],[`xca`,`application/xcap-caps+xml`],[`xcs`,`application/calendar+xml`],[`xdf`,`application/xcap-diff+xml`],[`xdm`,`application/vnd.syncml.dm+xml`],[`xdp`,`application/vnd.adobe.xdp+xml`],[`xdssc`,`application/dssc+xml`],[`xdw`,`application/vnd.fujixerox.docuworks`],[`xel`,`application/xcap-el+xml`],[`xenc`,`application/xenc+xml`],[`xer`,`application/patch-ops-error+xml`],[`xfdf`,`application/vnd.adobe.xfdf`],[`xfdl`,`application/vnd.xfdl`],[`xht`,`application/xhtml+xml`],[`xhtml`,`application/xhtml+xml`],[`xhvml`,`application/xv+xml`],[`xif`,`image/vnd.xiff`],[`xl`,`application/excel`],[`xla`,`application/vnd.ms-excel`],[`xlam`,`application/vnd.ms-excel.addin.macroEnabled.12`],[`xlc`,`application/vnd.ms-excel`],[`xlf`,`application/xliff+xml`],[`xlm`,`application/vnd.ms-excel`],[`xls`,`application/vnd.ms-excel`],[`xlsb`,`application/vnd.ms-excel.sheet.binary.macroEnabled.12`],[`xlsm`,`application/vnd.ms-excel.sheet.macroEnabled.12`],[`xlsx`,`application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`],[`xlt`,`application/vnd.ms-excel`],[`xltm`,`application/vnd.ms-excel.template.macroEnabled.12`],[`xltx`,`application/vnd.openxmlformats-officedocument.spreadsheetml.template`],[`xlw`,`application/vnd.ms-excel`],[`xm`,`audio/xm`],[`xml`,`application/xml`],[`xns`,`application/xcap-ns+xml`],[`xo`,`application/vnd.olpc-sugar`],[`xop`,`application/xop+xml`],[`xpi`,`application/x-xpinstall`],[`xpl`,`application/xproc+xml`],[`xpm`,`image/x-xpixmap`],[`xpr`,`application/vnd.is-xpr`],[`xps`,`application/vnd.ms-xpsdocument`],[`xpw`,`application/vnd.intercon.formnet`],[`xpx`,`application/vnd.intercon.formnet`],[`xsd`,`application/xml`],[`xsl`,`application/xml`],[`xslt`,`application/xslt+xml`],[`xsm`,`application/vnd.syncml+xml`],[`xspf`,`application/xspf+xml`],[`xul`,`application/vnd.mozilla.xul+xml`],[`xvm`,`application/xv+xml`],[`xvml`,`application/xv+xml`],[`xwd`,`image/x-xwindowdump`],[`xyz`,`chemical/x-xyz`],[`xz`,`application/x-xz`],[`yaml`,`text/yaml`],[`yang`,`application/yang`],[`yin`,`application/yin+xml`],[`yml`,`text/yaml`],[`ymp`,`text/x-suse-ymp`],[`z`,`application/x-compress`],[`z1`,`application/x-zmachine`],[`z2`,`application/x-zmachine`],[`z3`,`application/x-zmachine`],[`z4`,`application/x-zmachine`],[`z5`,`application/x-zmachine`],[`z6`,`application/x-zmachine`],[`z7`,`application/x-zmachine`],[`z8`,`application/x-zmachine`],[`zaz`,`application/vnd.zzazz.deck+xml`],[`zip`,`application/zip`],[`zir`,`application/vnd.zul`],[`zirz`,`application/vnd.zul`],[`zmm`,`application/vnd.handheld-entertainment+xml`],[`zsh`,`text/x-scriptzsh`]]);function D(e,t,n){let r=O(e),{webkitRelativePath:i}=e,a=typeof t==`string`?t:typeof i==`string`&&i.length>0?i:`./${e.name}`;return typeof r.path!=`string`&&k(r,`path`,a),n!==void 0&&Object.defineProperty(r,"handle",{value:n,writable:!1,configurable:!1,enumerable:!0}),k(r,`relativePath`,a),r}function O(e){let{name:t}=e;if(t&&t.lastIndexOf(`.`)!==-1&&!e.type){let n=t.split(`.`).pop().toLowerCase(),r=E.get(n);r&&Object.defineProperty(e,"type",{value:r,writable:!1,configurable:!1,enumerable:!0})}return e}function k(e,t,n){Object.defineProperty(e,t,{value:n,writable:!1,configurable:!1,enumerable:!0})}var A=[`.DS_Store`,`Thumbs.db`];function j(e){return T(this,void 0,void 0,function*(){return M(e)&&re(e.dataTransfer)?P(e.dataTransfer,e.type):ie(e)?ae(e):Array.isArray(e)&&e.every(e=>`getFile`in e&&typeof e.getFile==`function`)?N(e):[]})}function re(e){return M(e)}function ie(e){return M(e)&&M(e.target)}function M(e){return typeof e==`object`&&!!e}function ae(e){return F(e.target.files).map(e=>D(e))}function N(e){return T(this,void 0,void 0,function*(){return(yield Promise.all(e.map(e=>e.getFile()))).map(e=>D(e))})}function P(e,t){return T(this,void 0,void 0,function*(){if(e.items){let n=F(e.items).filter(e=>e.kind===`file`);return t===`drop`?oe(ce(yield Promise.all(n.map(se)))):n}return oe(F(e.files).map(e=>D(e)))})}function oe(e){return e.filter(e=>A.indexOf(e.name)===-1)}function F(e){if(e===null)return[];let t=[];for(let n=0;n[...e,...Array.isArray(t)?ce(t):[t]],[])}function I(e,t){return T(this,void 0,void 0,function*(){if(globalThis.isSecureContext&&typeof e.getAsFileSystemHandle==`function`){let t=yield e.getAsFileSystemHandle();if(t===null)throw Error(`${e} is not a File`);if(t!==void 0){let e=yield t.getFile();return e.handle=t,D(e)}}let n=e.getAsFile();if(!n)throw Error(`${e} is not a File`);return D(n,t?.fullPath??void 0)})}function L(e){return T(this,void 0,void 0,function*(){return e.isDirectory?R(e):z(e)})}function R(e){let t=e.createReader();return new Promise((e,n)=>{let r=[];function i(){t.readEntries(t=>T(this,void 0,void 0,function*(){if(t.length){let e=Promise.all(t.map(L));r.push(e),i()}else try{e(yield Promise.all(r))}catch(e){n(e)}}),e=>{n(e)})}i()})}function z(e){return T(this,void 0,void 0,function*(){return new Promise((t,n)=>{e.file(n=>{t(D(n,e.fullPath))},e=>{n(e)})})})}var le=r((e=>{e.__esModule=!0,e.default=function(e,t){if(e&&t){var n=Array.isArray(t)?t:t.split(`,`);if(n.length===0)return!0;var r=e.name||``,i=(e.type||``).toLowerCase(),a=i.replace(/\/.*$/,``);return n.some(function(e){var t=e.trim().toLowerCase();return t.charAt(0)===`.`?r.toLowerCase().endsWith(t):t.endsWith(`/*`)?a===t.replace(/\/.*$/,``):i===t})}return!0}})),B=a(t()),V=a(ne()),H=a(le());function U(e){return K(e)||G(e)||pe(e)||W()}function W(){throw TypeError(`Invalid attempt to spread non-iterable instance. -In order to be iterable, non-array objects must have a [Symbol.iterator]() method.`)}function G(e){if(typeof Symbol<`u`&&e[Symbol.iterator]!=null||e[`@@iterator`]!=null)return Array.from(e)}function K(e){if(Array.isArray(e))return me(e)}function q(e,t){var n=Object.keys(e);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(e);t&&(r=r.filter(function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable})),n.push.apply(n,r)}return n}function ue(e){for(var t=1;te.length)&&(t=e.length);for(var n=0,r=Array(t);n0&&arguments[0]!==void 0?arguments[0]:``).split(`,`);return{code:ve,message:`File type must be ${e.length>1?`one of ${e.join(`, `)}`:e[0]}`}},Ce=function(e){return{code:ye,message:`File is larger than ${e} ${e===1?`byte`:`bytes`}`}},we=function(e){return{code:be,message:`File is smaller than ${e} ${e===1?`byte`:`bytes`}`}},Te={code:xe,message:`Too many files`};function Ee(e){return e.type===``&&typeof e.getAsFile==`function`}function De(e,t){var n=e.type===`application/x-moz-file`||_e(e,t)||Ee(e);return[n,n?null:Se(t)]}function Oe(e,t,n){if(Y(e.size)){if(Y(t)&&Y(n)){if(e.size>n)return[!1,Ce(n)];if(e.sizen)return[!1,Ce(n)]}return[!0,null]}function Y(e){return e!=null}function ke(e){var t=e.files,n=e.accept,r=e.minSize,i=e.maxSize,a=e.multiple,o=e.maxFiles,s=e.validator;return!a&&t.length>1||a&&o>=1&&t.length>o?!1:t.every(function(e){var t=J(De(e,n),1)[0],a=J(Oe(e,r,i),1)[0],o=s?s(e):null;return t&&a&&!o})}function Ae(e){return typeof e.isPropagationStopped==`function`?e.isPropagationStopped():e.cancelBubble!==void 0&&e.cancelBubble}function X(e){return e.dataTransfer?Array.prototype.some.call(e.dataTransfer.types,function(e){return e===`Files`||e===`application/x-moz-file`}):!!e.target&&!!e.target.files}function je(e){e.preventDefault()}function Me(e){return e.indexOf(`MSIE`)!==-1||e.indexOf(`Trident/`)!==-1}function Ne(e){return e.indexOf(`Edge/`)!==-1}function Pe(){var e=arguments.length>0&&arguments[0]!==void 0?arguments[0]:window.navigator.userAgent;return Me(e)||Ne(e)}function Z(){var e=[...arguments];return function(t){var n=[...arguments].slice(1);return e.some(function(e){return!Ae(t)&&e&&e.apply(void 0,[t].concat(n)),Ae(t)})}}function Fe(){return`showOpenFilePicker`in window}function Ie(e){return Y(e)?[{description:`Files`,accept:Object.entries(e).filter(function(e){var t=J(e,2),n=t[0],r=t[1],i=!0;return Be(n)||(console.warn(`Skipped "${n}" because it is not a valid MIME type. Check https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types for a list of valid MIME types.`),i=!1),(!Array.isArray(r)||!r.every(Ve))&&(console.warn(`Skipped "${n}" because an invalid file extension was provided.`),i=!1),i}).reduce(function(e,t){var n=J(t,2),r=n[0],i=n[1];return ue(ue({},e),{},de({},r,i))},{})}]:e}function Le(e){if(Y(e))return Object.entries(e).reduce(function(e,t){var n=J(t,2),r=n[0],i=n[1];return[].concat(U(e),[r],U(i))},[]).filter(function(e){return Be(e)||Ve(e)}).join(`,`)}function Re(e){return e instanceof DOMException&&(e.name===`AbortError`||e.code===e.ABORT_ERR)}function ze(e){return e instanceof DOMException&&(e.name===`SecurityError`||e.code===e.SECURITY_ERR)}function Be(e){return e===`audio/*`||e===`video/*`||e===`image/*`||e===`text/*`||e===`application/*`||/\w+\/[-+.\w]+/g.test(e)}function Ve(e){return/^.*\.[\w]+$/.test(e)}var He=[`children`],Ue=[`open`],We=[`refKey`,`role`,`onKeyDown`,`onFocus`,`onBlur`,`onClick`,`onDragEnter`,`onDragOver`,`onDragLeave`,`onDrop`],Ge=[`refKey`,`onChange`,`onClick`];function Ke(e){return Ye(e)||Je(e)||Qe(e)||qe()}function qe(){throw TypeError(`Invalid attempt to spread non-iterable instance. -In order to be iterable, non-array objects must have a [Symbol.iterator]() method.`)}function Je(e){if(typeof Symbol<`u`&&e[Symbol.iterator]!=null||e[`@@iterator`]!=null)return Array.from(e)}function Ye(e){if(Array.isArray(e))return $e(e)}function Xe(e,t){return tt(e)||et(e,t)||Qe(e,t)||Ze()}function Ze(){throw TypeError(`Invalid attempt to destructure non-iterable instance. -In order to be iterable, non-array objects must have a [Symbol.iterator]() method.`)}function Qe(e,t){if(e){if(typeof e==`string`)return $e(e,t);var n=Object.prototype.toString.call(e).slice(8,-1);if(n===`Object`&&e.constructor&&(n=e.constructor.name),n===`Map`||n===`Set`)return Array.from(e);if(n===`Arguments`||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n))return $e(e,t)}}function $e(e,t){(t==null||t>e.length)&&(t=e.length);for(var n=0,r=Array(t);n=0)&&Object.prototype.propertyIsEnumerable.call(e,r)&&(n[r]=e[r])}return n}function at(e,t){if(e==null)return{};var n={},r=Object.keys(e),i,a;for(a=0;a=0)&&(n[i]=e[i]);return n}var ot=(0,B.forwardRef)(function(e,t){var n=e.children,r=lt(it(e,He)),i=r.open,a=it(r,Ue);return(0,B.useImperativeHandle)(t,function(){return{open:i}},[i]),B.createElement(B.Fragment,null,n(Q(Q({},a),{},{open:i})))});ot.displayName=`Dropzone`;var st={disabled:!1,getFilesFromEvent:j,maxSize:1/0,minSize:0,multiple:!0,maxFiles:0,preventDropOnDocument:!0,noClick:!1,noKeyboard:!1,noDrag:!1,noDragEventsBubbling:!1,validator:null,useFsAccessApi:!1,autoFocus:!1};ot.defaultProps=st,ot.propTypes={children:V.default.func,accept:V.default.objectOf(V.default.arrayOf(V.default.string)),multiple:V.default.bool,preventDropOnDocument:V.default.bool,noClick:V.default.bool,noKeyboard:V.default.bool,noDrag:V.default.bool,noDragEventsBubbling:V.default.bool,minSize:V.default.number,maxSize:V.default.number,maxFiles:V.default.number,disabled:V.default.bool,getFilesFromEvent:V.default.func,onFileDialogCancel:V.default.func,onFileDialogOpen:V.default.func,useFsAccessApi:V.default.bool,autoFocus:V.default.bool,onDragEnter:V.default.func,onDragLeave:V.default.func,onDragOver:V.default.func,onDrop:V.default.func,onDropAccepted:V.default.func,onDropRejected:V.default.func,onError:V.default.func,validator:V.default.func};var ct={isFocused:!1,isFileDialogActive:!1,isDragActive:!1,isDragAccept:!1,isDragReject:!1,isDragGlobal:!1,acceptedFiles:[],fileRejections:[]};function lt(){var e=arguments.length>0&&arguments[0]!==void 0?arguments[0]:{},t=Q(Q({},st),e),n=t.accept,r=t.disabled,i=t.getFilesFromEvent,a=t.maxSize,o=t.minSize,s=t.multiple,c=t.maxFiles,l=t.onDragEnter,u=t.onDragLeave,d=t.onDragOver,f=t.onDrop,p=t.onDropAccepted,m=t.onDropRejected,h=t.onFileDialogCancel,g=t.onFileDialogOpen,_=t.useFsAccessApi,v=t.autoFocus,y=t.preventDropOnDocument,b=t.noClick,x=t.noKeyboard,ee=t.noDrag,S=t.noDragEventsBubbling,te=t.onError,C=t.validator,w=(0,B.useMemo)(function(){return Le(n)},[n]),ne=(0,B.useMemo)(function(){return Ie(n)},[n]),T=(0,B.useMemo)(function(){return typeof g==`function`?g:dt},[g]),E=(0,B.useMemo)(function(){return typeof h==`function`?h:dt},[h]),D=(0,B.useRef)(null),O=(0,B.useRef)(null),k=Xe((0,B.useReducer)(ut,ct),2),A=k[0],j=k[1],re=A.isFocused,ie=A.isFileDialogActive,M=(0,B.useRef)(typeof window<`u`&&window.isSecureContext&&_&&Fe()),ae=function(){!M.current&&ie&&setTimeout(function(){O.current&&(O.current.files.length||(j({type:`closeDialog`}),E()))},300)};(0,B.useEffect)(function(){return window.addEventListener(`focus`,ae,!1),function(){window.removeEventListener(`focus`,ae,!1)}},[O,ie,E,M]);var N=(0,B.useRef)([]),P=(0,B.useRef)([]),oe=function(e){D.current&&D.current.contains(e.target)||(e.preventDefault(),N.current=[])};(0,B.useEffect)(function(){return y&&(document.addEventListener(`dragover`,je,!1),document.addEventListener(`drop`,oe,!1)),function(){y&&(document.removeEventListener(`dragover`,je),document.removeEventListener(`drop`,oe))}},[D,y]),(0,B.useEffect)(function(){var e=function(e){P.current=[].concat(Ke(P.current),[e.target]),X(e)&&j({isDragGlobal:!0,type:`setDragGlobal`})},t=function(e){P.current=P.current.filter(function(t){return t!==e.target&&t!==null}),!(P.current.length>0)&&j({isDragGlobal:!1,type:`setDragGlobal`})},n=function(){P.current=[],j({isDragGlobal:!1,type:`setDragGlobal`})},r=function(){P.current=[],j({isDragGlobal:!1,type:`setDragGlobal`})};return document.addEventListener(`dragenter`,e,!1),document.addEventListener(`dragleave`,t,!1),document.addEventListener(`dragend`,n,!1),document.addEventListener(`drop`,r,!1),function(){document.removeEventListener(`dragenter`,e),document.removeEventListener(`dragleave`,t),document.removeEventListener(`dragend`,n),document.removeEventListener(`drop`,r)}},[D]),(0,B.useEffect)(function(){return!r&&v&&D.current&&D.current.focus(),function(){}},[D,v,r]);var F=(0,B.useCallback)(function(e){te?te(e):console.error(e)},[te]),se=(0,B.useCallback)(function(e){e.preventDefault(),e.persist(),q(e),N.current=[].concat(Ke(N.current),[e.target]),X(e)&&Promise.resolve(i(e)).then(function(t){if(!(Ae(e)&&!S)){var n=t.length,r=n>0&&ke({files:t,accept:w,minSize:o,maxSize:a,multiple:s,maxFiles:c,validator:C});j({isDragAccept:r,isDragReject:n>0&&!r,isDragActive:!0,type:`setDraggedFiles`}),l&&l(e)}}).catch(function(e){return F(e)})},[i,l,F,S,w,o,a,s,c,C]),ce=(0,B.useCallback)(function(e){e.preventDefault(),e.persist(),q(e);var t=X(e);if(t&&e.dataTransfer)try{e.dataTransfer.dropEffect=`copy`}catch{}return t&&d&&d(e),!1},[d,S]),I=(0,B.useCallback)(function(e){e.preventDefault(),e.persist(),q(e);var t=N.current.filter(function(e){return D.current&&D.current.contains(e)}),n=t.indexOf(e.target);n!==-1&&t.splice(n,1),N.current=t,!(t.length>0)&&(j({type:`setDraggedFiles`,isDragActive:!1,isDragAccept:!1,isDragReject:!1}),X(e)&&u&&u(e))},[D,u,S]),L=(0,B.useCallback)(function(e,t){var n=[],r=[];e.forEach(function(e){var t=Xe(De(e,w),2),i=t[0],s=t[1],c=Xe(Oe(e,o,a),2),l=c[0],u=c[1],d=C?C(e):null;if(i&&l&&!d)n.push(e);else{var f=[s,u];d&&(f=f.concat(d)),r.push({file:e,errors:f.filter(function(e){return e})})}}),(!s&&n.length>1||s&&c>=1&&n.length>c)&&(n.forEach(function(e){r.push({file:e,errors:[Te]})}),n.splice(0)),j({acceptedFiles:n,fileRejections:r,isDragReject:r.length>0,type:`setFiles`}),f&&f(n,r,t),r.length>0&&m&&m(r,t),n.length>0&&p&&p(n,t)},[j,s,w,o,a,c,f,p,m,C]),R=(0,B.useCallback)(function(e){e.preventDefault(),e.persist(),q(e),N.current=[],X(e)&&Promise.resolve(i(e)).then(function(t){Ae(e)&&!S||L(t,e)}).catch(function(e){return F(e)}),j({type:`reset`})},[i,L,F,S]),z=(0,B.useCallback)(function(){if(M.current){j({type:`openDialog`}),T();var e={multiple:s,types:ne};window.showOpenFilePicker(e).then(function(e){return i(e)}).then(function(e){L(e,null),j({type:`closeDialog`})}).catch(function(e){Re(e)?(E(e),j({type:`closeDialog`})):ze(e)?(M.current=!1,O.current?(O.current.value=null,O.current.click()):F(Error(`Cannot open the file picker because the https://developer.mozilla.org/en-US/docs/Web/API/File_System_Access_API is not supported and no was provided.`))):F(e)});return}O.current&&(j({type:`openDialog`}),T(),O.current.value=null,O.current.click())},[j,T,E,_,L,F,ne,s]),le=(0,B.useCallback)(function(e){!D.current||!D.current.isEqualNode(e.target)||(e.key===` `||e.key===`Enter`||e.keyCode===32||e.keyCode===13)&&(e.preventDefault(),z())},[D,z]),V=(0,B.useCallback)(function(){j({type:`focus`})},[]),H=(0,B.useCallback)(function(){j({type:`blur`})},[]),U=(0,B.useCallback)(function(){b||(Pe()?setTimeout(z,0):z())},[b,z]),W=function(e){return r?null:e},G=function(e){return x?null:W(e)},K=function(e){return ee?null:W(e)},q=function(e){S&&e.stopPropagation()},ue=(0,B.useMemo)(function(){return function(){var e=arguments.length>0&&arguments[0]!==void 0?arguments[0]:{},t=e.refKey,n=t===void 0?`ref`:t,i=e.role,a=e.onKeyDown,o=e.onFocus,s=e.onBlur,c=e.onClick,l=e.onDragEnter,u=e.onDragOver,d=e.onDragLeave,f=e.onDrop,p=it(e,We);return Q(Q(rt({onKeyDown:G(Z(a,le)),onFocus:G(Z(o,V)),onBlur:G(Z(s,H)),onClick:W(Z(c,U)),onDragEnter:K(Z(l,se)),onDragOver:K(Z(u,ce)),onDragLeave:K(Z(d,I)),onDrop:K(Z(f,R)),role:typeof i==`string`&&i!==``?i:`presentation`},n,D),!r&&!x?{tabIndex:0}:{}),p)}},[D,le,V,H,U,se,ce,I,R,x,ee,r]),de=(0,B.useCallback)(function(e){e.stopPropagation()},[]),J=(0,B.useMemo)(function(){return function(){var e=arguments.length>0&&arguments[0]!==void 0?arguments[0]:{},t=e.refKey,n=t===void 0?`ref`:t,r=e.onChange,i=e.onClick,a=it(e,Ge);return Q(Q({},rt({accept:w,multiple:s,type:`file`,style:{border:0,clip:`rect(0, 0, 0, 0)`,clipPath:`inset(50%)`,height:`1px`,margin:`0 -1px -1px 0`,overflow:`hidden`,padding:0,position:`absolute`,width:`1px`,whiteSpace:`nowrap`},onChange:W(Z(r,R)),onClick:W(Z(i,de)),tabIndex:-1},n,O)),a)}},[O,n,s,R,r]);return Q(Q({},A),{},{isFocused:re&&!r,getRootProps:ue,getInputProps:J,rootRef:D,inputRef:O,open:W(z)})}function ut(e,t){switch(t.type){case`focus`:return Q(Q({},e),{},{isFocused:!0});case`blur`:return Q(Q({},e),{},{isFocused:!1});case`openDialog`:return Q(Q({},ct),{},{isFileDialogActive:!0});case`closeDialog`:return Q(Q({},e),{},{isFileDialogActive:!1});case`setDraggedFiles`:return Q(Q({},e),{},{isDragActive:t.isDragActive,isDragAccept:t.isDragAccept,isDragReject:t.isDragReject});case`setFiles`:return Q(Q({},e),{},{acceptedFiles:t.acceptedFiles,fileRejections:t.fileRejections,isDragReject:t.isDragReject});case`setDragGlobal`:return Q(Q({},e),{},{isDragGlobal:t.isDragGlobal});case`reset`:return Q({},ct);default:return e}}function dt(){}var ft=100*1024*1024,pt={"application/zip":[`.zip`],"application/x-zip-compressed":[`.zip`],"application/x-tar":[`.tar`],"application/gzip":[`.gz`,`.tar.gz`],"application/x-gzip":[`.gz`,`.tar.gz`],"application/x-compressed-tar":[`.tar.gz`]};function mt(e){return e.replace(/\.(zip|tar\.gz|tar|tgz|gz)$/i,``)}function ht(){let{repositories:e,selectRepository:t}=y(),r=_(e=>e.addRepository),a=_(e=>e.startAnalysis),[o,s]=(0,B.useState)(null),[c,l]=(0,B.useState)(!1),[u,d]=(0,B.useState)(null),f=(0,B.useMemo)(()=>new Set(e.map(e=>e.name.toLowerCase())),[e]),p=(0,B.useCallback)(e=>{d(null);let t=e[0];if(!t)return;if(t.size>104857600){d(`File too large. Maximum size is ${i(ft)}.`);return}let n=mt(t.name);if(f.has(n.toLowerCase())){d(`A repository named "${n}" already exists.`);return}s({file:t,name:t.name,size:t.size,type:t.type,lastModified:t.lastModified})},[f]),m=(0,B.useCallback)(()=>{d(`Invalid file type. Please upload a ZIP or TAR.GZ file.`)},[]),h=(0,B.useCallback)(()=>{s(null),d(null)},[]),v=(0,B.useCallback)(async()=>{if(!o)return null;l(!0),d(null);try{let e=mt(o.name),n=await g.uploadRepository(o.file,{name:e});if(!n)throw Error(`Upload did not return a repository.`);n&&await g.startAnalysis(n.id);let i={...n,status:`analysing`,analysisStage:n.analysisStage||`uploading`};return r(i),t(i),a(i.id),s(null),i}catch(e){return d(n(e)),null}finally{l(!1)}},[r,t,a,o]),b=(0,B.useCallback)(()=>d(null),[]);return{uploadFile:o,loading:c,error:u,empty:!o,success:!!o,source:`real`,selectFile:p,rejectFile:m,removeFile:h,analyseFile:v,retry:b,refresh:b}}function gt(e){return/^https:\/\/github\.com\/[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+(?:\.git)?\/?$/.test(e.trim())}function _t(e){let t=e.trim().replace(/\/$/,``).split(`/`);return t[t.length-1]?.replace(/\.git$/i,``)||`unknown-repo`}function vt(){let{repositories:e,selectRepository:t}=y(),r=_(e=>e.addRepository),i=_(e=>e.startAnalysis),[a,o]=(0,B.useState)(``),[s,c]=(0,B.useState)(!1),[l,u]=(0,B.useState)(null),d=(0,B.useMemo)(()=>new Set(e.map(e=>e.name.toLowerCase())),[e]),f=(0,B.useCallback)(()=>u(null),[]),p=(0,B.useCallback)(e=>{o(e),u(null)},[]),m=(0,B.useCallback)(async()=>{if(u(null),!a.trim())return u(`Please enter a GitHub repository URL.`),null;if(!gt(a))return u(`Invalid GitHub URL. Format: https://github.com/owner/repository`),null;let e=_t(a);if(d.has(e.toLowerCase()))return u(`A repository named "${e}" already exists.`),null;c(!0);try{let e=await g.importFromGithub(a.trim());if(!e)throw Error(`GitHub import did not return a repository.`);e&&await g.startAnalysis(e.id);let n={...e,status:`analysing`,analysisStage:e.analysisStage||`uploading`};return r(n),t(n),i(n.id),o(``),n}catch(e){return u(n(e)),null}finally{c(!1)}},[r,a,d,t,i]);return{githubUrl:a,setGithubUrl:p,loading:s,error:l,empty:a.trim().length===0,success:gt(a),source:`real`,previewName:gt(a)?_t(a):null,analyseGithub:m,retry:f,refresh:f}}var $=o();function yt(){let t=p(),n=ht(),r=vt(),[a,o]=(0,B.useState)(`file`),{getRootProps:m,getInputProps:g,isDragActive:_,open:y}=lt({onDrop:n.selectFile,onDropRejected:n.rejectFile,accept:pt,maxFiles:1,multiple:!1,noClick:!1}),C=async()=>{let e=await n.analyseFile();e&&t(`/analysis/${e.id}`)};return(0,$.jsxs)(`div`,{className:`max-w-2xl mx-auto`,children:[(0,$.jsx)(x,{title:`Upload Repository`,description:`Upload a repository archive or import from GitHub`,children:(0,$.jsx)(ee,{source:a===`file`?n.source:r.source})}),(0,$.jsxs)(`div`,{className:`flex items-center gap-1 p-1 rounded-lg bg-muted mb-6 w-fit`,children:[(0,$.jsxs)(`button`,{onClick:()=>{o(`file`),r.retry()},className:e(`flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium transition-all`,a===`file`?`bg-background text-foreground shadow-sm`:`text-muted-foreground hover:text-foreground`),children:[(0,$.jsx)(te,{className:`h-4 w-4`}),`Upload File`]}),(0,$.jsxs)(`button`,{onClick:()=>{o(`github`),n.retry()},className:e(`flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium transition-all`,a===`github`?`bg-background text-foreground shadow-sm`:`text-muted-foreground hover:text-foreground`),children:[(0,$.jsx)(h,{className:`h-4 w-4`}),`GitHub URL`]})]}),(0,$.jsx)(b,{mode:`wait`,children:a===`file`?(0,$.jsxs)(f.div,{initial:{opacity:0,x:-10},animate:{opacity:1,x:0},exit:{opacity:0,x:10},transition:{duration:.2},children:[(0,$.jsxs)(`div`,{...m(),className:e(`relative rounded-xl border-2 border-dashed p-12 text-center cursor-pointer transition-all duration-200`,_?`border-primary bg-primary/5`:`border-border hover:border-muted-foreground/50 hover:bg-accent/30`,n.uploadFile&&`pointer-events-none opacity-50`),children:[(0,$.jsx)(`input`,{...g()}),(0,$.jsxs)(`div`,{className:`flex flex-col items-center`,children:[(0,$.jsx)(`div`,{className:e(`flex h-14 w-14 items-center justify-center rounded-2xl mb-4 transition-colors`,_?`bg-primary/10`:`bg-muted`),children:(0,$.jsx)(v,{className:e(`h-6 w-6`,_?`text-primary`:`text-muted-foreground`)})}),(0,$.jsx)(`p`,{className:`text-sm font-medium text-foreground mb-1`,children:_?`Drop your file here`:`Drag and drop your repository archive`}),(0,$.jsxs)(`p`,{className:`text-xs text-muted-foreground mb-4`,children:[`Supports ZIP and TAR.GZ files up to `,i(ft)]}),(0,$.jsx)(`button`,{type:`button`,onClick:e=>{e.stopPropagation(),y()},className:`rounded-md border border-border px-4 py-2 text-sm font-medium text-foreground hover:bg-accent transition-colors`,children:`Browse Files`})]})]}),(0,$.jsx)(b,{children:n.error&&(0,$.jsxs)(f.div,{initial:{opacity:0,height:0},animate:{opacity:1,height:`auto`},exit:{opacity:0,height:0},className:`mt-4 flex items-center gap-2 rounded-lg border border-destructive/50 bg-destructive/5 px-4 py-3`,children:[(0,$.jsx)(c,{className:`h-4 w-4 text-destructive shrink-0`}),(0,$.jsx)(`p`,{className:`text-sm text-destructive`,children:n.error})]})}),(0,$.jsx)(b,{children:n.uploadFile&&(0,$.jsx)(f.div,{initial:{opacity:0,y:8},animate:{opacity:1,y:0},exit:{opacity:0,y:-8},className:`mt-4 rounded-xl border border-border bg-card p-4`,children:(0,$.jsxs)(`div`,{className:`flex items-center justify-between`,children:[(0,$.jsxs)(`div`,{className:`flex items-center gap-3`,children:[(0,$.jsx)(`div`,{className:`flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10`,children:(0,$.jsx)(l,{className:`h-5 w-5 text-primary`})}),(0,$.jsxs)(`div`,{children:[(0,$.jsx)(`p`,{className:`text-sm font-medium text-foreground`,children:n.uploadFile.name}),(0,$.jsxs)(`div`,{className:`flex items-center gap-3 mt-0.5`,children:[(0,$.jsxs)(`span`,{className:`flex items-center gap-1 text-xs text-muted-foreground`,children:[(0,$.jsx)(u,{className:`h-3 w-3`}),i(n.uploadFile.size)]}),(0,$.jsxs)(`span`,{className:`flex items-center gap-1 text-xs text-muted-foreground`,children:[(0,$.jsx)(S,{className:`h-3 w-3`}),new Date(n.uploadFile.lastModified).toLocaleDateString()]})]})]})]}),(0,$.jsx)(`button`,{onClick:n.removeFile,className:`flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors`,children:(0,$.jsx)(d,{className:`h-4 w-4`})})]})})}),n.uploadFile&&(0,$.jsx)(f.div,{initial:{opacity:0},animate:{opacity:1},className:`mt-6 flex justify-end`,children:(0,$.jsxs)(`button`,{onClick:C,disabled:n.loading,className:`flex items-center gap-2 rounded-md bg-primary px-5 py-2.5 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors`,children:[n.loading?`Starting Analysis...`:`Analyse Repository`,(0,$.jsx)(s,{className:`h-4 w-4`})]})})]},`file`):(0,$.jsxs)(f.div,{initial:{opacity:0,x:10},animate:{opacity:1,x:0},exit:{opacity:0,x:-10},transition:{duration:.2},children:[(0,$.jsxs)(`div`,{className:`rounded-xl border border-border bg-card p-6`,children:[(0,$.jsxs)(`div`,{className:`flex items-center gap-3 mb-4`,children:[(0,$.jsx)(`div`,{className:`flex h-10 w-10 items-center justify-center rounded-lg bg-muted`,children:(0,$.jsx)(h,{className:`h-5 w-5 text-muted-foreground`})}),(0,$.jsxs)(`div`,{children:[(0,$.jsx)(`p`,{className:`text-sm font-medium text-foreground`,children:`Import from GitHub`}),(0,$.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`Paste a public repository URL`})]})]}),(0,$.jsxs)(`div`,{className:`space-y-4`,children:[(0,$.jsxs)(`div`,{children:[(0,$.jsx)(`label`,{className:`block text-xs font-medium text-muted-foreground mb-1.5`,children:`Repository URL`}),(0,$.jsx)(`input`,{type:`url`,value:r.githubUrl,onChange:e=>r.setGithubUrl(e.target.value),placeholder:`https://github.com/owner/repository`,className:`w-full rounded-md border border-border bg-background px-4 py-2.5 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring font-mono`})]}),(0,$.jsx)(b,{children:r.error&&(0,$.jsxs)(f.div,{initial:{opacity:0,height:0},animate:{opacity:1,height:`auto`},exit:{opacity:0,height:0},className:`flex items-center gap-2 rounded-lg border border-destructive/50 bg-destructive/5 px-4 py-3`,children:[(0,$.jsx)(c,{className:`h-4 w-4 text-destructive shrink-0`}),(0,$.jsx)(`p`,{className:`text-sm text-destructive`,children:r.error})]})}),r.previewName&&(0,$.jsxs)(f.div,{initial:{opacity:0},animate:{opacity:1},className:`flex items-center gap-2 rounded-lg border border-success/30 bg-success/5 px-4 py-3`,children:[(0,$.jsx)(`div`,{className:`h-2 w-2 rounded-full bg-success`}),(0,$.jsxs)(`p`,{className:`text-sm text-foreground`,children:[`Repository: `,(0,$.jsx)(`span`,{className:`font-medium`,children:r.previewName})]})]})]})]}),(0,$.jsx)(`div`,{className:`mt-6 flex justify-end`,children:(0,$.jsxs)(`button`,{onClick:async()=>{let e=await r.analyseGithub();e&&t(`/analysis/${e.id}`)},disabled:!r.githubUrl.trim()||r.loading,className:e(`flex items-center gap-2 rounded-md bg-primary px-5 py-2.5 text-sm font-medium text-primary-foreground transition-colors`,r.githubUrl.trim()&&!r.loading?`hover:bg-primary/90`:`opacity-50 cursor-not-allowed`),children:[r.loading?`Starting Analysis...`:`Analyse Repository`,(0,$.jsx)(s,{className:`h-4 w-4`})]})})]},`github`)})]})}export{yt as UploadPage}; \ No newline at end of file diff --git a/dist/assets/activity-BtAw6juF.js b/dist/assets/activity-BtAw6juF.js deleted file mode 100644 index 27c76398..00000000 --- a/dist/assets/activity-BtAw6juF.js +++ /dev/null @@ -1 +0,0 @@ -import{S as e}from"./index-QB2QUwKm.js";var t=e(`Activity`,[[`path`,{d:`M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2`,key:`169zse`}]]);export{t}; \ No newline at end of file diff --git a/dist/assets/ai-DpySo4Gb.js b/dist/assets/ai-DpySo4Gb.js deleted file mode 100644 index d6c75224..00000000 --- a/dist/assets/ai-DpySo4Gb.js +++ /dev/null @@ -1,2 +0,0 @@ -import{n as e,t}from"./client-S4ekmpXx.js";var n={getConfig(e){return t.get(`/ai/config`,e)},saveConfig(e,n){return t.put(`/ai/config`,e,n)},testConfig(e,n){return t.post(`/ai/test`,e,n)},query(e,n){return t.post(`/ai/query`,e,n)},streamQuery(t,n,r){return e(`/ai/stream`,t,e=>{let t=e.split(` -`).filter(e=>e.startsWith(`data: `));for(let e of t)try{n(JSON.parse(e.slice(6)))}catch{n({type:`content`,content:e.slice(6)})}},r)}};export{n as t}; \ No newline at end of file diff --git a/dist/assets/arrow-left-BeIPd3Ya.js b/dist/assets/arrow-left-BeIPd3Ya.js deleted file mode 100644 index 7a8d31f2..00000000 --- a/dist/assets/arrow-left-BeIPd3Ya.js +++ /dev/null @@ -1 +0,0 @@ -import{S as e}from"./index-QB2QUwKm.js";var t=e(`ArrowLeft`,[[`path`,{d:`m12 19-7-7 7-7`,key:`1l729n`}],[`path`,{d:`M19 12H5`,key:`x3x0zl`}]]);export{t}; \ No newline at end of file diff --git a/dist/assets/arrow-right-DpUoK8Nu.js b/dist/assets/arrow-right-DpUoK8Nu.js deleted file mode 100644 index 3ce3fc9d..00000000 --- a/dist/assets/arrow-right-DpUoK8Nu.js +++ /dev/null @@ -1 +0,0 @@ -import{S as e}from"./index-QB2QUwKm.js";var t=e(`ArrowRight`,[[`path`,{d:`M5 12h14`,key:`1ays0h`}],[`path`,{d:`m12 5 7 7-7 7`,key:`xquz4c`}]]);export{t}; \ No newline at end of file diff --git a/dist/assets/circle-Ds7OrsG4.js b/dist/assets/circle-Ds7OrsG4.js deleted file mode 100644 index 469ec5b9..00000000 --- a/dist/assets/circle-Ds7OrsG4.js +++ /dev/null @@ -1 +0,0 @@ -import{S as e}from"./index-QB2QUwKm.js";var t=e(`Circle`,[[`circle`,{cx:`12`,cy:`12`,r:`10`,key:`1mglay`}]]);export{t}; \ No newline at end of file diff --git a/dist/assets/circle-alert-BUYmyF7c.js b/dist/assets/circle-alert-BUYmyF7c.js deleted file mode 100644 index e95a3c83..00000000 --- a/dist/assets/circle-alert-BUYmyF7c.js +++ /dev/null @@ -1 +0,0 @@ -import{S as e}from"./index-QB2QUwKm.js";var t=e(`CircleAlert`,[[`circle`,{cx:`12`,cy:`12`,r:`10`,key:`1mglay`}],[`line`,{x1:`12`,x2:`12`,y1:`8`,y2:`12`,key:`1pkeuh`}],[`line`,{x1:`12`,x2:`12.01`,y1:`16`,y2:`16`,key:`4dfq90`}]]);export{t}; \ No newline at end of file diff --git a/dist/assets/client-S4ekmpXx.js b/dist/assets/client-S4ekmpXx.js deleted file mode 100644 index c0de7c57..00000000 --- a/dist/assets/client-S4ekmpXx.js +++ /dev/null @@ -1 +0,0 @@ -var e=Object.create,t=Object.defineProperty,n=Object.getOwnPropertyDescriptor,r=Object.getOwnPropertyNames,i=Object.getPrototypeOf,a=Object.prototype.hasOwnProperty,o=(e,t,n)=>()=>{if(n)throw n[0];try{return e&&(t=e(e=0)),t}catch(e){throw n=[e],e}},s=(e,t)=>()=>(t||(e((t={exports:{}}).exports,t),e=null),t.exports),c=(e,n)=>{let r={};for(var i in e)t(r,i,{get:e[i],enumerable:!0});return n||t(r,Symbol.toStringTag,{value:`Module`}),r},l=(e,i,o,s)=>{if(i&&typeof i==`object`||typeof i==`function`)for(var c=r(i),l=0,u=c.length,d;li[e]).bind(null,d),enumerable:!(s=n(i,d))||s.enumerable});return e},u=(n,r,a)=>(a=n==null?{}:e(i(n)),l(r||!n||!n.__esModule?t(a,`default`,{value:n,enumerable:!0}):a,n)),d=e=>a.call(e,`module.exports`)?e[`module.exports`]:l(t({},`__esModule`,{value:!0}),e),f=s((e=>{var t=Symbol.for(`react.element`),n=Symbol.for(`react.portal`),r=Symbol.for(`react.fragment`),i=Symbol.for(`react.strict_mode`),a=Symbol.for(`react.profiler`),o=Symbol.for(`react.provider`),s=Symbol.for(`react.context`),c=Symbol.for(`react.forward_ref`),l=Symbol.for(`react.suspense`),u=Symbol.for(`react.memo`),d=Symbol.for(`react.lazy`),f=Symbol.iterator;function p(e){return typeof e!=`object`||!e?null:(e=f&&e[f]||e[`@@iterator`],typeof e==`function`?e:null)}var m={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},h=Object.assign,g={};function _(e,t,n){this.props=e,this.context=t,this.refs=g,this.updater=n||m}_.prototype.isReactComponent={},_.prototype.setState=function(e,t){if(typeof e!=`object`&&typeof e!=`function`&&e!=null)throw Error(`setState(...): takes an object of state variables to update or a function which returns an object of state variables.`);this.updater.enqueueSetState(this,e,t,`setState`)},_.prototype.forceUpdate=function(e){this.updater.enqueueForceUpdate(this,e,`forceUpdate`)};function v(){}v.prototype=_.prototype;function y(e,t,n){this.props=e,this.context=t,this.refs=g,this.updater=n||m}var b=y.prototype=new v;b.constructor=y,h(b,_.prototype),b.isPureReactComponent=!0;var x=Array.isArray,S=Object.prototype.hasOwnProperty,C={current:null},w={key:!0,ref:!0,__self:!0,__source:!0};function T(e,n,r){var i,a={},o=null,s=null;if(n!=null)for(i in n.ref!==void 0&&(s=n.ref),n.key!==void 0&&(o=``+n.key),n)S.call(n,i)&&!w.hasOwnProperty(i)&&(a[i]=n[i]);var c=arguments.length-2;if(c===1)a.children=r;else if(1{t.exports=f()})),m=s((e=>{var t=p(),n=Symbol.for(`react.element`),r=Symbol.for(`react.fragment`),i=Object.prototype.hasOwnProperty,a=t.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.ReactCurrentOwner,o={key:!0,ref:!0,__self:!0,__source:!0};function s(e,t,r){var s,c={},l=null,u=null;for(s in r!==void 0&&(l=``+r),t.key!==void 0&&(l=``+t.key),t.ref!==void 0&&(u=t.ref),t)i.call(t,s)&&!o.hasOwnProperty(s)&&(c[s]=t[s]);if(e&&e.defaultProps)for(s in t=e.defaultProps,t)c[s]===void 0&&(c[s]=t[s]);return{$$typeof:n,type:e,key:l,ref:u,props:c,_owner:a.current}}e.Fragment=r,e.jsx=s,e.jsxs=s})),h=s(((e,t)=>{t.exports=m()}));function g(e){var t,n,r=``;if(typeof e==`string`||typeof e==`number`)r+=e;else if(typeof e==`object`)if(Array.isArray(e)){var i=e.length;for(t=0;t{let t=C(e),{conflictingClassGroups:n,conflictingClassGroupModifiers:r}=e;return{getClassGroupId:e=>{let n=e.split(v);return n[0]===``&&n.length!==1&&n.shift(),b(n,t)||S(e)},getConflictingClassGroupIds:(e,t)=>{let i=n[e]||[];return t&&r[e]?[...i,...r[e]]:i}}},b=(e,t)=>{if(e.length===0)return t.classGroupId;let n=e[0],r=t.nextPart.get(n),i=r?b(e.slice(1),r):void 0;if(i)return i;if(t.validators.length===0)return;let a=e.join(v);return t.validators.find(({validator:e})=>e(a))?.classGroupId},x=/^\[(.+)\]$/,S=e=>{if(x.test(e)){let t=x.exec(e)[1],n=t?.substring(0,t.indexOf(`:`));if(n)return`arbitrary..`+n}},C=e=>{let{theme:t,prefix:n}=e,r={nextPart:new Map,validators:[]};return D(Object.entries(e.classGroups),n).forEach(([e,n])=>{w(n,r,e,t)}),r},w=(e,t,n,r)=>{e.forEach(e=>{if(typeof e==`string`){let r=e===``?t:T(t,e);r.classGroupId=n;return}if(typeof e==`function`){if(E(e)){w(e(r),t,n,r);return}t.validators.push({validator:e,classGroupId:n});return}Object.entries(e).forEach(([e,i])=>{w(i,T(t,e),n,r)})})},T=(e,t)=>{let n=e;return t.split(v).forEach(e=>{n.nextPart.has(e)||n.nextPart.set(e,{nextPart:new Map,validators:[]}),n=n.nextPart.get(e)}),n},E=e=>e.isThemeGetter,D=(e,t)=>t?e.map(([e,n])=>[e,n.map(e=>typeof e==`string`?t+e:typeof e==`object`?Object.fromEntries(Object.entries(e).map(([e,n])=>[t+e,n])):e)]):e,O=e=>{if(e<1)return{get:()=>void 0,set:()=>{}};let t=0,n=new Map,r=new Map,i=(i,a)=>{n.set(i,a),t++,t>e&&(t=0,r=n,n=new Map)};return{get(e){let t=n.get(e);if(t!==void 0)return t;if((t=r.get(e))!==void 0)return i(e,t),t},set(e,t){n.has(e)?n.set(e,t):i(e,t)}}},k=`!`,A=e=>{let{separator:t,experimentalParseClassName:n}=e,r=t.length===1,i=t[0],a=t.length,o=e=>{let n=[],o=0,s=0,c;for(let l=0;ls?c-s:void 0}};return n?e=>n({className:e,parseClassName:o}):o},j=e=>{if(e.length<=1)return e;let t=[],n=[];return e.forEach(e=>{e[0]===`[`?(t.push(...n.sort(),e),n=[]):n.push(e)}),t.push(...n.sort()),t},M=e=>({cache:O(e.cacheSize),parseClassName:A(e),...y(e)}),N=/\s+/,P=(e,t)=>{let{parseClassName:n,getClassGroupId:r,getConflictingClassGroupIds:i}=t,a=[],o=e.trim().split(N),s=``;for(let e=o.length-1;e>=0;--e){let t=o[e],{modifiers:c,hasImportantModifier:l,baseClassName:u,maybePostfixModifierPosition:d}=n(t),f=!!d,p=r(f?u.substring(0,d):u);if(!p){if(!f){s=t+(s.length>0?` `+s:s);continue}if(p=r(u),!p){s=t+(s.length>0?` `+s:s);continue}f=!1}let m=j(c).join(`:`),h=l?m+k:m,g=h+p;if(a.includes(g))continue;a.push(g);let _=i(p,f);for(let e=0;e<_.length;++e){let t=_[e];a.push(h+t)}s=t+(s.length>0?` `+s:s)}return s};function F(){let e=0,t,n,r=``;for(;e{if(typeof e==`string`)return e;let t,n=``;for(let r=0;rt(e),e())),r=n.cache.get,i=n.cache.set,a=s,s(o)}function s(e){let t=r(e);if(t)return t;let a=P(e,n);return i(e,a),a}return function(){return a(F.apply(null,arguments))}}var R=e=>{let t=t=>t[e]||[];return t.isThemeGetter=!0,t},ee=/^\[(?:([a-z-]+):)?(.+)\]$/i,te=/^\d+\/\d+$/,ne=new Set([`px`,`full`,`screen`]),re=/^(\d+(\.\d+)?)?(xs|sm|md|lg|xl)$/,ie=/\d+(%|px|r?em|[sdl]?v([hwib]|min|max)|pt|pc|in|cm|mm|cap|ch|ex|r?lh|cq(w|h|i|b|min|max))|\b(calc|min|max|clamp)\(.+\)|^0$/,ae=/^(rgba?|hsla?|hwb|(ok)?(lab|lch)|color-mix)\(.+\)$/,oe=/^(inset_)?-?((\d+)?\.?(\d+)[a-z]+|0)_-?((\d+)?\.?(\d+)[a-z]+|0)/,se=/^(url|image|image-set|cross-fade|element|(repeating-)?(linear|radial|conic)-gradient)\(.+\)$/,z=e=>V(e)||ne.has(e)||te.test(e),B=e=>q(e,`length`,he),V=e=>!!e&&!Number.isNaN(Number(e)),H=e=>q(e,`number`,V),U=e=>!!e&&Number.isInteger(Number(e)),ce=e=>e.endsWith(`%`)&&V(e.slice(0,-1)),W=e=>ee.test(e),G=e=>re.test(e),le=new Set([`length`,`size`,`percentage`]),ue=e=>q(e,le,ge),de=e=>q(e,`position`,ge),fe=new Set([`image`,`url`]),pe=e=>q(e,fe,ve),me=e=>q(e,``,_e),K=()=>!0,q=(e,t,n)=>{let r=ee.exec(e);return r?r[1]?typeof t==`string`?r[1]===t:t.has(r[1]):n(r[2]):!1},he=e=>ie.test(e)&&!ae.test(e),ge=()=>!1,_e=e=>oe.test(e),ve=e=>se.test(e),ye=L(()=>{let e=R(`colors`),t=R(`spacing`),n=R(`blur`),r=R(`brightness`),i=R(`borderColor`),a=R(`borderRadius`),o=R(`borderSpacing`),s=R(`borderWidth`),c=R(`contrast`),l=R(`grayscale`),u=R(`hueRotate`),d=R(`invert`),f=R(`gap`),p=R(`gradientColorStops`),m=R(`gradientColorStopPositions`),h=R(`inset`),g=R(`margin`),_=R(`opacity`),v=R(`padding`),y=R(`saturate`),b=R(`scale`),x=R(`sepia`),S=R(`skew`),C=R(`space`),w=R(`translate`),T=()=>[`auto`,`contain`,`none`],E=()=>[`auto`,`hidden`,`clip`,`visible`,`scroll`],D=()=>[`auto`,W,t],O=()=>[W,t],k=()=>[``,z,B],A=()=>[`auto`,V,W],j=()=>[`bottom`,`center`,`left`,`left-bottom`,`left-top`,`right`,`right-bottom`,`right-top`,`top`],M=()=>[`solid`,`dashed`,`dotted`,`double`,`none`],N=()=>[`normal`,`multiply`,`screen`,`overlay`,`darken`,`lighten`,`color-dodge`,`color-burn`,`hard-light`,`soft-light`,`difference`,`exclusion`,`hue`,`saturation`,`color`,`luminosity`],P=()=>[`start`,`end`,`center`,`between`,`around`,`evenly`,`stretch`],F=()=>[``,`0`,W],I=()=>[`auto`,`avoid`,`all`,`avoid-page`,`page`,`left`,`right`,`column`],L=()=>[V,W];return{cacheSize:500,separator:`:`,theme:{colors:[K],spacing:[z,B],blur:[`none`,``,G,W],brightness:L(),borderColor:[e],borderRadius:[`none`,``,`full`,G,W],borderSpacing:O(),borderWidth:k(),contrast:L(),grayscale:F(),hueRotate:L(),invert:F(),gap:O(),gradientColorStops:[e],gradientColorStopPositions:[ce,B],inset:D(),margin:D(),opacity:L(),padding:O(),saturate:L(),scale:L(),sepia:F(),skew:L(),space:O(),translate:O()},classGroups:{aspect:[{aspect:[`auto`,`square`,`video`,W]}],container:[`container`],columns:[{columns:[G]}],"break-after":[{"break-after":I()}],"break-before":[{"break-before":I()}],"break-inside":[{"break-inside":[`auto`,`avoid`,`avoid-page`,`avoid-column`]}],"box-decoration":[{"box-decoration":[`slice`,`clone`]}],box:[{box:[`border`,`content`]}],display:[`block`,`inline-block`,`inline`,`flex`,`inline-flex`,`table`,`inline-table`,`table-caption`,`table-cell`,`table-column`,`table-column-group`,`table-footer-group`,`table-header-group`,`table-row-group`,`table-row`,`flow-root`,`grid`,`inline-grid`,`contents`,`list-item`,`hidden`],float:[{float:[`right`,`left`,`none`,`start`,`end`]}],clear:[{clear:[`left`,`right`,`both`,`none`,`start`,`end`]}],isolation:[`isolate`,`isolation-auto`],"object-fit":[{object:[`contain`,`cover`,`fill`,`none`,`scale-down`]}],"object-position":[{object:[...j(),W]}],overflow:[{overflow:E()}],"overflow-x":[{"overflow-x":E()}],"overflow-y":[{"overflow-y":E()}],overscroll:[{overscroll:T()}],"overscroll-x":[{"overscroll-x":T()}],"overscroll-y":[{"overscroll-y":T()}],position:[`static`,`fixed`,`absolute`,`relative`,`sticky`],inset:[{inset:[h]}],"inset-x":[{"inset-x":[h]}],"inset-y":[{"inset-y":[h]}],start:[{start:[h]}],end:[{end:[h]}],top:[{top:[h]}],right:[{right:[h]}],bottom:[{bottom:[h]}],left:[{left:[h]}],visibility:[`visible`,`invisible`,`collapse`],z:[{z:[`auto`,U,W]}],basis:[{basis:D()}],"flex-direction":[{flex:[`row`,`row-reverse`,`col`,`col-reverse`]}],"flex-wrap":[{flex:[`wrap`,`wrap-reverse`,`nowrap`]}],flex:[{flex:[`1`,`auto`,`initial`,`none`,W]}],grow:[{grow:F()}],shrink:[{shrink:F()}],order:[{order:[`first`,`last`,`none`,U,W]}],"grid-cols":[{"grid-cols":[K]}],"col-start-end":[{col:[`auto`,{span:[`full`,U,W]},W]}],"col-start":[{"col-start":A()}],"col-end":[{"col-end":A()}],"grid-rows":[{"grid-rows":[K]}],"row-start-end":[{row:[`auto`,{span:[U,W]},W]}],"row-start":[{"row-start":A()}],"row-end":[{"row-end":A()}],"grid-flow":[{"grid-flow":[`row`,`col`,`dense`,`row-dense`,`col-dense`]}],"auto-cols":[{"auto-cols":[`auto`,`min`,`max`,`fr`,W]}],"auto-rows":[{"auto-rows":[`auto`,`min`,`max`,`fr`,W]}],gap:[{gap:[f]}],"gap-x":[{"gap-x":[f]}],"gap-y":[{"gap-y":[f]}],"justify-content":[{justify:[`normal`,...P()]}],"justify-items":[{"justify-items":[`start`,`end`,`center`,`stretch`]}],"justify-self":[{"justify-self":[`auto`,`start`,`end`,`center`,`stretch`]}],"align-content":[{content:[`normal`,...P(),`baseline`]}],"align-items":[{items:[`start`,`end`,`center`,`baseline`,`stretch`]}],"align-self":[{self:[`auto`,`start`,`end`,`center`,`stretch`,`baseline`]}],"place-content":[{"place-content":[...P(),`baseline`]}],"place-items":[{"place-items":[`start`,`end`,`center`,`baseline`,`stretch`]}],"place-self":[{"place-self":[`auto`,`start`,`end`,`center`,`stretch`]}],p:[{p:[v]}],px:[{px:[v]}],py:[{py:[v]}],ps:[{ps:[v]}],pe:[{pe:[v]}],pt:[{pt:[v]}],pr:[{pr:[v]}],pb:[{pb:[v]}],pl:[{pl:[v]}],m:[{m:[g]}],mx:[{mx:[g]}],my:[{my:[g]}],ms:[{ms:[g]}],me:[{me:[g]}],mt:[{mt:[g]}],mr:[{mr:[g]}],mb:[{mb:[g]}],ml:[{ml:[g]}],"space-x":[{"space-x":[C]}],"space-x-reverse":[`space-x-reverse`],"space-y":[{"space-y":[C]}],"space-y-reverse":[`space-y-reverse`],w:[{w:[`auto`,`min`,`max`,`fit`,`svw`,`lvw`,`dvw`,W,t]}],"min-w":[{"min-w":[W,t,`min`,`max`,`fit`]}],"max-w":[{"max-w":[W,t,`none`,`full`,`min`,`max`,`fit`,`prose`,{screen:[G]},G]}],h:[{h:[W,t,`auto`,`min`,`max`,`fit`,`svh`,`lvh`,`dvh`]}],"min-h":[{"min-h":[W,t,`min`,`max`,`fit`,`svh`,`lvh`,`dvh`]}],"max-h":[{"max-h":[W,t,`min`,`max`,`fit`,`svh`,`lvh`,`dvh`]}],size:[{size:[W,t,`auto`,`min`,`max`,`fit`]}],"font-size":[{text:[`base`,G,B]}],"font-smoothing":[`antialiased`,`subpixel-antialiased`],"font-style":[`italic`,`not-italic`],"font-weight":[{font:[`thin`,`extralight`,`light`,`normal`,`medium`,`semibold`,`bold`,`extrabold`,`black`,H]}],"font-family":[{font:[K]}],"fvn-normal":[`normal-nums`],"fvn-ordinal":[`ordinal`],"fvn-slashed-zero":[`slashed-zero`],"fvn-figure":[`lining-nums`,`oldstyle-nums`],"fvn-spacing":[`proportional-nums`,`tabular-nums`],"fvn-fraction":[`diagonal-fractions`,`stacked-fractions`],tracking:[{tracking:[`tighter`,`tight`,`normal`,`wide`,`wider`,`widest`,W]}],"line-clamp":[{"line-clamp":[`none`,V,H]}],leading:[{leading:[`none`,`tight`,`snug`,`normal`,`relaxed`,`loose`,z,W]}],"list-image":[{"list-image":[`none`,W]}],"list-style-type":[{list:[`none`,`disc`,`decimal`,W]}],"list-style-position":[{list:[`inside`,`outside`]}],"placeholder-color":[{placeholder:[e]}],"placeholder-opacity":[{"placeholder-opacity":[_]}],"text-alignment":[{text:[`left`,`center`,`right`,`justify`,`start`,`end`]}],"text-color":[{text:[e]}],"text-opacity":[{"text-opacity":[_]}],"text-decoration":[`underline`,`overline`,`line-through`,`no-underline`],"text-decoration-style":[{decoration:[...M(),`wavy`]}],"text-decoration-thickness":[{decoration:[`auto`,`from-font`,z,B]}],"underline-offset":[{"underline-offset":[`auto`,z,W]}],"text-decoration-color":[{decoration:[e]}],"text-transform":[`uppercase`,`lowercase`,`capitalize`,`normal-case`],"text-overflow":[`truncate`,`text-ellipsis`,`text-clip`],"text-wrap":[{text:[`wrap`,`nowrap`,`balance`,`pretty`]}],indent:[{indent:O()}],"vertical-align":[{align:[`baseline`,`top`,`middle`,`bottom`,`text-top`,`text-bottom`,`sub`,`super`,W]}],whitespace:[{whitespace:[`normal`,`nowrap`,`pre`,`pre-line`,`pre-wrap`,`break-spaces`]}],break:[{break:[`normal`,`words`,`all`,`keep`]}],hyphens:[{hyphens:[`none`,`manual`,`auto`]}],content:[{content:[`none`,W]}],"bg-attachment":[{bg:[`fixed`,`local`,`scroll`]}],"bg-clip":[{"bg-clip":[`border`,`padding`,`content`,`text`]}],"bg-opacity":[{"bg-opacity":[_]}],"bg-origin":[{"bg-origin":[`border`,`padding`,`content`]}],"bg-position":[{bg:[...j(),de]}],"bg-repeat":[{bg:[`no-repeat`,{repeat:[``,`x`,`y`,`round`,`space`]}]}],"bg-size":[{bg:[`auto`,`cover`,`contain`,ue]}],"bg-image":[{bg:[`none`,{"gradient-to":[`t`,`tr`,`r`,`br`,`b`,`bl`,`l`,`tl`]},pe]}],"bg-color":[{bg:[e]}],"gradient-from-pos":[{from:[m]}],"gradient-via-pos":[{via:[m]}],"gradient-to-pos":[{to:[m]}],"gradient-from":[{from:[p]}],"gradient-via":[{via:[p]}],"gradient-to":[{to:[p]}],rounded:[{rounded:[a]}],"rounded-s":[{"rounded-s":[a]}],"rounded-e":[{"rounded-e":[a]}],"rounded-t":[{"rounded-t":[a]}],"rounded-r":[{"rounded-r":[a]}],"rounded-b":[{"rounded-b":[a]}],"rounded-l":[{"rounded-l":[a]}],"rounded-ss":[{"rounded-ss":[a]}],"rounded-se":[{"rounded-se":[a]}],"rounded-ee":[{"rounded-ee":[a]}],"rounded-es":[{"rounded-es":[a]}],"rounded-tl":[{"rounded-tl":[a]}],"rounded-tr":[{"rounded-tr":[a]}],"rounded-br":[{"rounded-br":[a]}],"rounded-bl":[{"rounded-bl":[a]}],"border-w":[{border:[s]}],"border-w-x":[{"border-x":[s]}],"border-w-y":[{"border-y":[s]}],"border-w-s":[{"border-s":[s]}],"border-w-e":[{"border-e":[s]}],"border-w-t":[{"border-t":[s]}],"border-w-r":[{"border-r":[s]}],"border-w-b":[{"border-b":[s]}],"border-w-l":[{"border-l":[s]}],"border-opacity":[{"border-opacity":[_]}],"border-style":[{border:[...M(),`hidden`]}],"divide-x":[{"divide-x":[s]}],"divide-x-reverse":[`divide-x-reverse`],"divide-y":[{"divide-y":[s]}],"divide-y-reverse":[`divide-y-reverse`],"divide-opacity":[{"divide-opacity":[_]}],"divide-style":[{divide:M()}],"border-color":[{border:[i]}],"border-color-x":[{"border-x":[i]}],"border-color-y":[{"border-y":[i]}],"border-color-s":[{"border-s":[i]}],"border-color-e":[{"border-e":[i]}],"border-color-t":[{"border-t":[i]}],"border-color-r":[{"border-r":[i]}],"border-color-b":[{"border-b":[i]}],"border-color-l":[{"border-l":[i]}],"divide-color":[{divide:[i]}],"outline-style":[{outline:[``,...M()]}],"outline-offset":[{"outline-offset":[z,W]}],"outline-w":[{outline:[z,B]}],"outline-color":[{outline:[e]}],"ring-w":[{ring:k()}],"ring-w-inset":[`ring-inset`],"ring-color":[{ring:[e]}],"ring-opacity":[{"ring-opacity":[_]}],"ring-offset-w":[{"ring-offset":[z,B]}],"ring-offset-color":[{"ring-offset":[e]}],shadow:[{shadow:[``,`inner`,`none`,G,me]}],"shadow-color":[{shadow:[K]}],opacity:[{opacity:[_]}],"mix-blend":[{"mix-blend":[...N(),`plus-lighter`,`plus-darker`]}],"bg-blend":[{"bg-blend":N()}],filter:[{filter:[``,`none`]}],blur:[{blur:[n]}],brightness:[{brightness:[r]}],contrast:[{contrast:[c]}],"drop-shadow":[{"drop-shadow":[``,`none`,G,W]}],grayscale:[{grayscale:[l]}],"hue-rotate":[{"hue-rotate":[u]}],invert:[{invert:[d]}],saturate:[{saturate:[y]}],sepia:[{sepia:[x]}],"backdrop-filter":[{"backdrop-filter":[``,`none`]}],"backdrop-blur":[{"backdrop-blur":[n]}],"backdrop-brightness":[{"backdrop-brightness":[r]}],"backdrop-contrast":[{"backdrop-contrast":[c]}],"backdrop-grayscale":[{"backdrop-grayscale":[l]}],"backdrop-hue-rotate":[{"backdrop-hue-rotate":[u]}],"backdrop-invert":[{"backdrop-invert":[d]}],"backdrop-opacity":[{"backdrop-opacity":[_]}],"backdrop-saturate":[{"backdrop-saturate":[y]}],"backdrop-sepia":[{"backdrop-sepia":[x]}],"border-collapse":[{border:[`collapse`,`separate`]}],"border-spacing":[{"border-spacing":[o]}],"border-spacing-x":[{"border-spacing-x":[o]}],"border-spacing-y":[{"border-spacing-y":[o]}],"table-layout":[{table:[`auto`,`fixed`]}],caption:[{caption:[`top`,`bottom`]}],transition:[{transition:[`none`,`all`,``,`colors`,`opacity`,`shadow`,`transform`,W]}],duration:[{duration:L()}],ease:[{ease:[`linear`,`in`,`out`,`in-out`,W]}],delay:[{delay:L()}],animate:[{animate:[`none`,`spin`,`ping`,`pulse`,`bounce`,W]}],transform:[{transform:[``,`gpu`,`none`]}],scale:[{scale:[b]}],"scale-x":[{"scale-x":[b]}],"scale-y":[{"scale-y":[b]}],rotate:[{rotate:[U,W]}],"translate-x":[{"translate-x":[w]}],"translate-y":[{"translate-y":[w]}],"skew-x":[{"skew-x":[S]}],"skew-y":[{"skew-y":[S]}],"transform-origin":[{origin:[`center`,`top`,`top-right`,`right`,`bottom-right`,`bottom`,`bottom-left`,`left`,`top-left`,W]}],accent:[{accent:[`auto`,e]}],appearance:[{appearance:[`none`,`auto`]}],cursor:[{cursor:[`auto`,`default`,`pointer`,`wait`,`text`,`move`,`help`,`not-allowed`,`none`,`context-menu`,`progress`,`cell`,`crosshair`,`vertical-text`,`alias`,`copy`,`no-drop`,`grab`,`grabbing`,`all-scroll`,`col-resize`,`row-resize`,`n-resize`,`e-resize`,`s-resize`,`w-resize`,`ne-resize`,`nw-resize`,`se-resize`,`sw-resize`,`ew-resize`,`ns-resize`,`nesw-resize`,`nwse-resize`,`zoom-in`,`zoom-out`,W]}],"caret-color":[{caret:[e]}],"pointer-events":[{"pointer-events":[`none`,`auto`]}],resize:[{resize:[`none`,`y`,`x`,``]}],"scroll-behavior":[{scroll:[`auto`,`smooth`]}],"scroll-m":[{"scroll-m":O()}],"scroll-mx":[{"scroll-mx":O()}],"scroll-my":[{"scroll-my":O()}],"scroll-ms":[{"scroll-ms":O()}],"scroll-me":[{"scroll-me":O()}],"scroll-mt":[{"scroll-mt":O()}],"scroll-mr":[{"scroll-mr":O()}],"scroll-mb":[{"scroll-mb":O()}],"scroll-ml":[{"scroll-ml":O()}],"scroll-p":[{"scroll-p":O()}],"scroll-px":[{"scroll-px":O()}],"scroll-py":[{"scroll-py":O()}],"scroll-ps":[{"scroll-ps":O()}],"scroll-pe":[{"scroll-pe":O()}],"scroll-pt":[{"scroll-pt":O()}],"scroll-pr":[{"scroll-pr":O()}],"scroll-pb":[{"scroll-pb":O()}],"scroll-pl":[{"scroll-pl":O()}],"snap-align":[{snap:[`start`,`end`,`center`,`align-none`]}],"snap-stop":[{snap:[`normal`,`always`]}],"snap-type":[{snap:[`none`,`x`,`y`,`both`]}],"snap-strictness":[{snap:[`mandatory`,`proximity`]}],touch:[{touch:[`auto`,`none`,`manipulation`]}],"touch-x":[{"touch-pan":[`x`,`left`,`right`]}],"touch-y":[{"touch-pan":[`y`,`up`,`down`]}],"touch-pz":[`touch-pinch-zoom`],select:[{select:[`none`,`text`,`all`,`auto`]}],"will-change":[{"will-change":[`auto`,`scroll`,`contents`,`transform`,W]}],fill:[{fill:[e,`none`]}],"stroke-w":[{stroke:[z,B,H]}],stroke:[{stroke:[e,`none`]}],sr:[`sr-only`,`not-sr-only`],"forced-color-adjust":[{"forced-color-adjust":[`auto`,`none`]}]},conflictingClassGroups:{overflow:[`overflow-x`,`overflow-y`],overscroll:[`overscroll-x`,`overscroll-y`],inset:[`inset-x`,`inset-y`,`start`,`end`,`top`,`right`,`bottom`,`left`],"inset-x":[`right`,`left`],"inset-y":[`top`,`bottom`],flex:[`basis`,`grow`,`shrink`],gap:[`gap-x`,`gap-y`],p:[`px`,`py`,`ps`,`pe`,`pt`,`pr`,`pb`,`pl`],px:[`pr`,`pl`],py:[`pt`,`pb`],m:[`mx`,`my`,`ms`,`me`,`mt`,`mr`,`mb`,`ml`],mx:[`mr`,`ml`],my:[`mt`,`mb`],size:[`w`,`h`],"font-size":[`leading`],"fvn-normal":[`fvn-ordinal`,`fvn-slashed-zero`,`fvn-figure`,`fvn-spacing`,`fvn-fraction`],"fvn-ordinal":[`fvn-normal`],"fvn-slashed-zero":[`fvn-normal`],"fvn-figure":[`fvn-normal`],"fvn-spacing":[`fvn-normal`],"fvn-fraction":[`fvn-normal`],"line-clamp":[`display`,`overflow`],rounded:[`rounded-s`,`rounded-e`,`rounded-t`,`rounded-r`,`rounded-b`,`rounded-l`,`rounded-ss`,`rounded-se`,`rounded-ee`,`rounded-es`,`rounded-tl`,`rounded-tr`,`rounded-br`,`rounded-bl`],"rounded-s":[`rounded-ss`,`rounded-es`],"rounded-e":[`rounded-se`,`rounded-ee`],"rounded-t":[`rounded-tl`,`rounded-tr`],"rounded-r":[`rounded-tr`,`rounded-br`],"rounded-b":[`rounded-br`,`rounded-bl`],"rounded-l":[`rounded-tl`,`rounded-bl`],"border-spacing":[`border-spacing-x`,`border-spacing-y`],"border-w":[`border-w-s`,`border-w-e`,`border-w-t`,`border-w-r`,`border-w-b`,`border-w-l`],"border-w-x":[`border-w-r`,`border-w-l`],"border-w-y":[`border-w-t`,`border-w-b`],"border-color":[`border-color-s`,`border-color-e`,`border-color-t`,`border-color-r`,`border-color-b`,`border-color-l`],"border-color-x":[`border-color-r`,`border-color-l`],"border-color-y":[`border-color-t`,`border-color-b`],"scroll-m":[`scroll-mx`,`scroll-my`,`scroll-ms`,`scroll-me`,`scroll-mt`,`scroll-mr`,`scroll-mb`,`scroll-ml`],"scroll-mx":[`scroll-mr`,`scroll-ml`],"scroll-my":[`scroll-mt`,`scroll-mb`],"scroll-p":[`scroll-px`,`scroll-py`,`scroll-ps`,`scroll-pe`,`scroll-pt`,`scroll-pr`,`scroll-pb`,`scroll-pl`],"scroll-px":[`scroll-pr`,`scroll-pl`],"scroll-py":[`scroll-pt`,`scroll-pb`],touch:[`touch-x`,`touch-y`,`touch-pz`],"touch-x":[`touch`],"touch-y":[`touch`],"touch-pz":[`touch`]},conflictingClassGroupModifiers:{"font-size":[`leading`]}}});function be(...e){return ye(_(e))}function xe(e){if(e===0)return`0 B`;let t=1024,n=[`B`,`KB`,`MB`,`GB`],r=Math.floor(Math.log(e)/Math.log(t));return`${parseFloat((e/t**r).toFixed(1))} ${n[r]}`}var J=class extends Error{status;statusText;body;endpoint;constructor(e,t,n,r){super(`API Error ${e}: ${t} [${r}]`),this.status=e,this.statusText=t,this.body=n,this.endpoint=r,this.name=`ApiError`}get isUnauthorized(){return this.status===401}get isForbidden(){return this.status===403}get isNotFound(){return this.status===404}get isValidation(){return this.status===422}get isRateLimited(){return this.status===429}get isServerError(){return this.status>=500}get isUnavailable(){return this.status===503}},Y=class extends Error{endpoint;cause;constructor(e,t){super(`Network error: ${e}`),this.endpoint=e,this.cause=t,this.name=`NetworkError`}},X=class extends Error{endpoint;timeoutMs;constructor(e,t){super(`Request timed out after ${t}ms: ${e}`),this.endpoint=e,this.timeoutMs=t,this.name=`TimeoutError`}},Z=class extends Error{endpoint;constructor(e){super(`Request cancelled: ${e}`),this.endpoint=e,this.name=`CancelledError`}};function Se(e){return e instanceof J}function Ce(e){return e instanceof Y}function we(e){return e instanceof X}function Te(e){return e instanceof Z}function Ee(e){if(Se(e))switch(e.status){case 401:return`Authentication required. Please sign in.`;case 403:return`You do not have permission to perform this action.`;case 404:return`The requested resource was not found.`;case 422:return`The request data is invalid.`;case 429:return`Too many requests. Please try again later.`;case 503:return`Service is temporarily unavailable. Please try again.`;default:return e.status>=500?`A server error occurred. Please try again.`:e.message}return Ce(e)?`Unable to connect. Check your network connection.`:we(e)?`The request timed out. Please try again.`:Te(e)?`Request was cancelled.`:e instanceof Error?e.message:`An unknown error occurred.`}var Q={baseUrl:`http://localhost:8001`,defaultTimeout:3e4,defaultRetries:2,defaultRetryDelay:1e3};async function $(e,t,n,r){let i=r?.timeout??Q.defaultTimeout,a=r?.retries??Q.defaultRetries,o=r?.retryDelay??Q.defaultRetryDelay,s;for(let c=0;c<=a;c++)try{return await De(e,t,n,r,i)}catch(e){if(s=e,e instanceof Z||e instanceof J&&e.status<500&&e.status!==429)throw e;co.abort():null;s&&c&&s.addEventListener(`abort`,c);let l=setTimeout(()=>o.abort(),i);try{let i={...r?.headers},s=Q.getAuthToken?.();s&&(i.Authorization=`Bearer ${s}`),n&&!(n instanceof FormData)&&(i[`Content-Type`]=`application/json`);let c=await fetch(a,{method:e,headers:i,body:n instanceof FormData?n:n?JSON.stringify(n):void 0,signal:o.signal});if(!c.ok){let e;try{e=await c.json()}catch{e=await c.text().catch(()=>null)}let n=new J(c.status,c.statusText,e,t);throw n.isUnauthorized&&Q.onUnauthorized?.(),n}return c.headers.get(`content-type`)?.includes(`application/json`)?await c.json():await c.text()}catch(e){throw e instanceof J?e:o.signal.aborted?s?.aborted?new Z(t):new X(t,i):new Y(t,e)}finally{clearTimeout(l),s&&c&&s.removeEventListener(`abort`,c)}}async function Oe(e,t,n,r){let i=`${Q.baseUrl}${e}`,a=new AbortController,o=r?.signal;if(o?.aborted)throw new Z(e);let s=o?()=>a.abort():null;o&&s&&o.addEventListener(`abort`,s);let c=r?.timeout??12e4,l=setTimeout(()=>a.abort(),c);try{let o=new FormData;o.append(`file`,t),n&&Object.entries(n).forEach(([e,t])=>o.append(e,t));let s={...r?.headers},c=Q.getAuthToken?.();if(c&&(s.Authorization=`Bearer ${c}`),r?.onUploadProgress)return await ke(i,o,s,a.signal,r.onUploadProgress);let l=await fetch(i,{method:`POST`,headers:s,body:o,signal:a.signal});if(!l.ok){let t;try{t=await l.json()}catch{t=null}throw new J(l.status,l.statusText,t,e)}return await l.json()}catch(t){throw t instanceof J?t:a.signal.aborted?o?.aborted?new Z(e):new X(e,c):new Y(e,t)}finally{clearTimeout(l),o&&s&&o.removeEventListener(`abort`,s)}}async function ke(e,t,n,r,i){return new Promise((a,o)=>{let s=new XMLHttpRequest;s.open(`POST`,e),Object.entries(n).forEach(([e,t])=>s.setRequestHeader(e,t)),s.upload.onprogress=e=>{e.lengthComputable&&i(Math.round(e.loaded/e.total*100))},s.onload=()=>{if(s.status>=200&&s.status<300)try{a(JSON.parse(s.responseText))}catch{a(s.responseText)}else{let t;try{t=JSON.parse(s.responseText)}catch{t=s.responseText}o(new J(s.status,s.statusText,t,e))}},s.onerror=()=>o(new Y(e)),s.onabort=()=>o(new Z(e)),r.addEventListener(`abort`,()=>s.abort()),s.send(t)})}async function Ae(e,t,n,r){let i=`${Q.baseUrl}${e}`,a=new AbortController,o=r?.signal;if(o?.aborted)throw new Z(e);let s=o?()=>a.abort():null;o&&s&&o.addEventListener(`abort`,s);let c=r?.timeout??6e4,l=setTimeout(()=>a.abort(),c);try{let o={"Content-Type":`application/json`,Accept:`text/event-stream`,...r?.headers},s=Q.getAuthToken?.();s&&(o.Authorization=`Bearer ${s}`);let c=await fetch(i,{method:`POST`,headers:o,body:JSON.stringify(t),signal:a.signal});if(!c.ok){let t;try{t=await c.json()}catch{t=null}throw new J(c.status,c.statusText,t,e)}let u=c.body?.getReader();if(!u)throw new Y(e);let d=new TextDecoder;for(;;){let{done:e,value:t}=await u.read();if(e)break;clearTimeout(l),n(d.decode(t,{stream:!0}))}}catch(t){throw t instanceof J?t:a.signal.aborted?o?.aborted?new Z(e):new X(e,c):new Y(e,t)}finally{clearTimeout(l),o&&s&&o.removeEventListener(`abort`,s)}}function je(e){return new Promise(t=>setTimeout(t,e))}var Me={get:(e,t)=>$(`GET`,e,void 0,t),post:(e,t,n)=>$(`POST`,e,t,n),put:(e,t,n)=>$(`PUT`,e,t,n),patch:(e,t,n)=>$(`PATCH`,e,t,n),delete:(e,t)=>$(`DELETE`,e,void 0,t),upload:Oe,stream:Ae};export{be as a,p as c,c as d,d as f,Ee as i,s as l,Ae as n,xe as o,u as p,Oe as r,h as s,Me as t,o as u}; \ No newline at end of file diff --git a/dist/assets/clock-qFz1Yxfz.js b/dist/assets/clock-qFz1Yxfz.js deleted file mode 100644 index 74d1e605..00000000 --- a/dist/assets/clock-qFz1Yxfz.js +++ /dev/null @@ -1 +0,0 @@ -import{S as e}from"./index-QB2QUwKm.js";var t=e(`Clock`,[[`circle`,{cx:`12`,cy:`12`,r:`10`,key:`1mglay`}],[`polyline`,{points:`12 6 12 12 16 14`,key:`68esgv`}]]);export{t}; \ No newline at end of file diff --git a/dist/assets/download-BM44DUO2.js b/dist/assets/download-BM44DUO2.js deleted file mode 100644 index caa1bc75..00000000 --- a/dist/assets/download-BM44DUO2.js +++ /dev/null @@ -1 +0,0 @@ -import{S as e}from"./index-QB2QUwKm.js";var t=e(`Download`,[[`path`,{d:`M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4`,key:`ih7n3h`}],[`polyline`,{points:`7 10 12 15 17 10`,key:`2ggqvy`}],[`line`,{x1:`12`,x2:`12`,y1:`15`,y2:`3`,key:`1vk2je`}]]);export{t}; \ No newline at end of file diff --git a/dist/assets/file-BFCfRogd.js b/dist/assets/file-BFCfRogd.js deleted file mode 100644 index b2790f4a..00000000 --- a/dist/assets/file-BFCfRogd.js +++ /dev/null @@ -1 +0,0 @@ -import{S as e}from"./index-QB2QUwKm.js";var t=e(`File`,[[`path`,{d:`M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z`,key:`1rqfz7`}],[`path`,{d:`M14 2v4a2 2 0 0 0 2 2h4`,key:`tnqrlb`}]]);export{t}; \ No newline at end of file diff --git a/dist/assets/file-code-o79iDh0J.js b/dist/assets/file-code-o79iDh0J.js deleted file mode 100644 index 1bac87da..00000000 --- a/dist/assets/file-code-o79iDh0J.js +++ /dev/null @@ -1 +0,0 @@ -import{S as e}from"./index-QB2QUwKm.js";var t=e(`FileCode`,[[`path`,{d:`M10 12.5 8 15l2 2.5`,key:`1tg20x`}],[`path`,{d:`m14 12.5 2 2.5-2 2.5`,key:`yinavb`}],[`path`,{d:`M14 2v4a2 2 0 0 0 2 2h4`,key:`tnqrlb`}],[`path`,{d:`M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7z`,key:`1mlx9k`}]]);export{t}; \ No newline at end of file diff --git a/dist/assets/hard-drive-B0dUssIC.js b/dist/assets/hard-drive-B0dUssIC.js deleted file mode 100644 index e173aec2..00000000 --- a/dist/assets/hard-drive-B0dUssIC.js +++ /dev/null @@ -1 +0,0 @@ -import{S as e}from"./index-QB2QUwKm.js";var t=e(`HardDrive`,[[`line`,{x1:`22`,x2:`2`,y1:`12`,y2:`12`,key:`1y58io`}],[`path`,{d:`M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z`,key:`oot6mr`}],[`line`,{x1:`6`,x2:`6.01`,y1:`16`,y2:`16`,key:`sgf278`}],[`line`,{x1:`10`,x2:`10.01`,y1:`16`,y2:`16`,key:`1l4acy`}]]);export{t}; \ No newline at end of file diff --git a/dist/assets/index-BtBRIalZ.css b/dist/assets/index-BtBRIalZ.css deleted file mode 100644 index d5e710cd..00000000 --- a/dist/assets/index-BtBRIalZ.css +++ /dev/null @@ -1 +0,0 @@ -*,:before,:after,::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#3b82f680;--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }*,:before,:after{box-sizing:border-box;border:0 solid #e5e7eb}:before,:after{--tw-content:""}html,:host{-webkit-text-size-adjust:100%;tab-size:4;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent;font-family:Inter,system-ui,-apple-system,sans-serif;line-height:1.5}body{line-height:inherit;margin:0}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-feature-settings:normal;font-variation-settings:normal;font-family:JetBrains Mono,Fira Code,monospace;font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-feature-settings:inherit;font-variation-settings:inherit;font-family:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:#0000;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dl,dd,h1,h2,h3,h4,h5,h6,hr,figure,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}ol,ul,menu{margin:0;padding:0;list-style:none}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder{opacity:1;color:#9ca3af}textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}button,[role=button]{cursor:pointer}:disabled{cursor:default}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}:root{--background:0 0% 100%;--foreground:224 71% 4%;--card:0 0% 100%;--card-foreground:224 71% 4%;--popover:0 0% 100%;--popover-foreground:224 71% 4%;--primary:220 90% 56%;--primary-foreground:210 40% 98%;--secondary:220 14% 96%;--secondary-foreground:220 9% 46%;--muted:220 14% 96%;--muted-foreground:220 9% 46%;--accent:220 14% 96%;--accent-foreground:224 71% 4%;--destructive:0 84% 60%;--destructive-foreground:210 40% 98%;--success:142 76% 36%;--success-foreground:210 40% 98%;--warning:38 92% 50%;--warning-foreground:224 71% 4%;--border:220 13% 91%;--input:220 13% 91%;--ring:220 90% 56%;--radius:.5rem;--sidebar:0 0% 98%;--sidebar-foreground:224 71% 4%;--sidebar-border:220 13% 91%;--sidebar-accent:220 14% 96%;--sidebar-accent-foreground:224 71% 4%}.dark{--background:224 71% 4%;--foreground:213 31% 91%;--card:222 47% 7%;--card-foreground:213 31% 91%;--popover:222 47% 7%;--popover-foreground:213 31% 91%;--primary:217 91% 60%;--primary-foreground:222 47% 7%;--secondary:222 47% 11%;--secondary-foreground:215 20% 65%;--muted:223 47% 11%;--muted-foreground:215 20% 65%;--accent:216 34% 17%;--accent-foreground:213 31% 91%;--destructive:0 63% 51%;--destructive-foreground:210 40% 98%;--success:142 71% 45%;--success-foreground:222 47% 7%;--warning:38 92% 50%;--warning-foreground:222 47% 7%;--border:216 34% 17%;--input:216 34% 17%;--ring:217 91% 60%;--radius:.5rem;--sidebar:222 47% 5%;--sidebar-foreground:213 31% 91%;--sidebar-border:216 34% 17%;--sidebar-accent:216 34% 12%;--sidebar-accent-foreground:213 31% 91%}*{border-color:hsl(var(--border))}body{background-color:hsl(var(--background));color:hsl(var(--foreground));-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;font-family:Inter,system-ui,-apple-system,sans-serif}.container{width:100%}@media (width>=640px){.container{max-width:640px}}@media (width>=768px){.container{max-width:768px}}@media (width>=1024px){.container{max-width:1024px}}@media (width>=1280px){.container{max-width:1280px}}@media (width>=1536px){.container{max-width:1536px}}.pointer-events-none{pointer-events:none}.pointer-events-auto{pointer-events:auto}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:0}.bottom-0{bottom:0}.left-0{left:0}.left-2\.5{left:.625rem}.left-3{left:.75rem}.left-\[11px\]{left:11px}.right-0{right:0}.right-0\.5{right:.125rem}.right-1{right:.25rem}.right-2{right:.5rem}.right-3{right:.75rem}.top-0{top:0}.top-0\.5{top:.125rem}.top-1{top:.25rem}.top-1\/2{top:50%}.top-3{top:.75rem}.top-\[34px\]{top:34px}.top-full{top:100%}.z-10{z-index:10}.z-30{z-index:30}.z-40{z-index:40}.z-50{z-index:50}.z-\[100\]{z-index:100}.-m-6{margin:-1.5rem}.mx-1{margin-left:.25rem;margin-right:.25rem}.mx-auto{margin-left:auto;margin-right:auto}.mb-0\.5{margin-bottom:.125rem}.mb-1{margin-bottom:.25rem}.mb-1\.5{margin-bottom:.375rem}.mb-2{margin-bottom:.5rem}.mb-2\.5{margin-bottom:.625rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-6{margin-bottom:1.5rem}.mb-8{margin-bottom:2rem}.ml-16{margin-left:4rem}.ml-60{margin-left:15rem}.ml-auto{margin-left:auto}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-1\.5{margin-top:.375rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.mt-6{margin-top:1.5rem}.line-clamp-1{-webkit-line-clamp:1;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.line-clamp-2{-webkit-line-clamp:2;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.block{display:block}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.hidden{display:none}.\!h-2{height:.5rem!important}.h-0\.5{height:.125rem}.h-1\.5{height:.375rem}.h-10{height:2.5rem}.h-14{height:3.5rem}.h-16{height:4rem}.h-2{height:.5rem}.h-28{height:7rem}.h-3{height:.75rem}.h-3\.5{height:.875rem}.h-32{height:8rem}.h-4{height:1rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-7{height:1.75rem}.h-8{height:2rem}.h-9{height:2.25rem}.h-\[14px\]{height:14px}.h-\[calc\(100vh-14rem\)\]{height:calc(100vh - 14rem)}.h-\[calc\(100vh-8rem\)\]{height:calc(100vh - 8rem)}.h-full{height:100%}.h-screen{height:100vh}.max-h-40{max-height:10rem}.max-h-64{max-height:16rem}.max-h-\[620px\]{max-height:620px}.min-h-0{min-height:0}.min-h-\[400px\]{min-height:400px}.\!w-2{width:.5rem!important}.w-1{width:.25rem}.w-1\.5{width:.375rem}.w-10{width:2.5rem}.w-14{width:3.5rem}.w-16{width:4rem}.w-2{width:.5rem}.w-28{width:7rem}.w-3{width:.75rem}.w-3\.5{width:.875rem}.w-36{width:9rem}.w-4{width:1rem}.w-40{width:10rem}.w-48{width:12rem}.w-5{width:1.25rem}.w-56{width:14rem}.w-6{width:1.5rem}.w-64{width:16rem}.w-7{width:1.75rem}.w-72{width:18rem}.w-8{width:2rem}.w-80{width:20rem}.w-9{width:2.25rem}.w-96{width:24rem}.w-\[2px\]{width:2px}.w-fit{width:fit-content}.w-full{width:100%}.w-px{width:1px}.min-w-0{min-width:0}.min-w-\[160px\]{min-width:160px}.max-w-2xl{max-width:42rem}.max-w-3xl{max-width:48rem}.max-w-5xl{max-width:64rem}.max-w-\[200px\]{max-width:200px}.max-w-\[60\%\]{max-width:60%}.max-w-\[78\%\]{max-width:78%}.max-w-md{max-width:28rem}.max-w-sm{max-width:24rem}.max-w-xl{max-width:36rem}.flex-1{flex:1}.shrink-0{flex-shrink:0}.-translate-y-1\/2{--tw-translate-y:-50%;transform:translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.-rotate-90{--tw-rotate:-90deg;transform:translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.rotate-180{--tw-rotate:180deg;transform:translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.rotate-90{--tw-rotate:90deg;transform:translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.scale-105{--tw-scale-x:1.05;--tw-scale-y:1.05;transform:translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes scaleIn{0%{opacity:0;transform:scale(.95)}to{opacity:1;transform:scale(1)}}.animate-scale-in{animation:.2s ease-out scaleIn}@keyframes spin{to{transform:rotate(360deg)}}.animate-spin{animation:1s linear infinite spin}.cursor-col-resize{cursor:col-resize}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.justify-start{justify-content:flex-start}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-2\.5{gap:.625rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-6{gap:1.5rem}.space-y-0>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(0px * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(0px * var(--tw-space-y-reverse))}.space-y-0\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.125rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.125rem * var(--tw-space-y-reverse))}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.25rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem * var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem * var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.75rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem * var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem * var(--tw-space-y-reverse))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem * var(--tw-space-y-reverse))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-top-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px * var(--tw-divide-y-reverse))}.divide-border>:not([hidden])~:not([hidden]){border-color:hsl(var(--border))}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.\!rounded-lg{border-radius:var(--radius)!important}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:1rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:var(--radius)}.rounded-md{border-radius:calc(var(--radius) - 2px)}.rounded-xl{border-radius:.75rem}.\!border-0{border-width:0!important}.border{border-width:1px}.border-2{border-width:2px}.border-b{border-bottom-width:1px}.border-l{border-left-width:1px}.border-r{border-right-width:1px}.border-t{border-top-width:1px}.border-dashed{border-style:dashed}.\!border-border{border-color:hsl(var(--border))!important}.border-amber-500\/20{border-color:#f59e0b33}.border-amber-500\/30{border-color:#f59e0b4d}.border-blue-500\/20{border-color:#3b82f633}.border-blue-500\/30{border-color:#3b82f64d}.border-border{border-color:hsl(var(--border))}.border-border\/60{border-color:hsl(var(--border) / .6)}.border-cyan-500\/30{border-color:#06b6d44d}.border-destructive\/50{border-color:hsl(var(--destructive) / .5)}.border-emerald-500\/20{border-color:#10b98133}.border-emerald-500\/30{border-color:#10b9814d}.border-gray-500\/30{border-color:#6b72804d}.border-green-500\/30{border-color:#22c55e4d}.border-indigo-500\/30{border-color:#6366f14d}.border-orange-500\/20{border-color:#f9731633}.border-orange-500\/30{border-color:#f973164d}.border-pink-500\/30{border-color:#ec48994d}.border-primary{border-color:hsl(var(--primary))}.border-primary\/30{border-color:hsl(var(--primary) / .3)}.border-primary\/50{border-color:hsl(var(--primary) / .5)}.border-red-500\/20{border-color:#ef444433}.border-red-500\/30{border-color:#ef44444d}.border-rose-500\/30{border-color:#f43f5e4d}.border-sidebar-border{border-color:hsl(var(--sidebar-border))}.border-sky-500\/30{border-color:#0ea5e94d}.border-slate-500\/30{border-color:#64748b4d}.border-success\/30{border-color:hsl(var(--success) / .3)}.border-teal-500\/30{border-color:#14b8a64d}.border-violet-500\/20{border-color:#8b5cf633}.border-violet-500\/30{border-color:#8b5cf64d}.border-yellow-500\/30{border-color:#eab3084d}.border-zinc-500\/30{border-color:#71717a4d}.border-t-transparent{border-top-color:#0000}.\!bg-card{background-color:hsl(var(--card))!important}.\!bg-muted-foreground\/50{background-color:hsl(var(--muted-foreground) / .5)!important}.bg-\[\#0a0e1a\]{--tw-bg-opacity:1;background-color:rgb(10 14 26/var(--tw-bg-opacity,1))}.bg-accent{background-color:hsl(var(--accent))}.bg-accent\/50{background-color:hsl(var(--accent) / .5)}.bg-accent\/60{background-color:hsl(var(--accent) / .6)}.bg-amber-400{--tw-bg-opacity:1;background-color:rgb(251 191 36/var(--tw-bg-opacity,1))}.bg-amber-500\/10{background-color:#f59e0b1a}.bg-amber-500\/5{background-color:#f59e0b0d}.bg-background{background-color:hsl(var(--background))}.bg-background\/80{background-color:hsl(var(--background) / .8)}.bg-blue-400{--tw-bg-opacity:1;background-color:rgb(96 165 250/var(--tw-bg-opacity,1))}.bg-blue-500\/10{background-color:#3b82f61a}.bg-blue-500\/5{background-color:#3b82f60d}.bg-border{background-color:hsl(var(--border))}.bg-card{background-color:hsl(var(--card))}.bg-card\/50{background-color:hsl(var(--card) / .5)}.bg-card\/90{background-color:hsl(var(--card) / .9)}.bg-cyan-500\/10{background-color:#06b6d41a}.bg-destructive\/10{background-color:hsl(var(--destructive) / .1)}.bg-destructive\/5{background-color:hsl(var(--destructive) / .05)}.bg-emerald-500\/10{background-color:#10b9811a}.bg-gray-500\/10{background-color:#6b72801a}.bg-green-400{--tw-bg-opacity:1;background-color:rgb(74 222 128/var(--tw-bg-opacity,1))}.bg-green-500\/10{background-color:#22c55e1a}.bg-indigo-500\/10{background-color:#6366f11a}.bg-muted{background-color:hsl(var(--muted))}.bg-muted\/50{background-color:hsl(var(--muted) / .5)}.bg-orange-400{--tw-bg-opacity:1;background-color:rgb(251 146 60/var(--tw-bg-opacity,1))}.bg-orange-500\/10{background-color:#f973161a}.bg-orange-500\/5{background-color:#f973160d}.bg-pink-500\/10{background-color:#ec48991a}.bg-popover{background-color:hsl(var(--popover))}.bg-primary{background-color:hsl(var(--primary))}.bg-primary-foreground{background-color:hsl(var(--primary-foreground))}.bg-primary\/10{background-color:hsl(var(--primary) / .1)}.bg-primary\/20{background-color:hsl(var(--primary) / .2)}.bg-primary\/5{background-color:hsl(var(--primary) / .05)}.bg-red-400{--tw-bg-opacity:1;background-color:rgb(248 113 113/var(--tw-bg-opacity,1))}.bg-red-500\/10{background-color:#ef44441a}.bg-red-500\/5{background-color:#ef44440d}.bg-rose-500\/10{background-color:#f43f5e1a}.bg-sidebar{background-color:hsl(var(--sidebar))}.bg-sidebar-accent{background-color:hsl(var(--sidebar-accent))}.bg-sky-500\/10{background-color:#0ea5e91a}.bg-slate-500\/10{background-color:#64748b1a}.bg-success{background-color:hsl(var(--success))}.bg-success\/10{background-color:hsl(var(--success) / .1)}.bg-success\/20{background-color:hsl(var(--success) / .2)}.bg-success\/30{background-color:hsl(var(--success) / .3)}.bg-success\/5{background-color:hsl(var(--success) / .05)}.bg-teal-500\/10{background-color:#14b8a61a}.bg-violet-500\/10{background-color:#8b5cf61a}.bg-warning\/10{background-color:hsl(var(--warning) / .1)}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.bg-yellow-500\/10{background-color:#eab3081a}.bg-zinc-500\/10{background-color:#71717a1a}.fill-current{fill:currentColor}.p-1{padding:.25rem}.p-1\.5{padding:.375rem}.p-12{padding:3rem}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.p-8{padding:2rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-1\.5{padding-left:.375rem;padding-right:.375rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-16{padding-top:4rem;padding-bottom:4rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-2\.5{padding-top:.625rem;padding-bottom:.625rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-3\.5{padding-top:.875rem;padding-bottom:.875rem}.py-4{padding-top:1rem;padding-bottom:1rem}.py-6{padding-top:1.5rem;padding-bottom:1.5rem}.py-8{padding-top:2rem;padding-bottom:2rem}.py-\[3px\]{padding-top:3px;padding-bottom:3px}.pb-1{padding-bottom:.25rem}.pb-3{padding-bottom:.75rem}.pl-4{padding-left:1rem}.pl-8{padding-left:2rem}.pl-9{padding-left:2.25rem}.pr-3{padding-right:.75rem}.pr-8{padding-right:2rem}.pt-1{padding-top:.25rem}.pt-2{padding-top:.5rem}.pt-4{padding-top:1rem}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.font-mono{font-family:JetBrains Mono,Fira Code,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-2xs{font-size:.625rem;line-height:.875rem}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.text-\[13px\]{font-size:13px}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.capitalize{text-transform:capitalize}.italic{font-style:italic}.leading-6{line-height:1.5rem}.leading-relaxed{line-height:1.625}.tracking-tight{letter-spacing:-.025em}.tracking-wider{letter-spacing:.05em}.text-accent-foreground{color:hsl(var(--accent-foreground))}.text-amber-400{--tw-text-opacity:1;color:rgb(251 191 36/var(--tw-text-opacity,1))}.text-blue-400{--tw-text-opacity:1;color:rgb(96 165 250/var(--tw-text-opacity,1))}.text-cyan-400{--tw-text-opacity:1;color:rgb(34 211 238/var(--tw-text-opacity,1))}.text-destructive{color:hsl(var(--destructive))}.text-destructive\/80{color:hsl(var(--destructive) / .8)}.text-emerald-400{--tw-text-opacity:1;color:rgb(52 211 153/var(--tw-text-opacity,1))}.text-foreground{color:hsl(var(--foreground))}.text-gray-400{--tw-text-opacity:1;color:rgb(156 163 175/var(--tw-text-opacity,1))}.text-green-400{--tw-text-opacity:1;color:rgb(74 222 128/var(--tw-text-opacity,1))}.text-green-500{--tw-text-opacity:1;color:rgb(34 197 94/var(--tw-text-opacity,1))}.text-indigo-400{--tw-text-opacity:1;color:rgb(129 140 248/var(--tw-text-opacity,1))}.text-muted-foreground{color:hsl(var(--muted-foreground))}.text-muted-foreground\/30{color:hsl(var(--muted-foreground) / .3)}.text-muted-foreground\/50{color:hsl(var(--muted-foreground) / .5)}.text-muted-foreground\/60{color:hsl(var(--muted-foreground) / .6)}.text-muted-foreground\/70{color:hsl(var(--muted-foreground) / .7)}.text-orange-400{--tw-text-opacity:1;color:rgb(251 146 60/var(--tw-text-opacity,1))}.text-orange-500{--tw-text-opacity:1;color:rgb(249 115 22/var(--tw-text-opacity,1))}.text-pink-400{--tw-text-opacity:1;color:rgb(244 114 182/var(--tw-text-opacity,1))}.text-primary{color:hsl(var(--primary))}.text-primary-foreground{color:hsl(var(--primary-foreground))}.text-primary\/70{color:hsl(var(--primary) / .7)}.text-red-400{--tw-text-opacity:1;color:rgb(248 113 113/var(--tw-text-opacity,1))}.text-rose-400{--tw-text-opacity:1;color:rgb(251 113 133/var(--tw-text-opacity,1))}.text-sidebar-accent-foreground{color:hsl(var(--sidebar-accent-foreground))}.text-sidebar-foreground{color:hsl(var(--sidebar-foreground))}.text-sky-400{--tw-text-opacity:1;color:rgb(56 189 248/var(--tw-text-opacity,1))}.text-slate-400{--tw-text-opacity:1;color:rgb(148 163 184/var(--tw-text-opacity,1))}.text-success{color:hsl(var(--success))}.text-teal-400{--tw-text-opacity:1;color:rgb(45 212 191/var(--tw-text-opacity,1))}.text-violet-400{--tw-text-opacity:1;color:rgb(167 139 250/var(--tw-text-opacity,1))}.text-warning{color:hsl(var(--warning))}.text-yellow-400{--tw-text-opacity:1;color:rgb(250 204 21/var(--tw-text-opacity,1))}.text-yellow-600{--tw-text-opacity:1;color:rgb(202 138 4/var(--tw-text-opacity,1))}.text-zinc-400{--tw-text-opacity:1;color:rgb(161 161 170/var(--tw-text-opacity,1))}.opacity-0{opacity:0}.opacity-50{opacity:.5}.shadow-lg{--tw-shadow:0 10px 15px -3px #0000001a, 0 4px 6px -4px #0000001a;--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000), var(--tw-ring-shadow,0 0 #0000), var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px #0000001a, 0 2px 4px -2px #0000001a;--tw-shadow-colored:0 4px 6px -1px var(--tw-shadow-color), 0 2px 4px -2px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000), var(--tw-ring-shadow,0 0 #0000), var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 2px 0 #0000000d;--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000), var(--tw-ring-shadow,0 0 #0000), var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px #0000001a, 0 8px 10px -6px #0000001a;--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color), 0 8px 10px -6px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000), var(--tw-ring-shadow,0 0 #0000), var(--tw-shadow)}.outline-none{outline-offset:2px;outline:2px solid #0000}.ring-1{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow,0 0 #0000)}.ring-2{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow,0 0 #0000)}.ring-amber-500\/50{--tw-ring-color:#f59e0b80}.ring-primary{--tw-ring-color:hsl(var(--primary))}.ring-primary\/50{--tw-ring-color:hsl(var(--primary) / .5)}.ring-red-500\/60{--tw-ring-color:#ef444499}.ring-yellow-500\/30{--tw-ring-color:#eab3084d}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.backdrop-blur-sm{--tw-backdrop-blur:blur(4px);-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter,backdrop-filter;transition-duration:.15s;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-all{transition-property:all;transition-duration:.15s;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-duration:.15s;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-opacity{transition-property:opacity;transition-duration:.15s;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-shadow{transition-property:box-shadow;transition-duration:.15s;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-transform{transition-property:transform;transition-duration:.15s;transition-timing-function:cubic-bezier(.4,0,.2,1)}.duration-100{transition-duration:.1s}.duration-1000{transition-duration:1s}.duration-150{transition-duration:.15s}.duration-200{transition-duration:.2s}.duration-500{transition-duration:.5s}.ease-out{transition-timing-function:cubic-bezier(0,0,.2,1)}@keyframes enter{0%{opacity:var(--tw-enter-opacity,1);transform:translate3d(var(--tw-enter-translate-x,0), var(--tw-enter-translate-y,0), 0) scale3d(var(--tw-enter-scale,1), var(--tw-enter-scale,1), var(--tw-enter-scale,1)) rotate(var(--tw-enter-rotate,0))}}@keyframes exit{to{opacity:var(--tw-exit-opacity,1);transform:translate3d(var(--tw-exit-translate-x,0), var(--tw-exit-translate-y,0), 0) scale3d(var(--tw-exit-scale,1), var(--tw-exit-scale,1), var(--tw-exit-scale,1)) rotate(var(--tw-exit-rotate,0))}}.animate-in{--tw-enter-opacity:initial;--tw-enter-scale:initial;--tw-enter-rotate:initial;--tw-enter-translate-x:initial;--tw-enter-translate-y:initial;animation-name:enter;animation-duration:.15s}.fade-in{--tw-enter-opacity:0}.zoom-in-95{--tw-enter-scale:.95}.duration-100{animation-duration:.1s}.duration-1000{animation-duration:1s}.duration-150{animation-duration:.15s}.duration-200{animation-duration:.2s}.duration-500{animation-duration:.5s}.ease-out{animation-timing-function:cubic-bezier(0,0,.2,1)}.scrollbar-thin{scrollbar-width:thin}.scrollbar-thin::-webkit-scrollbar{width:6px;height:6px}.scrollbar-thin::-webkit-scrollbar-track{background:0 0}.scrollbar-thin::-webkit-scrollbar-thumb{background:hsl(var(--border));border-radius:3px}.scrollbar-thin::-webkit-scrollbar-thumb:hover{background:hsl(var(--muted-foreground))}.placeholder\:text-muted-foreground::placeholder{color:hsl(var(--muted-foreground))}.hover\:border-muted-foreground\/30:hover{border-color:hsl(var(--muted-foreground) / .3)}.hover\:border-muted-foreground\/50:hover{border-color:hsl(var(--muted-foreground) / .5)}.hover\:border-primary\/20:hover{border-color:hsl(var(--primary) / .2)}.hover\:border-primary\/30:hover{border-color:hsl(var(--primary) / .3)}.hover\:bg-accent:hover{background-color:hsl(var(--accent))}.hover\:bg-accent\/30:hover{background-color:hsl(var(--accent) / .3)}.hover\:bg-accent\/40:hover{background-color:hsl(var(--accent) / .4)}.hover\:bg-accent\/50:hover{background-color:hsl(var(--accent) / .5)}.hover\:bg-accent\/80:hover{background-color:hsl(var(--accent) / .8)}.hover\:bg-destructive\/10:hover{background-color:hsl(var(--destructive) / .1)}.hover\:bg-primary\/20:hover{background-color:hsl(var(--primary) / .2)}.hover\:bg-primary\/30:hover{background-color:hsl(var(--primary) / .3)}.hover\:bg-primary\/90:hover{background-color:hsl(var(--primary) / .9)}.hover\:bg-sidebar-accent:hover{background-color:hsl(var(--sidebar-accent))}.hover\:bg-sidebar-accent\/50:hover{background-color:hsl(var(--sidebar-accent) / .5)}.hover\:text-destructive:hover{color:hsl(var(--destructive))}.hover\:text-foreground:hover{color:hsl(var(--foreground))}.hover\:text-primary:hover{color:hsl(var(--primary))}.hover\:text-primary\/80:hover{color:hsl(var(--primary) / .8)}.hover\:text-sidebar-accent-foreground:hover{color:hsl(var(--sidebar-accent-foreground))}.hover\:text-sidebar-foreground:hover{color:hsl(var(--sidebar-foreground))}.hover\:underline:hover{text-decoration-line:underline}.hover\:shadow-sm:hover{--tw-shadow:0 1px 2px 0 #0000000d;--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000), var(--tw-ring-shadow,0 0 #0000), var(--tw-shadow)}.focus\:outline-none:focus{outline-offset:2px;outline:2px solid #0000}.focus\:ring-1:focus{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow,0 0 #0000)}.focus\:ring-ring:focus{--tw-ring-color:hsl(var(--ring))}.active\:bg-primary\/30:active{background-color:hsl(var(--primary) / .3)}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-40:disabled{opacity:.4}.disabled\:opacity-50:disabled{opacity:.5}.group:hover .group-hover\:opacity-100{opacity:1}@media (width>=640px){.sm\:block{display:block}.sm\:inline{display:inline}.sm\:table-cell{display:table-cell}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.sm\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.sm\:grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}.sm\:items-center{align-items:center}}@media (width>=768px){.md\:table-cell{display:table-cell}}@media (width>=1024px){.lg\:table-cell{display:table-cell}.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.lg\:grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}.lg\:flex-row{flex-direction:row}.lg\:items-center{align-items:center}} diff --git a/dist/assets/index-QB2QUwKm.js b/dist/assets/index-QB2QUwKm.js deleted file mode 100644 index 4032469c..00000000 --- a/dist/assets/index-QB2QUwKm.js +++ /dev/null @@ -1,19 +0,0 @@ -const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/DashboardPage-B7P5IANl.js","assets/client-S4ekmpXx.js","assets/activity-BtAw6juF.js","assets/clock-qFz1Yxfz.js","assets/PageHeader-DsNWbEI1.js","assets/EmptyState-BeMA8Ikt.js","assets/DataSourceBadge-D7dhrb9n.js","assets/status-5ylMtKLG.js","assets/RepositoriesPage-GMDpbLMw.js","assets/RepositoryDetailPage-DG0b2Pf1.js","assets/arrow-left-BeIPd3Ya.js","assets/panel-left-open-DLIMLp_X.js","assets/file-code-o79iDh0J.js","assets/file-BFCfRogd.js","assets/hard-drive-B0dUssIC.js","assets/package-Dhq59A-j.js","assets/x-Bf4UPE8b.js","assets/UploadPage-04HmGrk-.js","assets/arrow-right-DpUoK8Nu.js","assets/circle-alert-BUYmyF7c.js","assets/AnalysisPipelinePage-Zvy8DKxE.js","assets/circle-Ds7OrsG4.js","assets/ArchitecturePage-7B6CwUGU.js","assets/zap-8B5FG3kN.js","assets/download-BM44DUO2.js","assets/ArchitecturePage-DLioOiRN.css","assets/DependenciesPage-L38XGsJu.js","assets/useRepositoryFeatureStatus-BAj4lrBf.js","assets/EngineeringReviewPage-BpcXtZzj.js","assets/AIWorkspacePage-Ddm0QeRu.js","assets/ai-DpySo4Gb.js","assets/DocumentationPage-CP6VAto-.js","assets/InsightsPage-BlJ2erOS.js","assets/SettingsPage-vKuDSF0h.js"])))=>i.map(i=>d[i]); -import{a as e,c as t,d as n,f as r,l as i,p as a,r as o,s,t as c,u as l}from"./client-S4ekmpXx.js";(function(){let e=document.createElement(`link`).relList;if(e&&e.supports&&e.supports(`modulepreload`))return;for(let e of document.querySelectorAll(`link[rel="modulepreload"]`))n(e);new MutationObserver(e=>{for(let t of e)if(t.type===`childList`)for(let e of t.addedNodes)e.tagName===`LINK`&&e.rel===`modulepreload`&&n(e)}).observe(document,{childList:!0,subtree:!0});function t(e){let t={};return e.integrity&&(t.integrity=e.integrity),e.referrerPolicy&&(t.referrerPolicy=e.referrerPolicy),e.crossOrigin===`use-credentials`?t.credentials=`include`:e.crossOrigin===`anonymous`?t.credentials=`omit`:t.credentials=`same-origin`,t}function n(e){if(e.ep)return;e.ep=!0;let n=t(e);fetch(e.href,n)}})();var u=i((e=>{function t(e,t){var n=e.length;e.push(t);a:for(;0>>1,a=e[r];if(0>>1;ri(c,n))li(u,c)?(e[r]=u,e[l]=n,r=l):(e[r]=c,e[s]=n,r=s);else if(li(u,n))e[r]=u,e[l]=n,r=l;else break a}}return t}function i(e,t){var n=e.sortIndex-t.sortIndex;return n===0?e.id-t.id:n}if(typeof performance==`object`&&typeof performance.now==`function`){var a=performance;e.unstable_now=function(){return a.now()}}else{var o=Date,s=o.now();e.unstable_now=function(){return o.now()-s}}var c=[],l=[],u=1,d=null,f=3,p=!1,m=!1,h=!1,g=typeof setTimeout==`function`?setTimeout:null,_=typeof clearTimeout==`function`?clearTimeout:null,v=typeof setImmediate<`u`?setImmediate:null;typeof navigator<`u`&&navigator.scheduling!==void 0&&navigator.scheduling.isInputPending!==void 0&&navigator.scheduling.isInputPending.bind(navigator.scheduling);function y(e){for(var i=n(l);i!==null;){if(i.callback===null)r(l);else if(i.startTime<=e)r(l),i.sortIndex=i.expirationTime,t(c,i);else break;i=n(l)}}function b(e){if(h=!1,y(e),!m)if(n(c)!==null)m=!0,j(x);else{var t=n(l);t!==null&&te(b,t.startTime-e)}}function x(t,i){m=!1,h&&(h=!1,_(w),w=-1),p=!0;var a=f;try{for(y(i),d=n(c);d!==null&&(!(d.expirationTime>i)||t&&!D());){var o=d.callback;if(typeof o==`function`){d.callback=null,f=d.priorityLevel;var s=o(d.expirationTime<=i);i=e.unstable_now(),typeof s==`function`?d.callback=s:d===n(c)&&r(c),y(i)}else r(c);d=n(c)}if(d!==null)var u=!0;else{var g=n(l);g!==null&&te(b,g.startTime-i),u=!1}return u}finally{d=null,f=a,p=!1}}var S=!1,C=null,w=-1,T=5,E=-1;function D(){return!(e.unstable_now()-Ee||125o?(r.sortIndex=a,t(l,r),n(c)===null&&r===n(l)&&(h?(_(w),w=-1):h=!0,te(b,a-o))):(r.sortIndex=s,t(c,r),m||p||(m=!0,j(x))),r},e.unstable_shouldYield=D,e.unstable_wrapCallback=function(e){var t=f;return function(){var n=f;f=t;try{return e.apply(this,arguments)}finally{f=n}}}})),d=i(((e,t)=>{t.exports=u()})),f=i((e=>{var n=t(),r=d();function i(e){for(var t=`https://reactjs.org/docs/error-decoder.html?invariant=`+e,n=1;n`u`||window.document===void 0||window.document.createElement===void 0),u=Object.prototype.hasOwnProperty,f=/^[:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD][:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\-.0-9\u00B7\u0300-\u036F\u203F-\u2040]*$/,p={},m={};function h(e){return u.call(m,e)?!0:u.call(p,e)?!1:f.test(e)?m[e]=!0:(p[e]=!0,!1)}function g(e,t,n,r){if(n!==null&&n.type===0)return!1;switch(typeof t){case`function`:case`symbol`:return!0;case`boolean`:return r?!1:n===null?(e=e.toLowerCase().slice(0,5),e!==`data-`&&e!==`aria-`):!n.acceptsBooleans;default:return!1}}function _(e,t,n,r){if(t==null||g(e,t,n,r))return!0;if(r)return!1;if(n!==null)switch(n.type){case 3:return!t;case 4:return!1===t;case 5:return isNaN(t);case 6:return isNaN(t)||1>t}return!1}function v(e,t,n,r,i,a,o){this.acceptsBooleans=t===2||t===3||t===4,this.attributeName=r,this.attributeNamespace=i,this.mustUseProperty=n,this.propertyName=e,this.type=t,this.sanitizeURL=a,this.removeEmptyString=o}var y={};`children dangerouslySetInnerHTML defaultValue defaultChecked innerHTML suppressContentEditableWarning suppressHydrationWarning style`.split(` `).forEach(function(e){y[e]=new v(e,0,!1,e,null,!1,!1)}),[[`acceptCharset`,`accept-charset`],[`className`,`class`],[`htmlFor`,`for`],[`httpEquiv`,`http-equiv`]].forEach(function(e){var t=e[0];y[t]=new v(t,1,!1,e[1],null,!1,!1)}),[`contentEditable`,`draggable`,`spellCheck`,`value`].forEach(function(e){y[e]=new v(e,2,!1,e.toLowerCase(),null,!1,!1)}),[`autoReverse`,`externalResourcesRequired`,`focusable`,`preserveAlpha`].forEach(function(e){y[e]=new v(e,2,!1,e,null,!1,!1)}),`allowFullScreen async autoFocus autoPlay controls default defer disabled disablePictureInPicture disableRemotePlayback formNoValidate hidden loop noModule noValidate open playsInline readOnly required reversed scoped seamless itemScope`.split(` `).forEach(function(e){y[e]=new v(e,3,!1,e.toLowerCase(),null,!1,!1)}),[`checked`,`multiple`,`muted`,`selected`].forEach(function(e){y[e]=new v(e,3,!0,e,null,!1,!1)}),[`capture`,`download`].forEach(function(e){y[e]=new v(e,4,!1,e,null,!1,!1)}),[`cols`,`rows`,`size`,`span`].forEach(function(e){y[e]=new v(e,6,!1,e,null,!1,!1)}),[`rowSpan`,`start`].forEach(function(e){y[e]=new v(e,5,!1,e.toLowerCase(),null,!1,!1)});var b=/[\-:]([a-z])/g;function x(e){return e[1].toUpperCase()}`accent-height alignment-baseline arabic-form baseline-shift cap-height clip-path clip-rule color-interpolation color-interpolation-filters color-profile color-rendering dominant-baseline enable-background fill-opacity fill-rule flood-color flood-opacity font-family font-size font-size-adjust font-stretch font-style font-variant font-weight glyph-name glyph-orientation-horizontal glyph-orientation-vertical horiz-adv-x horiz-origin-x image-rendering letter-spacing lighting-color marker-end marker-mid marker-start overline-position overline-thickness paint-order panose-1 pointer-events rendering-intent shape-rendering stop-color stop-opacity strikethrough-position strikethrough-thickness stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit stroke-opacity stroke-width text-anchor text-decoration text-rendering underline-position underline-thickness unicode-bidi unicode-range units-per-em v-alphabetic v-hanging v-ideographic v-mathematical vector-effect vert-adv-y vert-origin-x vert-origin-y word-spacing writing-mode xmlns:xlink x-height`.split(` `).forEach(function(e){var t=e.replace(b,x);y[t]=new v(t,1,!1,e,null,!1,!1)}),`xlink:actuate xlink:arcrole xlink:role xlink:show xlink:title xlink:type`.split(` `).forEach(function(e){var t=e.replace(b,x);y[t]=new v(t,1,!1,e,`http://www.w3.org/1999/xlink`,!1,!1)}),[`xml:base`,`xml:lang`,`xml:space`].forEach(function(e){var t=e.replace(b,x);y[t]=new v(t,1,!1,e,`http://www.w3.org/XML/1998/namespace`,!1,!1)}),[`tabIndex`,`crossOrigin`].forEach(function(e){y[e]=new v(e,1,!1,e.toLowerCase(),null,!1,!1)}),y.xlinkHref=new v(`xlinkHref`,1,!1,`xlink:href`,`http://www.w3.org/1999/xlink`,!0,!1),[`src`,`href`,`action`,`formAction`].forEach(function(e){y[e]=new v(e,1,!1,e.toLowerCase(),null,!0,!0)});function S(e,t,n,r){var i=y.hasOwnProperty(t)?y[t]:null;(i===null?r||!(2s||i[o]!==a[s]){var c=` -`+i[o].replace(` at new `,` at `);return e.displayName&&c.includes(``)&&(c=c.replace(``,e.displayName)),c}while(1<=o&&0<=s);break}}}finally{ce=!1,Error.prepareStackTrace=n}return(e=e?e.displayName||e.name:``)?se(e):``}function P(e){switch(e.tag){case 5:return se(e.type);case 16:return se(`Lazy`);case 13:return se(`Suspense`);case 19:return se(`SuspenseList`);case 0:case 2:case 15:return e=le(e.type,!1),e;case 11:return e=le(e.type.render,!1),e;case 1:return e=le(e.type,!0),e;default:return``}}function ue(e){if(e==null)return null;if(typeof e==`function`)return e.displayName||e.name||null;if(typeof e==`string`)return e;switch(e){case E:return`Fragment`;case T:return`Portal`;case O:return`Profiler`;case D:return`StrictMode`;case j:return`Suspense`;case te:return`SuspenseList`}if(typeof e==`object`)switch(e.$$typeof){case A:return(e.displayName||`Context`)+`.Consumer`;case k:return(e._context.displayName||`Context`)+`.Provider`;case ee:var t=e.render;return e=e.displayName,e||=(e=t.displayName||t.name||``,e===``?`ForwardRef`:`ForwardRef(`+e+`)`),e;case ne:return t=e.displayName||null,t===null?ue(e.type)||`Memo`:t;case M:t=e._payload,e=e._init;try{return ue(e(t))}catch{}}return null}function de(e){var t=e.type;switch(e.tag){case 24:return`Cache`;case 9:return(t.displayName||`Context`)+`.Consumer`;case 10:return(t._context.displayName||`Context`)+`.Provider`;case 18:return`DehydratedFragment`;case 11:return e=t.render,e=e.displayName||e.name||``,t.displayName||(e===``?`ForwardRef`:`ForwardRef(`+e+`)`);case 7:return`Fragment`;case 5:return t;case 4:return`Portal`;case 3:return`Root`;case 6:return`Text`;case 16:return ue(t);case 8:return t===D?`StrictMode`:`Mode`;case 22:return`Offscreen`;case 12:return`Profiler`;case 21:return`Scope`;case 13:return`Suspense`;case 19:return`SuspenseList`;case 25:return`TracingMarker`;case 1:case 0:case 17:case 2:case 14:case 15:if(typeof t==`function`)return t.displayName||t.name||null;if(typeof t==`string`)return t}return null}function fe(e){switch(typeof e){case`boolean`:case`number`:case`string`:case`undefined`:return e;case`object`:return e;default:return``}}function pe(e){var t=e.type;return(e=e.nodeName)&&e.toLowerCase()===`input`&&(t===`checkbox`||t===`radio`)}function me(e){var t=pe(e)?`checked`:`value`,n=Object.getOwnPropertyDescriptor(e.constructor.prototype,t),r=``+e[t];if(!e.hasOwnProperty(t)&&n!==void 0&&typeof n.get==`function`&&typeof n.set==`function`){var i=n.get,a=n.set;return Object.defineProperty(e,t,{configurable:!0,get:function(){return i.call(this)},set:function(e){r=``+e,a.call(this,e)}}),Object.defineProperty(e,t,{enumerable:n.enumerable}),{getValue:function(){return r},setValue:function(e){r=``+e},stopTracking:function(){e._valueTracker=null,delete e[t]}}}}function he(e){e._valueTracker||=me(e)}function ge(e){if(!e)return!1;var t=e._valueTracker;if(!t)return!0;var n=t.getValue(),r=``;return e&&(r=pe(e)?e.checked?`true`:`false`:e.value),e=r,e===n?!1:(t.setValue(e),!0)}function F(e){if(e||=typeof document<`u`?document:void 0,e===void 0)return null;try{return e.activeElement||e.body}catch{return e.body}}function _e(e,t){var n=t.checked;return N({},t,{defaultChecked:void 0,defaultValue:void 0,value:void 0,checked:n??e._wrapperState.initialChecked})}function ve(e,t){var n=t.defaultValue==null?``:t.defaultValue,r=t.checked==null?t.defaultChecked:t.checked;n=fe(t.value==null?n:t.value),e._wrapperState={initialChecked:r,initialValue:n,controlled:t.type===`checkbox`||t.type===`radio`?t.checked!=null:t.value!=null}}function ye(e,t){t=t.checked,t!=null&&S(e,`checked`,t,!1)}function be(e,t){ye(e,t);var n=fe(t.value),r=t.type;if(n!=null)r===`number`?(n===0&&e.value===``||e.value!=n)&&(e.value=``+n):e.value!==``+n&&(e.value=``+n);else if(r===`submit`||r===`reset`){e.removeAttribute(`value`);return}t.hasOwnProperty(`value`)?Se(e,t.type,n):t.hasOwnProperty(`defaultValue`)&&Se(e,t.type,fe(t.defaultValue)),t.checked==null&&t.defaultChecked!=null&&(e.defaultChecked=!!t.defaultChecked)}function xe(e,t,n){if(t.hasOwnProperty(`value`)||t.hasOwnProperty(`defaultValue`)){var r=t.type;if(!(r!==`submit`&&r!==`reset`||t.value!==void 0&&t.value!==null))return;t=``+e._wrapperState.initialValue,n||t===e.value||(e.value=t),e.defaultValue=t}n=e.name,n!==``&&(e.name=``),e.defaultChecked=!!e._wrapperState.initialChecked,n!==``&&(e.name=n)}function Se(e,t,n){(t!==`number`||F(e.ownerDocument)!==e)&&(n==null?e.defaultValue=``+e._wrapperState.initialValue:e.defaultValue!==``+n&&(e.defaultValue=``+n))}var I=Array.isArray;function Ce(e,t,n,r){if(e=e.options,t){t={};for(var i=0;i`+t.valueOf().toString()+``,t=Ae.firstChild;e.firstChild;)e.removeChild(e.firstChild);for(;t.firstChild;)e.appendChild(t.firstChild)}});function Me(e,t){if(t){var n=e.firstChild;if(n&&n===e.lastChild&&n.nodeType===3){n.nodeValue=t;return}}e.textContent=t}var Ne={animationIterationCount:!0,aspectRatio:!0,borderImageOutset:!0,borderImageSlice:!0,borderImageWidth:!0,boxFlex:!0,boxFlexGroup:!0,boxOrdinalGroup:!0,columnCount:!0,columns:!0,flex:!0,flexGrow:!0,flexPositive:!0,flexShrink:!0,flexNegative:!0,flexOrder:!0,gridArea:!0,gridRow:!0,gridRowEnd:!0,gridRowSpan:!0,gridRowStart:!0,gridColumn:!0,gridColumnEnd:!0,gridColumnSpan:!0,gridColumnStart:!0,fontWeight:!0,lineClamp:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,tabSize:!0,widows:!0,zIndex:!0,zoom:!0,fillOpacity:!0,floodOpacity:!0,stopOpacity:!0,strokeDasharray:!0,strokeDashoffset:!0,strokeMiterlimit:!0,strokeOpacity:!0,strokeWidth:!0},Pe=[`Webkit`,`ms`,`Moz`,`O`];Object.keys(Ne).forEach(function(e){Pe.forEach(function(t){t=t+e.charAt(0).toUpperCase()+e.substring(1),Ne[t]=Ne[e]})});function Fe(e,t,n){return t==null||typeof t==`boolean`||t===``?``:n||typeof t!=`number`||t===0||Ne.hasOwnProperty(e)&&Ne[e]?(``+t).trim():t+`px`}function Ie(e,t){for(var n in e=e.style,t)if(t.hasOwnProperty(n)){var r=n.indexOf(`--`)===0,i=Fe(n,t[n],r);n===`float`&&(n=`cssFloat`),r?e.setProperty(n,i):e[n]=i}}var Le=N({menuitem:!0},{area:!0,base:!0,br:!0,col:!0,embed:!0,hr:!0,img:!0,input:!0,keygen:!0,link:!0,meta:!0,param:!0,source:!0,track:!0,wbr:!0});function Re(e,t){if(t){if(Le[e]&&(t.children!=null||t.dangerouslySetInnerHTML!=null))throw Error(i(137,e));if(t.dangerouslySetInnerHTML!=null){if(t.children!=null)throw Error(i(60));if(typeof t.dangerouslySetInnerHTML!=`object`||!(`__html`in t.dangerouslySetInnerHTML))throw Error(i(61))}if(t.style!=null&&typeof t.style!=`object`)throw Error(i(62))}}function ze(e,t){if(e.indexOf(`-`)===-1)return typeof t.is==`string`;switch(e){case`annotation-xml`:case`color-profile`:case`font-face`:case`font-face-src`:case`font-face-uri`:case`font-face-format`:case`font-face-name`:case`missing-glyph`:return!1;default:return!0}}var Be=null;function Ve(e){return e=e.target||e.srcElement||window,e.correspondingUseElement&&(e=e.correspondingUseElement),e.nodeType===3?e.parentNode:e}var He=null,Ue=null,We=null;function Ge(e){if(e=Vi(e)){if(typeof He!=`function`)throw Error(i(280));var t=e.stateNode;t&&(t=Ui(t),He(e.stateNode,e.type,t))}}function Ke(e){Ue?We?We.push(e):We=[e]:Ue=e}function qe(){if(Ue){var e=Ue,t=We;if(We=Ue=null,Ge(e),t)for(e=0;e>>=0,e===0?32:31-(kt(e)/At|0)|0}var Mt=64,Nt=4194304;function Pt(e){switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return e&4194240;case 4194304:case 8388608:case 16777216:case 33554432:case 67108864:return e&130023424;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 1073741824;default:return e}}function Ft(e,t){var n=e.pendingLanes;if(n===0)return 0;var r=0,i=e.suspendedLanes,a=e.pingedLanes,o=n&268435455;if(o!==0){var s=o&~i;s===0?(a&=o,a!==0&&(r=Pt(a))):r=Pt(s)}else o=n&~i,o===0?a!==0&&(r=Pt(a)):r=Pt(o);if(r===0)return 0;if(t!==0&&t!==r&&(t&i)===0&&(i=r&-r,a=t&-t,i>=a||i===16&&a&4194240))return t;if(r&4&&(r|=n&16),t=e.entangledLanes,t!==0)for(e=e.entanglements,t&=r;0n;n++)t.push(e);return t}function Vt(e,t,n){e.pendingLanes|=t,t!==536870912&&(e.suspendedLanes=0,e.pingedLanes=0),e=e.eventTimes,t=31-Ot(t),e[t]=n}function Ht(e,t){var n=e.pendingLanes&~t;e.pendingLanes=t,e.suspendedLanes=0,e.pingedLanes=0,e.expiredLanes&=t,e.mutableReadLanes&=t,e.entangledLanes&=t,t=e.entanglements;var r=e.eventTimes;for(e=e.expirationTimes;0=rr),or=` `,sr=!1;function cr(e,t){switch(e){case`keyup`:return tr.indexOf(t.keyCode)!==-1;case`keydown`:return t.keyCode!==229;case`keypress`:case`mousedown`:case`focusout`:return!0;default:return!1}}function lr(e){return e=e.detail,typeof e==`object`&&`data`in e?e.data:null}var ur=!1;function dr(e,t){switch(e){case`compositionend`:return lr(t);case`keypress`:return t.which===32?(sr=!0,or):null;case`textInput`:return e=t.data,e===or&&sr?null:e;default:return null}}function fr(e,t){if(ur)return e===`compositionend`||!nr&&cr(e,t)?(e=En(),Tn=wn=Cn=null,ur=!1,e):null;switch(e){case`paste`:return null;case`keypress`:if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1=t)return{node:n,offset:t-e};e=r}a:{for(;n;){if(n.nextSibling){n=n.nextSibling;break a}n=n.parentNode}n=void 0}n=z(n)}}function Fr(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?Fr(e,t.parentNode):`contains`in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function Ir(){for(var e=window,t=F();t instanceof e.HTMLIFrameElement;){try{var n=typeof t.contentWindow.location.href==`string`}catch{n=!1}if(n)e=t.contentWindow;else break;t=F(e.document)}return t}function Lr(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t&&(t===`input`&&(e.type===`text`||e.type===`search`||e.type===`tel`||e.type===`url`||e.type===`password`)||t===`textarea`||e.contentEditable===`true`)}function Rr(e){var t=Ir(),n=e.focusedElem,r=e.selectionRange;if(t!==n&&n&&n.ownerDocument&&Fr(n.ownerDocument.documentElement,n)){if(r!==null&&Lr(n)){if(t=r.start,e=r.end,e===void 0&&(e=t),`selectionStart`in n)n.selectionStart=t,n.selectionEnd=Math.min(e,n.value.length);else if(e=(t=n.ownerDocument||document)&&t.defaultView||window,e.getSelection){e=e.getSelection();var i=n.textContent.length,a=Math.min(r.start,i);r=r.end===void 0?a:Math.min(r.end,i),!e.extend&&a>r&&(i=r,r=a,a=i),i=Pr(n,a);var o=Pr(n,r);i&&o&&(e.rangeCount!==1||e.anchorNode!==i.node||e.anchorOffset!==i.offset||e.focusNode!==o.node||e.focusOffset!==o.offset)&&(t=t.createRange(),t.setStart(i.node,i.offset),e.removeAllRanges(),a>r?(e.addRange(t),e.extend(o.node,o.offset)):(t.setEnd(o.node,o.offset),e.addRange(t)))}}for(t=[],e=n;e=e.parentNode;)e.nodeType===1&&t.push({element:e,left:e.scrollLeft,top:e.scrollTop});for(typeof n.focus==`function`&&n.focus(),n=0;n=document.documentMode,Br=null,Vr=null,Hr=null,Ur=!1;function Wr(e,t,n){var r=n.window===n?n.document:n.nodeType===9?n:n.ownerDocument;Ur||Br==null||Br!==F(r)||(r=Br,`selectionStart`in r&&Lr(r)?r={start:r.selectionStart,end:r.selectionEnd}:(r=(r.ownerDocument&&r.ownerDocument.defaultView||window).getSelection(),r={anchorNode:r.anchorNode,anchorOffset:r.anchorOffset,focusNode:r.focusNode,focusOffset:r.focusOffset}),Hr&&Nr(Hr,r)||(Hr=r,r=mi(Vr,`onSelect`),0Gi||(e.current=Wi[Gi],Wi[Gi]=null,Gi--)}function U(e,t){Gi++,Wi[Gi]=e.current,e.current=t}var W={},qi=Ki(W),Ji=Ki(!1),Yi=W;function Xi(e,t){var n=e.type.contextTypes;if(!n)return W;var r=e.stateNode;if(r&&r.__reactInternalMemoizedUnmaskedChildContext===t)return r.__reactInternalMemoizedMaskedChildContext;var i={},a;for(a in n)i[a]=t[a];return r&&(e=e.stateNode,e.__reactInternalMemoizedUnmaskedChildContext=t,e.__reactInternalMemoizedMaskedChildContext=i),i}function Zi(e){return e=e.childContextTypes,e!=null}function Qi(){H(Ji),H(qi)}function $i(e,t,n){if(qi.current!==W)throw Error(i(168));U(qi,t),U(Ji,n)}function ea(e,t,n){var r=e.stateNode;if(t=t.childContextTypes,typeof r.getChildContext!=`function`)return n;for(var a in r=r.getChildContext(),r)if(!(a in t))throw Error(i(108,de(e)||`Unknown`,a));return N({},n,r)}function ta(e){return e=(e=e.stateNode)&&e.__reactInternalMemoizedMergedChildContext||W,Yi=qi.current,U(qi,e),U(Ji,Ji.current),!0}function na(e,t,n){var r=e.stateNode;if(!r)throw Error(i(169));n?(e=ea(e,t,Yi),r.__reactInternalMemoizedMergedChildContext=e,H(Ji),H(qi),U(qi,e)):H(Ji),U(Ji,n)}var ra=null,ia=!1,aa=!1;function oa(e){ra===null?ra=[e]:ra.push(e)}function sa(e){ia=!0,oa(e)}function ca(){if(!aa&&ra!==null){aa=!0;var e=0,t=R;try{var n=ra;for(R=1;e>=o,i-=o,ha=1<<32-Ot(t)+i|n<h?(g=d,d=null):g=d.sibling;var _=p(i,d,s[h],c);if(_===null){d===null&&(d=g);break}e&&d&&_.alternate===null&&t(i,d),a=o(_,a,h),u===null?l=_:u.sibling=_,u=_,d=g}if(h===s.length)return n(i,d),K&&_a(i,h),l;if(d===null){for(;hg?(_=h,h=null):_=h.sibling;var y=p(a,h,v.value,l);if(y===null){h===null&&(h=_);break}e&&h&&y.alternate===null&&t(a,h),s=o(y,s,g),d===null?u=y:d.sibling=y,d=y,h=_}if(v.done)return n(a,h),K&&_a(a,g),u;if(h===null){for(;!v.done;g++,v=c.next())v=f(a,v.value,l),v!==null&&(s=o(v,s,g),d===null?u=v:d.sibling=v,d=v);return K&&_a(a,g),u}for(h=r(a,h);!v.done;g++,v=c.next())v=m(h,a,g,v.value,l),v!==null&&(e&&v.alternate!==null&&h.delete(v.key===null?g:v.key),s=o(v,s,g),d===null?u=v:d.sibling=v,d=v);return e&&h.forEach(function(e){return t(a,e)}),K&&_a(a,g),u}function _(e,r,i,o){if(typeof i==`object`&&i&&i.type===E&&i.key===null&&(i=i.props.children),typeof i==`object`&&i){switch(i.$$typeof){case w:a:{for(var c=i.key,l=r;l!==null;){if(l.key===c){if(c=i.type,c===E){if(l.tag===7){n(e,l.sibling),r=a(l,i.props.children),r.return=e,e=r;break a}}else if(l.elementType===c||typeof c==`object`&&c&&c.$$typeof===M&&Ia(c)===l.type){n(e,l.sibling),r=a(l,i.props),r.ref=Pa(e,l,i),r.return=e,e=r;break a}n(e,l);break}else t(e,l);l=l.sibling}i.type===E?(r=Zl(i.props.children,e.mode,o,i.key),r.return=e,e=r):(o=Xl(i.type,i.key,i.props,null,e.mode,o),o.ref=Pa(e,r,i),o.return=e,e=o)}return s(e);case T:a:{for(l=i.key;r!==null;){if(r.key===l)if(r.tag===4&&r.stateNode.containerInfo===i.containerInfo&&r.stateNode.implementation===i.implementation){n(e,r.sibling),r=a(r,i.children||[]),r.return=e,e=r;break a}else{n(e,r);break}else t(e,r);r=r.sibling}r=eu(i,e.mode,o),r.return=e,e=r}return s(e);case M:return l=i._init,_(e,r,l(i._payload),o)}if(I(i))return h(e,r,i,o);if(ae(i))return g(e,r,i,o);Fa(e,i)}return typeof i==`string`&&i!==``||typeof i==`number`?(i=``+i,r!==null&&r.tag===6?(n(e,r.sibling),r=a(r,i),r.return=e,e=r):(n(e,r),r=$l(i,e.mode,o),r.return=e,e=r),s(e)):n(e,r)}return _}var Ra=La(!0),za=La(!1),Ba=Ki(null),Va=null,Ha=null,Ua=null;function Wa(){Ua=Ha=Va=null}function Ga(e){var t=Ba.current;H(Ba),e._currentValue=t}function Ka(e,t,n){for(;e!==null;){var r=e.alternate;if((e.childLanes&t)===t?r!==null&&(r.childLanes&t)!==t&&(r.childLanes|=t):(e.childLanes|=t,r!==null&&(r.childLanes|=t)),e===n)break;e=e.return}}function qa(e,t){Va=e,Ua=Ha=null,e=e.dependencies,e!==null&&e.firstContext!==null&&((e.lanes&t)!==0&&(Ps=!0),e.firstContext=null)}function Ja(e){var t=e._currentValue;if(Ua!==e)if(e={context:e,memoizedValue:t,next:null},Ha===null){if(Va===null)throw Error(i(308));Ha=e,Va.dependencies={lanes:0,firstContext:e}}else Ha=Ha.next=e;return t}var Ya=null;function Xa(e){Ya===null?Ya=[e]:Ya.push(e)}function Za(e,t,n,r){var i=t.interleaved;return i===null?(n.next=n,Xa(t)):(n.next=i.next,i.next=n),t.interleaved=n,Qa(e,r)}function Qa(e,t){e.lanes|=t;var n=e.alternate;for(n!==null&&(n.lanes|=t),n=e,e=e.return;e!==null;)e.childLanes|=t,n=e.alternate,n!==null&&(n.childLanes|=t),n=e,e=e.return;return n.tag===3?n.stateNode:null}var $a=!1;function eo(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,interleaved:null,lanes:0},effects:null}}function to(e,t){e=e.updateQueue,t.updateQueue===e&&(t.updateQueue={baseState:e.baseState,firstBaseUpdate:e.firstBaseUpdate,lastBaseUpdate:e.lastBaseUpdate,shared:e.shared,effects:e.effects})}function no(e,t){return{eventTime:e,lane:t,tag:0,payload:null,callback:null,next:null}}function ro(e,t,n){var r=e.updateQueue;if(r===null)return null;if(r=r.shared,Q&2){var i=r.pending;return i===null?t.next=t:(t.next=i.next,i.next=t),r.pending=t,Qa(e,n)}return i=r.interleaved,i===null?(t.next=t,Xa(r)):(t.next=i.next,i.next=t),r.interleaved=t,Qa(e,n)}function io(e,t,n){if(t=t.updateQueue,t!==null&&(t=t.shared,n&4194240)){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,Ut(e,n)}}function ao(e,t){var n=e.updateQueue,r=e.alternate;if(r!==null&&(r=r.updateQueue,n===r)){var i=null,a=null;if(n=n.firstBaseUpdate,n!==null){do{var o={eventTime:n.eventTime,lane:n.lane,tag:n.tag,payload:n.payload,callback:n.callback,next:null};a===null?i=a=o:a=a.next=o,n=n.next}while(n!==null);a===null?i=a=t:a=a.next=t}else i=a=t;n={baseState:r.baseState,firstBaseUpdate:i,lastBaseUpdate:a,shared:r.shared,effects:r.effects},e.updateQueue=n;return}e=n.lastBaseUpdate,e===null?n.firstBaseUpdate=t:e.next=t,n.lastBaseUpdate=t}function oo(e,t,n,r){var i=e.updateQueue;$a=!1;var a=i.firstBaseUpdate,o=i.lastBaseUpdate,s=i.shared.pending;if(s!==null){i.shared.pending=null;var c=s,l=c.next;c.next=null,o===null?a=l:o.next=l,o=c;var u=e.alternate;u!==null&&(u=u.updateQueue,s=u.lastBaseUpdate,s!==o&&(s===null?u.firstBaseUpdate=l:s.next=l,u.lastBaseUpdate=c))}if(a!==null){var d=i.baseState;o=0,u=l=c=null,s=a;do{var f=s.lane,p=s.eventTime;if((r&f)===f){u!==null&&(u=u.next={eventTime:p,lane:0,tag:s.tag,payload:s.payload,callback:s.callback,next:null});a:{var m=e,h=s;switch(f=t,p=n,h.tag){case 1:if(m=h.payload,typeof m==`function`){d=m.call(p,d,f);break a}d=m;break a;case 3:m.flags=m.flags&-65537|128;case 0:if(m=h.payload,f=typeof m==`function`?m.call(p,d,f):m,f==null)break a;d=N({},d,f);break a;case 2:$a=!0}}s.callback!==null&&s.lane!==0&&(e.flags|=64,f=i.effects,f===null?i.effects=[s]:f.push(s))}else p={eventTime:p,lane:f,tag:s.tag,payload:s.payload,callback:s.callback,next:null},u===null?(l=u=p,c=d):u=u.next=p,o|=f;if(s=s.next,s===null){if(s=i.shared.pending,s===null)break;f=s,s=f.next,f.next=null,i.lastBaseUpdate=f,i.shared.pending=null}}while(1);if(u===null&&(c=d),i.baseState=c,i.firstBaseUpdate=l,i.lastBaseUpdate=u,t=i.shared.interleaved,t!==null){i=t;do o|=i.lane,i=i.next;while(i!==t)}else a===null&&(i.shared.lanes=0);Jc|=o,e.lanes=o,e.memoizedState=d}}function so(e,t,n){if(e=t.effects,t.effects=null,e!==null)for(t=0;tn?n:4,e(!0);var r=So.transition;So.transition={};try{e(!1),t()}finally{R=n,So.transition=r}}function ss(){return Po().memoizedState}function cs(e,t,n){var r=pl(e);if(n={lane:r,action:n,hasEagerState:!1,eagerState:null,next:null},us(e))ds(t,n);else if(n=Za(e,t,n,r),n!==null){var i=fl();ml(n,e,r,i),fs(n,t,r)}}function ls(e,t,n){var r=pl(e),i={lane:r,action:n,hasEagerState:!1,eagerState:null,next:null};if(us(e))ds(t,i);else{var a=e.alternate;if(e.lanes===0&&(a===null||a.lanes===0)&&(a=t.lastRenderedReducer,a!==null))try{var o=t.lastRenderedState,s=a(o,n);if(i.hasEagerState=!0,i.eagerState=s,Mr(s,o)){var c=t.interleaved;c===null?(i.next=i,Xa(t)):(i.next=c.next,c.next=i),t.interleaved=i;return}}catch{}n=Za(e,t,i,r),n!==null&&(i=fl(),ml(n,e,r,i),fs(n,t,r))}}function us(e){var t=e.alternate;return e===J||t!==null&&t===J}function ds(e,t){Eo=To=!0;var n=e.pending;n===null?t.next=t:(t.next=n.next,n.next=t),e.pending=t}function fs(e,t,n){if(n&4194240){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,Ut(e,n)}}var ps={readContext:Ja,useCallback:ko,useContext:ko,useEffect:ko,useImperativeHandle:ko,useInsertionEffect:ko,useLayoutEffect:ko,useMemo:ko,useReducer:ko,useRef:ko,useState:ko,useDebugValue:ko,useDeferredValue:ko,useTransition:ko,useMutableSource:ko,useSyncExternalStore:ko,useId:ko,unstable_isNewReconciler:!1},ms={readContext:Ja,useCallback:function(e,t){return No().memoizedState=[e,t===void 0?null:t],e},useContext:Ja,useEffect:Xo,useImperativeHandle:function(e,t,n){return n=n==null?null:n.concat([e]),Jo(4194308,4,es.bind(null,t,e),n)},useLayoutEffect:function(e,t){return Jo(4194308,4,e,t)},useInsertionEffect:function(e,t){return Jo(4,2,e,t)},useMemo:function(e,t){var n=No();return t=t===void 0?null:t,e=e(),n.memoizedState=[e,t],e},useReducer:function(e,t,n){var r=No();return t=n===void 0?t:n(t),r.memoizedState=r.baseState=t,e={pending:null,interleaved:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:t},r.queue=e,e=e.dispatch=cs.bind(null,J,e),[r.memoizedState,e]},useRef:function(e){var t=No();return e={current:e},t.memoizedState=e},useState:Go,useDebugValue:ns,useDeferredValue:function(e){return No().memoizedState=e},useTransition:function(){var e=Go(!1),t=e[0];return e=os.bind(null,e[1]),No().memoizedState=e,[t,e]},useMutableSource:function(){},useSyncExternalStore:function(e,t,n){var r=J,a=No();if(K){if(n===void 0)throw Error(i(407));n=n()}else{if(n=t(),$===null)throw Error(i(349));Co&30||Bo(r,t,n)}a.memoizedState=n;var o={value:n,getSnapshot:t};return a.queue=o,Xo(Ho.bind(null,r,o,e),[e]),r.flags|=2048,Ko(9,Vo.bind(null,r,o,n,t),void 0,null),n},useId:function(){var e=No(),t=$.identifierPrefix;if(K){var n=ga,r=ha;n=(r&~(1<<32-Ot(r)-1)).toString(32)+n,t=`:`+t+`R`+n,n=Do++,0<\/script>`,e=e.removeChild(e.firstChild)):typeof r.is==`string`?e=c.createElement(n,{is:r.is}):(e=c.createElement(n),n===`select`&&(c=e,r.multiple?c.multiple=!0:r.size&&(c.size=r.size))):e=c.createElementNS(e,n),e[Pi]=t,e[Fi]=r,ic(e,t,!1,!1),t.stateNode=e;a:{switch(c=ze(n,r),n){case`dialog`:V(`cancel`,e),V(`close`,e),a=r;break;case`iframe`:case`object`:case`embed`:V(`load`,e),a=r;break;case`video`:case`audio`:for(a=0;ael&&(t.flags|=128,r=!0,sc(s,!1),t.lanes=4194304)}else{if(!r)if(e=vo(c),e!==null){if(t.flags|=128,r=!0,n=e.updateQueue,n!==null&&(t.updateQueue=n,t.flags|=4),sc(s,!0),s.tail===null&&s.tailMode===`hidden`&&!c.alternate&&!K)return cc(t),null}else 2*L()-s.renderingStartTime>el&&n!==1073741824&&(t.flags|=128,r=!0,sc(s,!1),t.lanes=4194304);s.isBackwards?(c.sibling=t.child,t.child=c):(n=s.last,n===null?t.child=c:n.sibling=c,s.last=c)}return s.tail===null?(cc(t),null):(t=s.tail,s.rendering=t,s.tail=t.sibling,s.renderingStartTime=L(),t.sibling=null,n=q.current,U(q,r?n&1|2:n&1),t);case 22:case 23:return wl(),r=t.memoizedState!==null,e!==null&&e.memoizedState!==null!==r&&(t.flags|=8192),r&&t.mode&1?Wc&1073741824&&(cc(t),t.subtreeFlags&6&&(t.flags|=8192)):cc(t),null;case 24:return null;case 25:return null}throw Error(i(156,t.tag))}function uc(e,t){switch(ba(t),t.tag){case 1:return Zi(t.type)&&Qi(),e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 3:return ho(),H(Ji),H(qi),bo(),e=t.flags,e&65536&&!(e&128)?(t.flags=e&-65537|128,t):null;case 5:return _o(t),null;case 13:if(H(q),e=t.memoizedState,e!==null&&e.dehydrated!==null){if(t.alternate===null)throw Error(i(340));ja()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 19:return H(q),null;case 4:return ho(),null;case 10:return Ga(t.type._context),null;case 22:case 23:return wl(),null;case 24:return null;default:return null}}var dc=!1,fc=!1,pc=typeof WeakSet==`function`?WeakSet:Set,X=null;function mc(e,t){var n=e.ref;if(n!==null)if(typeof n==`function`)try{n(null)}catch(n){Rl(e,t,n)}else n.current=null}function hc(e,t,n){try{n()}catch(n){Rl(e,t,n)}}var gc=!1;function _c(e,t){if(Si=gn,e=Ir(),Lr(e)){if(`selectionStart`in e)var n={start:e.selectionStart,end:e.selectionEnd};else a:{n=(n=e.ownerDocument)&&n.defaultView||window;var r=n.getSelection&&n.getSelection();if(r&&r.rangeCount!==0){n=r.anchorNode;var a=r.anchorOffset,o=r.focusNode;r=r.focusOffset;try{n.nodeType,o.nodeType}catch{n=null;break a}var s=0,c=-1,l=-1,u=0,d=0,f=e,p=null;b:for(;;){for(var m;f!==n||a!==0&&f.nodeType!==3||(c=s+a),f!==o||r!==0&&f.nodeType!==3||(l=s+r),f.nodeType===3&&(s+=f.nodeValue.length),(m=f.firstChild)!==null;)p=f,f=m;for(;;){if(f===e)break b;if(p===n&&++u===a&&(c=s),p===o&&++d===r&&(l=s),(m=f.nextSibling)!==null)break;f=p,p=f.parentNode}f=m}n=c===-1||l===-1?null:{start:c,end:l}}else n=null}n||={start:0,end:0}}else n=null;for(Ci={focusedElem:e,selectionRange:n},gn=!1,X=t;X!==null;)if(t=X,e=t.child,t.subtreeFlags&1028&&e!==null)e.return=t,X=e;else for(;X!==null;){t=X;try{var h=t.alternate;if(t.flags&1024)switch(t.tag){case 0:case 11:case 15:break;case 1:if(h!==null){var g=h.memoizedProps,_=h.memoizedState,v=t.stateNode;v.__reactInternalSnapshotBeforeUpdate=v.getSnapshotBeforeUpdate(t.elementType===t.type?g:_s(t.type,g),_)}break;case 3:var y=t.stateNode.containerInfo;y.nodeType===1?y.textContent=``:y.nodeType===9&&y.documentElement&&y.removeChild(y.documentElement);break;case 5:case 6:case 4:case 17:break;default:throw Error(i(163))}}catch(e){Rl(t,t.return,e)}if(e=t.sibling,e!==null){e.return=t.return,X=e;break}X=t.return}return h=gc,gc=!1,h}function vc(e,t,n){var r=t.updateQueue;if(r=r===null?null:r.lastEffect,r!==null){var i=r=r.next;do{if((i.tag&e)===e){var a=i.destroy;i.destroy=void 0,a!==void 0&&hc(t,n,a)}i=i.next}while(i!==r)}}function yc(e,t){if(t=t.updateQueue,t=t===null?null:t.lastEffect,t!==null){var n=t=t.next;do{if((n.tag&e)===e){var r=n.create;n.destroy=r()}n=n.next}while(n!==t)}}function bc(e){var t=e.ref;if(t!==null){var n=e.stateNode;switch(e.tag){case 5:e=n;break;default:e=n}typeof t==`function`?t(e):t.current=e}}function xc(e){var t=e.alternate;t!==null&&(e.alternate=null,xc(t)),e.child=null,e.deletions=null,e.sibling=null,e.tag===5&&(t=e.stateNode,t!==null&&(delete t[Pi],delete t[Fi],delete t[Li],delete t[Ri],delete t[zi])),e.stateNode=null,e.return=null,e.dependencies=null,e.memoizedProps=null,e.memoizedState=null,e.pendingProps=null,e.stateNode=null,e.updateQueue=null}function Z(e){return e.tag===5||e.tag===3||e.tag===4}function Sc(e){a:for(;;){for(;e.sibling===null;){if(e.return===null||Z(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.flags&2||e.child===null||e.tag===4)continue a;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function Cc(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.nodeType===8?n.parentNode.insertBefore(e,t):n.insertBefore(e,t):(n.nodeType===8?(t=n.parentNode,t.insertBefore(e,n)):(t=n,t.appendChild(e)),n=n._reactRootContainer,n!=null||t.onclick!==null||(t.onclick=xi));else if(r!==4&&(e=e.child,e!==null))for(Cc(e,t,n),e=e.sibling;e!==null;)Cc(e,t,n),e=e.sibling}function wc(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.insertBefore(e,t):n.appendChild(e);else if(r!==4&&(e=e.child,e!==null))for(wc(e,t,n),e=e.sibling;e!==null;)wc(e,t,n),e=e.sibling}var Tc=null,Ec=!1;function Dc(e,t,n){for(n=n.child;n!==null;)Oc(e,t,n),n=n.sibling}function Oc(e,t,n){if(Et&&typeof Et.onCommitFiberUnmount==`function`)try{Et.onCommitFiberUnmount(Tt,n)}catch{}switch(n.tag){case 5:fc||mc(n,t);case 6:var r=Tc,i=Ec;Tc=null,Dc(e,t,n),Tc=r,Ec=i,Tc!==null&&(Ec?(e=Tc,n=n.stateNode,e.nodeType===8?e.parentNode.removeChild(n):e.removeChild(n)):Tc.removeChild(n.stateNode));break;case 18:Tc!==null&&(Ec?(e=Tc,n=n.stateNode,e.nodeType===8?Ai(e.parentNode,n):e.nodeType===1&&Ai(e,n),mn(e)):Ai(Tc,n.stateNode));break;case 4:r=Tc,i=Ec,Tc=n.stateNode.containerInfo,Ec=!0,Dc(e,t,n),Tc=r,Ec=i;break;case 0:case 11:case 14:case 15:if(!fc&&(r=n.updateQueue,r!==null&&(r=r.lastEffect,r!==null))){i=r=r.next;do{var a=i,o=a.destroy;a=a.tag,o!==void 0&&(a&2||a&4)&&hc(n,t,o),i=i.next}while(i!==r)}Dc(e,t,n);break;case 1:if(!fc&&(mc(n,t),r=n.stateNode,typeof r.componentWillUnmount==`function`))try{r.props=n.memoizedProps,r.state=n.memoizedState,r.componentWillUnmount()}catch(e){Rl(n,t,e)}Dc(e,t,n);break;case 21:Dc(e,t,n);break;case 22:n.mode&1?(fc=(r=fc)||n.memoizedState!==null,Dc(e,t,n),fc=r):Dc(e,t,n);break;default:Dc(e,t,n)}}function kc(e){var t=e.updateQueue;if(t!==null){e.updateQueue=null;var n=e.stateNode;n===null&&(n=e.stateNode=new pc),t.forEach(function(t){var r=Hl.bind(null,e,t);n.has(t)||(n.add(t),t.then(r,r))})}}function Ac(e,t){var n=t.deletions;if(n!==null)for(var r=0;ra&&(a=s),r&=~o}if(r=a,r=L()-r,r=(120>r?120:480>r?480:1080>r?1080:1920>r?1920:3e3>r?3e3:4320>r?4320:1960*Rc(r/1960))-r,10e?16:e,ol===null)var r=!1;else{if(e=ol,ol=null,sl=0,Q&6)throw Error(i(331));var a=Q;for(Q|=4,X=e.current;X!==null;){var o=X,s=o.child;if(X.flags&16){var c=o.deletions;if(c!==null){for(var l=0;lL()-$c?Tl(e,0):Xc|=n),hl(e,t)}function Bl(e,t){t===0&&(e.mode&1?(t=Nt,Nt<<=1,!(Nt&130023424)&&(Nt=4194304)):t=1);var n=fl();e=Qa(e,t),e!==null&&(Vt(e,t,n),hl(e,n))}function Vl(e){var t=e.memoizedState,n=0;t!==null&&(n=t.retryLane),Bl(e,n)}function Hl(e,t){var n=0;switch(e.tag){case 13:var r=e.stateNode,a=e.memoizedState;a!==null&&(n=a.retryLane);break;case 19:r=e.stateNode;break;default:throw Error(i(314))}r!==null&&r.delete(t),Bl(e,n)}var Ul=function(e,t,n){if(e!==null)if(e.memoizedProps!==t.pendingProps||Ji.current)Ps=!0;else{if((e.lanes&n)===0&&!(t.flags&128))return Ps=!1,rc(e,t,n);Ps=!!(e.flags&131072)}else Ps=!1,K&&t.flags&1048576&&va(t,G,t.index);switch(t.lanes=0,t.tag){case 2:var r=t.type;tc(e,t),e=t.pendingProps;var a=Xi(t,qi.current);qa(t,n),a=jo(null,t,r,e,a,n);var o=Mo();return t.flags|=1,typeof a==`object`&&a&&typeof a.render==`function`&&a.$$typeof===void 0?(t.tag=1,t.memoizedState=null,t.updateQueue=null,Zi(r)?(o=!0,ta(t)):o=!1,t.memoizedState=a.state!==null&&a.state!==void 0?a.state:null,eo(t),a.updater=ys,t.stateNode=a,a._reactInternals=t,Cs(t,r,e,n),t=Us(null,t,r,!0,o,n)):(t.tag=0,K&&o&&ya(t),Fs(null,t,a,n),t=t.child),t;case 16:r=t.elementType;a:{switch(tc(e,t),e=t.pendingProps,a=r._init,r=a(r._payload),t.type=r,a=t.tag=Jl(r),e=_s(r,e),a){case 0:t=Vs(null,t,r,e,n);break a;case 1:t=Hs(null,t,r,e,n);break a;case 11:t=Is(null,t,r,e,n);break a;case 14:t=Ls(null,t,r,_s(r.type,e),n);break a}throw Error(i(306,r,``))}return t;case 0:return r=t.type,a=t.pendingProps,a=t.elementType===r?a:_s(r,a),Vs(e,t,r,a,n);case 1:return r=t.type,a=t.pendingProps,a=t.elementType===r?a:_s(r,a),Hs(e,t,r,a,n);case 3:a:{if(Ws(t),e===null)throw Error(i(387));r=t.pendingProps,o=t.memoizedState,a=o.element,to(e,t),oo(t,r,null,n);var s=t.memoizedState;if(r=s.element,o.isDehydrated)if(o={element:r,isDehydrated:!1,cache:s.cache,pendingSuspenseBoundaries:s.pendingSuspenseBoundaries,transitions:s.transitions},t.updateQueue.baseState=o,t.memoizedState=o,t.flags&256){a=ws(Error(i(423)),t),t=Gs(e,t,r,n,a);break a}else if(r!==a){a=ws(Error(i(424)),t),t=Gs(e,t,r,n,a);break a}else for(Sa=ji(t.stateNode.containerInfo.firstChild),xa=t,K=!0,Ca=null,n=za(t,null,r,n),t.child=n;n;)n.flags=n.flags&-3|4096,n=n.sibling;else{if(ja(),r===a){t=nc(e,t,n);break a}Fs(e,t,r,n)}t=t.child}return t;case 5:return go(t),e===null&&Da(t),r=t.type,a=t.pendingProps,o=e===null?null:e.memoizedProps,s=a.children,wi(r,a)?s=null:o!==null&&wi(r,o)&&(t.flags|=32),Bs(e,t),Fs(e,t,s,n),t.child;case 6:return e===null&&Da(t),null;case 13:return Js(e,t,n);case 4:return mo(t,t.stateNode.containerInfo),r=t.pendingProps,e===null?t.child=Ra(t,null,r,n):Fs(e,t,r,n),t.child;case 11:return r=t.type,a=t.pendingProps,a=t.elementType===r?a:_s(r,a),Is(e,t,r,a,n);case 7:return Fs(e,t,t.pendingProps,n),t.child;case 8:return Fs(e,t,t.pendingProps.children,n),t.child;case 12:return Fs(e,t,t.pendingProps.children,n),t.child;case 10:a:{if(r=t.type._context,a=t.pendingProps,o=t.memoizedProps,s=a.value,U(Ba,r._currentValue),r._currentValue=s,o!==null)if(Mr(o.value,s)){if(o.children===a.children&&!Ji.current){t=nc(e,t,n);break a}}else for(o=t.child,o!==null&&(o.return=t);o!==null;){var c=o.dependencies;if(c!==null){s=o.child;for(var l=c.firstContext;l!==null;){if(l.context===r){if(o.tag===1){l=no(-1,n&-n),l.tag=2;var u=o.updateQueue;if(u!==null){u=u.shared;var d=u.pending;d===null?l.next=l:(l.next=d.next,d.next=l),u.pending=l}}o.lanes|=n,l=o.alternate,l!==null&&(l.lanes|=n),Ka(o.return,n,t),c.lanes|=n;break}l=l.next}}else if(o.tag===10)s=o.type===t.type?null:o.child;else if(o.tag===18){if(s=o.return,s===null)throw Error(i(341));s.lanes|=n,c=s.alternate,c!==null&&(c.lanes|=n),Ka(s,n,t),s=o.sibling}else s=o.child;if(s!==null)s.return=o;else for(s=o;s!==null;){if(s===t){s=null;break}if(o=s.sibling,o!==null){o.return=s.return,s=o;break}s=s.return}o=s}Fs(e,t,a.children,n),t=t.child}return t;case 9:return a=t.type,r=t.pendingProps.children,qa(t,n),a=Ja(a),r=r(a),t.flags|=1,Fs(e,t,r,n),t.child;case 14:return r=t.type,a=_s(r,t.pendingProps),a=_s(r.type,a),Ls(e,t,r,a,n);case 15:return Rs(e,t,t.type,t.pendingProps,n);case 17:return r=t.type,a=t.pendingProps,a=t.elementType===r?a:_s(r,a),tc(e,t),t.tag=1,Zi(r)?(e=!0,ta(t)):e=!1,qa(t,n),xs(t,r,a),Cs(t,r,a,n),Us(null,t,r,!0,e,n);case 19:return ec(e,t,n);case 22:return zs(e,t,n)}throw Error(i(156,t.tag))};function Wl(e,t){return ht(e,t)}function Gl(e,t,n,r){this.tag=e,this.key=n,this.sibling=this.child=this.return=this.stateNode=this.type=this.elementType=null,this.index=0,this.ref=null,this.pendingProps=t,this.dependencies=this.memoizedState=this.updateQueue=this.memoizedProps=null,this.mode=r,this.subtreeFlags=this.flags=0,this.deletions=null,this.childLanes=this.lanes=0,this.alternate=null}function Kl(e,t,n,r){return new Gl(e,t,n,r)}function ql(e){return e=e.prototype,!(!e||!e.isReactComponent)}function Jl(e){if(typeof e==`function`)return+!!ql(e);if(e!=null){if(e=e.$$typeof,e===ee)return 11;if(e===ne)return 14}return 2}function Yl(e,t){var n=e.alternate;return n===null?(n=Kl(e.tag,t,e.key,e.mode),n.elementType=e.elementType,n.type=e.type,n.stateNode=e.stateNode,n.alternate=e,e.alternate=n):(n.pendingProps=t,n.type=e.type,n.flags=0,n.subtreeFlags=0,n.deletions=null),n.flags=e.flags&14680064,n.childLanes=e.childLanes,n.lanes=e.lanes,n.child=e.child,n.memoizedProps=e.memoizedProps,n.memoizedState=e.memoizedState,n.updateQueue=e.updateQueue,t=e.dependencies,n.dependencies=t===null?null:{lanes:t.lanes,firstContext:t.firstContext},n.sibling=e.sibling,n.index=e.index,n.ref=e.ref,n}function Xl(e,t,n,r,a,o){var s=2;if(r=e,typeof e==`function`)ql(e)&&(s=1);else if(typeof e==`string`)s=5;else a:switch(e){case E:return Zl(n.children,a,o,t);case D:s=8,a|=8;break;case O:return e=Kl(12,n,t,a|2),e.elementType=O,e.lanes=o,e;case j:return e=Kl(13,n,t,a),e.elementType=j,e.lanes=o,e;case te:return e=Kl(19,n,t,a),e.elementType=te,e.lanes=o,e;case re:return Ql(n,a,o,t);default:if(typeof e==`object`&&e)switch(e.$$typeof){case k:s=10;break a;case A:s=9;break a;case ee:s=11;break a;case ne:s=14;break a;case M:s=16,r=null;break a}throw Error(i(130,e==null?e:typeof e,``))}return t=Kl(s,n,t,a),t.elementType=e,t.type=r,t.lanes=o,t}function Zl(e,t,n,r){return e=Kl(7,e,r,t),e.lanes=n,e}function Ql(e,t,n,r){return e=Kl(22,e,r,t),e.elementType=re,e.lanes=n,e.stateNode={isHidden:!1},e}function $l(e,t,n){return e=Kl(6,e,null,t),e.lanes=n,e}function eu(e,t,n){return t=Kl(4,e.children===null?[]:e.children,e.key,t),t.lanes=n,t.stateNode={containerInfo:e.containerInfo,pendingChildren:null,implementation:e.implementation},t}function tu(e,t,n,r,i){this.tag=t,this.containerInfo=e,this.finishedWork=this.pingCache=this.current=this.pendingChildren=null,this.timeoutHandle=-1,this.callbackNode=this.pendingContext=this.context=null,this.callbackPriority=0,this.eventTimes=Bt(0),this.expirationTimes=Bt(-1),this.entangledLanes=this.finishedLanes=this.mutableReadLanes=this.expiredLanes=this.pingedLanes=this.suspendedLanes=this.pendingLanes=0,this.entanglements=Bt(0),this.identifierPrefix=r,this.onRecoverableError=i,this.mutableSourceEagerHydrationData=null}function nu(e,t,n,r,i,a,o,s,c){return e=new tu(e,t,n,s,c),t===1?(t=1,!0===a&&(t|=8)):t=0,a=Kl(3,null,null,t),e.current=a,a.stateNode=e,a.memoizedState={element:r,isDehydrated:n,cache:null,transitions:null,pendingSuspenseBoundaries:null},eo(a),e}function ru(e,t,n){var r=3{function n(){if(!(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>`u`||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!=`function`))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(n)}catch(e){console.error(e)}}n(),t.exports=f()})),m=i((e=>{var t=p();e.createRoot=t.createRoot,e.hydrateRoot=t.hydrateRoot})),h=a(t()),g=a(p()),_=a(m(),1);function v(){return v=Object.assign?Object.assign.bind():function(e){for(var t=1;t=0&&(t.hash=e.substr(n),e=e.substr(0,n));let r=e.indexOf(`?`);r>=0&&(t.search=e.substr(r),e=e.substr(0,r)),e&&(t.pathname=e)}return t}function k(e,t,n,r){r===void 0&&(r={});let{window:i=document.defaultView,v5Compat:a=!1}=r,o=i.history,s=y.Pop,c=null,l=u();l??(l=0,o.replaceState(v({},o.state,{idx:l}),``));function u(){return(o.state||{idx:null}).idx}function d(){s=y.Pop;let e=u(),t=e==null?null:e-l;l=e,c&&c({action:s,location:h.location,delta:t})}function f(e,t){s=y.Push;let r=E(h.location,e,t);n&&n(r,e),l=u()+1;let d=T(r,l),f=h.createHref(r);try{o.pushState(d,``,f)}catch(e){if(e instanceof DOMException&&e.name===`DataCloneError`)throw e;i.location.assign(f)}a&&c&&c({action:s,location:h.location,delta:1})}function p(e,t){s=y.Replace;let r=E(h.location,e,t);n&&n(r,e),l=u();let i=T(r,l),d=h.createHref(r);o.replaceState(i,``,d),a&&c&&c({action:s,location:h.location,delta:0})}function m(e){let t=i.location.origin===`null`?i.location.href:i.location.origin,n=typeof e==`string`?e:D(e);return n=n.replace(/ $/,`%20`),S(t,`No window.location.(origin|href) available to create URL for href: `+n),new URL(n,t)}let h={get action(){return s},get location(){return e(i,o)},listen(e){if(c)throw Error(`A history only accepts one active listener`);return i.addEventListener(b,d),c=e,()=>{i.removeEventListener(b,d),c=null}},createHref(e){return t(i,e)},createURL:m,encodeLocation(e){let t=m(e);return{pathname:t.pathname,search:t.search,hash:t.hash}},push:f,replace:p,go(e){return o.go(e)}};return h}var A;(function(e){e.data=`data`,e.deferred=`deferred`,e.redirect=`redirect`,e.error=`error`})(A||={});var ee=new Set([`lazy`,`caseSensitive`,`path`,`id`,`index`,`children`]);function j(e){return e.index===!0}function te(e,t,n,r){return n===void 0&&(n=[]),r===void 0&&(r={}),e.map((e,i)=>{let a=[...n,String(i)],o=typeof e.id==`string`?e.id:a.join(`-`);if(S(e.index!==!0||!e.children,`Cannot specify children on an index route`),S(!r[o],`Found a route id collision on id "`+o+`". Route id's must be globally unique within Data Router usages`),j(e)){let n=v({},e,t(e),{id:o});return r[o]=n,n}else{let n=v({},e,t(e),{id:o,children:void 0});return r[o]=n,e.children&&(n.children=te(e.children,t,a,r)),n}})}function ne(e,t,n){return n===void 0&&(n=`/`),M(e,t,n,!1)}function M(e,t,n,r){let i=_e((typeof t==`string`?O(t):t).pathname||`/`,n);if(i==null)return null;let a=ie(e);N(a);let o=null,s=F(i);for(let e=0;o==null&&e{let o={relativePath:a===void 0?e.path||``:a,caseSensitive:e.caseSensitive===!0,childrenIndex:i,route:e};o.relativePath.startsWith(`/`)&&(S(o.relativePath.startsWith(r),`Absolute route path "`+o.relativePath+`" nested under path `+(`"`+r+`" is not valid. An absolute child route path `)+`must start with the combined path of all its parent routes.`),o.relativePath=o.relativePath.slice(r.length));let s=Ee([r,o.relativePath]),c=n.concat(o);e.children&&e.children.length>0&&(S(e.index!==!0,`Index routes must not have child routes. Please remove `+(`all child routes from route path "`+s+`".`)),ie(e.children,t,c,s)),!(e.path==null&&!e.index)&&t.push({path:s,score:fe(s,e.index),routesMeta:c})};return e.forEach((e,t)=>{var n;if(e.path===``||!((n=e.path)!=null&&n.includes(`?`)))i(e,t);else for(let n of ae(e.path))i(e,t,n)}),t}function ae(e){let t=e.split(`/`);if(t.length===0)return[];let[n,...r]=t,i=n.endsWith(`?`),a=n.replace(/\?$/,``);if(r.length===0)return i?[a,``]:[a];let o=ae(r.join(`/`)),s=[];return s.push(...o.map(e=>e===``?a:[a,e].join(`/`))),i&&s.push(...o),s.map(t=>e.startsWith(`/`)&&t===``?`/`:t)}function N(e){e.sort((e,t)=>e.score===t.score?pe(e.routesMeta.map(e=>e.childrenIndex),t.routesMeta.map(e=>e.childrenIndex)):t.score-e.score)}var oe=/^:[\w-]+$/,se=3,ce=2,le=1,P=10,ue=-2,de=e=>e===`*`;function fe(e,t){let n=e.split(`/`),r=n.length;return n.some(de)&&(r+=ue),t&&(r+=ce),n.filter(e=>!de(e)).reduce((e,t)=>e+(oe.test(t)?se:t===``?le:P),r)}function pe(e,t){return e.length===t.length&&e.slice(0,-1).every((e,n)=>e===t[n])?e[e.length-1]-t[t.length-1]:0}function me(e,t,n){n===void 0&&(n=!1);let{routesMeta:r}=e,i={},a=`/`,o=[];for(let e=0;e{let{paramName:r,isOptional:i}=t;if(r===`*`){let e=s[n]||``;o=a.slice(0,a.length-e.length).replace(/(.)\/+$/,`$1`)}let c=s[n];return i&&!c?e[r]=void 0:e[r]=(c||``).replace(/%2F/g,`/`),e},{}),pathname:a,pathnameBase:o,pattern:e}}function ge(e,t,n){t===void 0&&(t=!1),n===void 0&&(n=!0),C(e===`*`||!e.endsWith(`*`)||e.endsWith(`/*`),`Route path "`+e+`" will be treated as if it were `+(`"`+e.replace(/\*$/,`/*`)+'" because the `*` character must ')+"always follow a `/` in the pattern. To get rid of this warning, "+(`please change the route path to "`+e.replace(/\*$/,`/*`)+`".`));let r=[],i=`^`+e.replace(/\/*\*?$/,``).replace(/^\/*/,`/`).replace(/[\\.*+^${}|()[\]]/g,`\\$&`).replace(/\/:([\w-]+)(\?)?/g,(e,t,n)=>(r.push({paramName:t,isOptional:n!=null}),n?`/?([^\\/]+)?`:`/([^\\/]+)`));return e.endsWith(`*`)?(r.push({paramName:`*`}),i+=e===`*`||e===`/*`?`(.*)$`:`(?:\\/(.+)|\\/*)$`):n?i+=`\\/*$`:e!==``&&e!==`/`&&(i+=`(?:(?=\\/|$))`),[new RegExp(i,t?void 0:`i`),r]}function F(e){try{return e.split(`/`).map(e=>decodeURIComponent(e).replace(/\//g,`%2F`)).join(`/`)}catch(t){return C(!1,`The URL path "`+e+`" could not be decoded because it is is a malformed URL segment. This is probably due to a bad percent `+(`encoding (`+t+`).`)),e}}function _e(e,t){if(t===`/`)return e;if(!e.toLowerCase().startsWith(t.toLowerCase()))return null;let n=t.endsWith(`/`)?t.length-1:t.length,r=e.charAt(n);return r&&r!==`/`?null:e.slice(n)||`/`}var ve=/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i,ye=e=>ve.test(e);function be(e,t){t===void 0&&(t=`/`);let{pathname:n,search:r=``,hash:i=``}=typeof e==`string`?O(e):e,a;if(n)if(ye(n))a=n;else{if(n.includes(`//`)){let e=n;n=Te(n),C(!1,`Pathnames cannot have embedded double slashes - normalizing `+(e+` -> `+n))}a=n.startsWith(`/`)?xe(n.substring(1),`/`):xe(n,t)}else a=t;return{pathname:a,search:Oe(r),hash:ke(i)}}function xe(e,t){let n=t.replace(/\/+$/,``).split(`/`);return e.split(`/`).forEach(e=>{e===`..`?n.length>1&&n.pop():e!==`.`&&n.push(e)}),n.length>1?n.join(`/`):`/`}function Se(e,t,n,r){return`Cannot include a '`+e+`' character in a manually specified `+("`to."+t+"` field ["+JSON.stringify(r)+`]. Please separate it out to the `)+("`to."+n+"` field. Alternatively you may provide the full path as ")+`a string in and the router will parse it for you.`}function I(e){return e.filter((e,t)=>t===0||e.route.path&&e.route.path.length>0)}function Ce(e,t){let n=I(e);return t?n.map((e,t)=>t===n.length-1?e.pathname:e.pathnameBase):n.map(e=>e.pathnameBase)}function we(e,t,n,r){r===void 0&&(r=!1);let i;typeof e==`string`?i=O(e):(i=v({},e),S(!i.pathname||!i.pathname.includes(`?`),Se(`?`,`pathname`,`search`,i)),S(!i.pathname||!i.pathname.includes(`#`),Se(`#`,`pathname`,`hash`,i)),S(!i.search||!i.search.includes(`#`),Se(`#`,`search`,`hash`,i)));let a=e===``||i.pathname===``,o=a?`/`:i.pathname,s;if(o==null)s=n;else{let e=t.length-1;if(!r&&o.startsWith(`..`)){let t=o.split(`/`);for(;t[0]===`..`;)t.shift(),--e;i.pathname=t.join(`/`)}s=e>=0?t[e]:`/`}let c=be(i,s),l=o&&o!==`/`&&o.endsWith(`/`),u=(a||o===`.`)&&n.endsWith(`/`);return!c.pathname.endsWith(`/`)&&(l||u)&&(c.pathname+=`/`),c}var Te=e=>e.replace(/\/\/+/g,`/`),Ee=e=>Te(e.join(`/`)),De=e=>e.replace(/\/+$/,``).replace(/^\/*/,`/`),Oe=e=>!e||e===`?`?``:e.startsWith(`?`)?e:`?`+e,ke=e=>!e||e===`#`?``:e.startsWith(`#`)?e:`#`+e,Ae=class{constructor(e,t,n,r){r===void 0&&(r=!1),this.status=e,this.statusText=t||``,this.internal=r,n instanceof Error?(this.data=n.toString(),this.error=n):this.data=n}};function je(e){return e!=null&&typeof e.status==`number`&&typeof e.statusText==`string`&&typeof e.internal==`boolean`&&`data`in e}var Me=[`post`,`put`,`patch`,`delete`],Ne=new Set(Me),Pe=[`get`,...Me],Fe=new Set(Pe),Ie=new Set([301,302,303,307,308]),Le=new Set([307,308]),Re={state:`idle`,location:void 0,formMethod:void 0,formAction:void 0,formEncType:void 0,formData:void 0,json:void 0,text:void 0},ze={state:`idle`,data:void 0,formMethod:void 0,formAction:void 0,formEncType:void 0,formData:void 0,json:void 0,text:void 0},Be={state:`unblocked`,proceed:void 0,reset:void 0,location:void 0},Ve=/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i,He=e=>({hasErrorBoundary:!!e.hasErrorBoundary}),Ue=`remix-router-transitions`;function We(e){let t=e.window?e.window:typeof window<`u`?window:void 0,n=t!==void 0&&t.document!==void 0&&t.document.createElement!==void 0,r=!n;S(e.routes.length>0,`You must provide a non-empty routes array to createRouter`);let i;if(e.mapRouteProperties)i=e.mapRouteProperties;else if(e.detectErrorBoundary){let t=e.detectErrorBoundary;i=e=>({hasErrorBoundary:t(e)})}else i=He;let a={},o=te(e.routes,i,void 0,a),s,c=e.basename||`/`,l=e.dataStrategy||rt,u=e.patchRoutesOnNavigation,d=v({v7_fetcherPersist:!1,v7_normalizeFormMethod:!1,v7_partialHydration:!1,v7_prependBasename:!1,v7_relativeSplatPath:!1,v7_skipActionErrorRevalidation:!1},e.future),f=null,p=new Set,m=null,h=null,g=null,_=e.hydrationData!=null,b=ne(o,e.history.location,c),x=!1,w=null;if(b==null&&!u){let t=vt(404,{pathname:e.history.location.pathname}),{matches:n,route:r}=_t(o);b=n,w={[r.id]:t}}b&&!e.hydrationData&&Wt(b,o,e.history.location.pathname).active&&(b=null);let T;if(!b){if(T=!1,b=[],d.v7_partialHydration){let t=Wt(null,o,e.history.location.pathname);t.active&&t.matches&&(x=!0,b=t.matches)}}else if(b.some(e=>e.route.lazy))T=!1;else if(!b.some(e=>e.route.loader))T=!0;else if(d.v7_partialHydration){let t=e.hydrationData?e.hydrationData.loaderData:null,n=e.hydrationData?e.hydrationData.errors:null;if(n){let e=b.findIndex(e=>n[e.route.id]!==void 0);T=b.slice(0,e+1).every(e=>!Xe(e.route,t,n))}else T=b.every(e=>!Xe(e.route,t,n))}else T=e.hydrationData!=null;let D,O={historyAction:e.history.action,location:e.history.location,matches:b,initialized:T,navigation:Re,restoreScrollPosition:e.hydrationData==null&&null,preventScrollReset:!1,revalidation:`idle`,loaderData:e.hydrationData&&e.hydrationData.loaderData||{},actionData:e.hydrationData&&e.hydrationData.actionData||null,errors:e.hydrationData&&e.hydrationData.errors||w,fetchers:new Map,blockers:new Map},k=y.Pop,ee=!1,j,ie=!1,ae=new Map,N=null,oe=!1,se=!1,ce=[],le=new Set,P=new Map,ue=0,de=-1,fe=new Map,pe=new Set,me=new Map,he=new Map,ge=new Set,F=new Map,ve=new Map,ye;function be(){if(f=e.history.listen(t=>{let{action:n,location:r,delta:i}=t;if(ye){ye(),ye=void 0;return}C(ve.size===0||i!=null,"You are trying to use a blocker on a POP navigation to a location that was not created by @remix-run/router. This will fail silently in production. This can happen if you are navigating outside the router via `window.history.pushState`/`window.location.hash` instead of using router navigation APIs. This can also happen if you are using createHashRouter and the user manually changes the URL.");let a=Tt({currentLocation:O.location,nextLocation:r,historyAction:n});if(a&&i!=null){let t=new Promise(e=>{ye=e});e.history.go(i*-1),yt(a,{state:`blocked`,location:r,proceed(){yt(a,{state:`proceeding`,proceed:void 0,reset:void 0,location:r}),t.then(()=>e.history.go(i))},reset(){let e=new Map(O.blockers);e.set(a,Be),I({blockers:e})}});return}return Ee(n,r)}),n){Vt(t,ae);let e=()=>Ht(t,ae);t.addEventListener(`pagehide`,e),N=()=>t.removeEventListener(`pagehide`,e)}return O.initialized||Ee(y.Pop,O.location,{initialHydration:!0}),D}function xe(){f&&f(),N&&N(),p.clear(),j&&j.abort(),O.fetchers.forEach((e,t)=>Qe(t)),O.blockers.forEach((e,t)=>ft(t))}function Se(e){return p.add(e),()=>p.delete(e)}function I(e,t){t===void 0&&(t={}),O=v({},O,e);let n=[],r=[];d.v7_fetcherPersist&&O.fetchers.forEach((e,t)=>{e.state===`idle`&&(ge.has(t)?r.push(t):n.push(t))}),ge.forEach(e=>{!O.fetchers.has(e)&&!P.has(e)&&r.push(e)}),[...p].forEach(e=>e(O,{deletedFetchers:r,viewTransitionOpts:t.viewTransitionOpts,flushSync:t.flushSync===!0})),d.v7_fetcherPersist?(n.forEach(e=>O.fetchers.delete(e)),r.forEach(e=>Qe(e))):r.forEach(e=>ge.delete(e))}function Ce(t,n,r){let{flushSync:i}=r===void 0?{}:r,a=O.actionData!=null&&O.navigation.formMethod!=null&&kt(O.navigation.formMethod)&&O.navigation.state===`loading`&&t.state?._isRedirect!==!0,c;c=n.actionData?Object.keys(n.actionData).length>0?n.actionData:null:a?O.actionData:null;let l=n.loaderData?mt(O.loaderData,n.loaderData,n.matches||[],n.errors):O.loaderData,u=O.blockers;u.size>0&&(u=new Map(u),u.forEach((e,t)=>u.set(t,Be)));let d=ee===!0||O.navigation.formMethod!=null&&kt(O.navigation.formMethod)&&t.state?._isRedirect!==!0;s&&=(o=s,void 0),oe||k===y.Pop||(k===y.Push?e.history.push(t,t.state):k===y.Replace&&e.history.replace(t,t.state));let f;if(k===y.Pop){let e=ae.get(O.location.pathname);e&&e.has(t.pathname)?f={currentLocation:O.location,nextLocation:t}:ae.has(t.pathname)&&(f={currentLocation:t,nextLocation:O.location})}else if(ie){let e=ae.get(O.location.pathname);e?e.add(t.pathname):(e=new Set([t.pathname]),ae.set(O.location.pathname,e)),f={currentLocation:O.location,nextLocation:t}}I(v({},n,{actionData:c,loaderData:l,historyAction:k,location:t,initialized:!0,navigation:Re,revalidation:`idle`,restoreScrollPosition:R(t,n.matches||O.matches),preventScrollReset:d,blockers:u}),{viewTransitionOpts:f,flushSync:i===!0}),k=y.Pop,ee=!1,ie=!1,oe=!1,se=!1,ce=[]}async function we(t,n){if(typeof t==`number`){e.history.go(t);return}let r=Ke(O.location,O.matches,c,d.v7_prependBasename,t,d.v7_relativeSplatPath,n?.fromRouteId,n?.relative),{path:i,submission:a,error:o}=qe(d.v7_normalizeFormMethod,!1,r,n),s=O.location,l=E(O.location,i,n&&n.state);l=v({},l,e.history.encodeLocation(l));let u=n&&n.replace!=null?n.replace:void 0,f=y.Push;u===!0?f=y.Replace:u===!1||a!=null&&kt(a.formMethod)&&a.formAction===O.location.pathname+O.location.search&&(f=y.Replace);let p=n&&`preventScrollReset`in n?n.preventScrollReset===!0:void 0,m=(n&&n.flushSync)===!0,h=Tt({currentLocation:s,nextLocation:l,historyAction:f});if(h){yt(h,{state:`blocked`,location:l,proceed(){yt(h,{state:`proceeding`,proceed:void 0,reset:void 0,location:l}),we(t,n)},reset(){let e=new Map(O.blockers);e.set(h,Be),I({blockers:e})}});return}return await Ee(f,l,{submission:a,pendingError:o,preventScrollReset:p,replace:n&&n.replace,enableViewTransition:n&&n.viewTransition,flushSync:m})}function Te(){if(We(),I({revalidation:`loading`}),O.navigation.state!==`submitting`){if(O.navigation.state===`idle`){Ee(O.historyAction,O.location,{startUninterruptedRevalidation:!0});return}Ee(k||O.historyAction,O.navigation.location,{overrideNavigation:O.navigation,enableViewTransition:ie===!0})}}async function Ee(t,n,r){j&&j.abort(),j=null,k=t,oe=(r&&r.startUninterruptedRevalidation)===!0,Ut(O.location,O.matches),ee=(r&&r.preventScrollReset)===!0,ie=(r&&r.enableViewTransition)===!0;let i=s||o,a=r&&r.overrideNavigation,l=r!=null&&r.initialHydration&&O.matches&&O.matches.length>0&&!x?O.matches:ne(i,n,c),u=(r&&r.flushSync)===!0;if(l&&O.initialized&&!se&&bt(O.location,n)&&!(r&&r.submission&&kt(r.submission.formMethod))){Ce(n,{matches:l},{flushSync:u});return}let d=Wt(l,i,n.pathname);if(d.active&&d.matches&&(l=d.matches),!l){let{error:e,notFoundMatches:t,route:r}=Et(n.pathname);Ce(n,{matches:t,loaderData:{},errors:{[r.id]:e}},{flushSync:u});return}j=new AbortController;let f=lt(e.history,n,j.signal,r&&r.submission),p;if(r&&r.pendingError)p=[gt(l).route.id,{type:A.error,error:r.pendingError}];else if(r&&r.submission&&kt(r.submission.formMethod)){let t=await De(f,n,r.submission,l,d.active,{replace:r.replace,flushSync:u});if(t.shortCircuited)return;if(t.pendingActionResult){let[e,r]=t.pendingActionResult;if(Ct(r)&&je(r.error)&&r.error.status===404){j=null,Ce(n,{matches:t.matches,loaderData:{},errors:{[e]:r.error}});return}}l=t.matches||l,p=t.pendingActionResult,a=It(n,r.submission),u=!1,d.active=!1,f=lt(e.history,f.url,f.signal)}let{shortCircuited:m,matches:h,loaderData:g,errors:_}=await Oe(f,n,l,d.active,a,r&&r.submission,r&&r.fetcherSubmission,r&&r.replace,r&&r.initialHydration===!0,u,p);m||(j=null,Ce(n,v({matches:h||l},ht(p),{loaderData:g,errors:_})))}async function De(t,n,r,i,a,o){if(o===void 0&&(o={}),We(),I({navigation:Lt(n,r)},{flushSync:o.flushSync===!0}),a){let e=await Gt(i,n.pathname,t.signal);if(e.type===`aborted`)return{shortCircuited:!0};if(e.type===`error`){let t=gt(e.partialMatches).route.id;return{matches:e.partialMatches,pendingActionResult:[t,{type:A.error,error:e.error}]}}else if(e.matches)i=e.matches;else{let{notFoundMatches:e,error:t,route:r}=Et(n.pathname);return{matches:e,pendingActionResult:[r.id,{type:A.error,error:t}]}}}let s,l=Pt(i,n);if(!l.route.action&&!l.route.lazy)s={type:A.error,error:vt(405,{method:t.method,pathname:n.pathname,routeId:l.route.id})};else if(s=(await Ie(`action`,O,t,[l],i,null))[l.route.id],t.signal.aborted)return{shortCircuited:!0};if(wt(s)){let n;return n=o&&o.replace!=null?o.replace:ct(s.response.headers.get(`Location`),new URL(t.url),c,e.history)===O.location.pathname+O.location.search,await Fe(t,s,!0,{submission:r,replace:n}),{shortCircuited:!0}}if(St(s))throw vt(400,{type:`defer-action`});if(Ct(s)){let e=gt(i,l.route.id);return(o&&o.replace)!==!0&&(k=y.Push),{matches:i,pendingActionResult:[e.route.id,s]}}return{matches:i,pendingActionResult:[l.route.id,s]}}async function Oe(t,n,r,i,a,l,u,f,p,m,h){let g=a||It(n,l),_=l||u||Ft(g),y=!oe&&(!d.v7_partialHydration||!p);if(i){if(y){let e=ke(h);I(v({navigation:g},e===void 0?{}:{actionData:e}),{flushSync:m})}let e=await Gt(r,n.pathname,t.signal);if(e.type===`aborted`)return{shortCircuited:!0};if(e.type===`error`){let t=gt(e.partialMatches).route.id;return{matches:e.partialMatches,loaderData:{},errors:{[t]:e.error}}}else if(e.matches)r=e.matches;else{let{error:e,notFoundMatches:t,route:r}=Et(n.pathname);return{matches:t,loaderData:{},errors:{[r.id]:e}}}}let b=s||o,[x,S]=Ye(e.history,O,r,_,n,d.v7_partialHydration&&p===!0,d.v7_skipActionErrorRevalidation,se,ce,le,ge,me,pe,b,c,h);if(Dt(e=>!(r&&r.some(t=>t.route.id===e))||x&&x.some(t=>t.route.id===e)),de=++ue,x.length===0&&S.length===0){let e=at();return Ce(n,v({matches:r,loaderData:{},errors:h&&Ct(h[1])?{[h[0]]:h[1].error}:null},ht(h),e?{fetchers:new Map(O.fetchers)}:{}),{flushSync:m}),{shortCircuited:!0}}if(y){let e={};if(!i){e.navigation=g;let t=ke(h);t!==void 0&&(e.actionData=t)}S.length>0&&(e.fetchers=Ae(S)),I(e,{flushSync:m})}S.forEach(e=>{tt(e.key),e.controller&&P.set(e.key,e.controller)});let C=()=>S.forEach(e=>tt(e.key));j&&j.signal.addEventListener(`abort`,C);let{loaderResults:w,fetcherResults:T}=await Ue(O,r,x,S,t);if(t.signal.aborted)return{shortCircuited:!0};j&&j.signal.removeEventListener(`abort`,C),S.forEach(e=>P.delete(e.key));let E=L(w);if(E)return await Fe(t,E.result,!0,{replace:f}),{shortCircuited:!0};if(E=L(T),E)return pe.add(E.key),await Fe(t,E.result,!0,{replace:f}),{shortCircuited:!0};let{loaderData:D,errors:k}=pt(O,r,w,h,S,T,F);F.forEach((e,t)=>{e.subscribe(n=>{(n||e.done)&&F.delete(t)})}),d.v7_partialHydration&&p&&O.errors&&(k=v({},O.errors,k));let A=at(),ee=ut(de),te=A||ee||S.length>0;return v({matches:r,loaderData:D,errors:k},te?{fetchers:new Map(O.fetchers)}:{})}function ke(e){if(e&&!Ct(e[1]))return{[e[0]]:e[1].data};if(O.actionData)return Object.keys(O.actionData).length===0?null:O.actionData}function Ae(e){return e.forEach(e=>{let t=O.fetchers.get(e.key),n=Rt(void 0,t?t.data:void 0);O.fetchers.set(e.key,n)}),new Map(O.fetchers)}function Me(e,t,n,i){if(r)throw Error(`router.fetch() was called during the server render, but it shouldn't be. You are likely calling a useFetcher() method in the body of your component. Try moving it to a useEffect or a callback.`);tt(e);let a=(i&&i.flushSync)===!0,l=s||o,u=Ke(O.location,O.matches,c,d.v7_prependBasename,n,d.v7_relativeSplatPath,t,i?.relative),f=ne(l,u,c),p=Wt(f,l,u);if(p.active&&p.matches&&(f=p.matches),!f){Je(e,t,vt(404,{pathname:u}),{flushSync:a});return}let{path:m,submission:h,error:g}=qe(d.v7_normalizeFormMethod,!0,u,i);if(g){Je(e,t,g,{flushSync:a});return}let _=Pt(f,m),v=(i&&i.preventScrollReset)===!0;if(h&&kt(h.formMethod)){Ne(e,t,m,_,f,p.active,a,v,h);return}me.set(e,{routeId:t,path:m}),Pe(e,t,m,_,f,p.active,a,v,h)}async function Ne(t,n,r,i,a,l,u,f,p){We(),me.delete(t);function m(e){return!e.route.action&&!e.route.lazy?(Je(t,n,vt(405,{method:p.formMethod,pathname:r,routeId:n}),{flushSync:u}),!0):!1}if(!l&&m(i))return;Ge(t,zt(p,O.fetchers.get(t)),{flushSync:u});let h=new AbortController,g=lt(e.history,r,h.signal,p);if(l){let e=await Gt(a,new URL(g.url).pathname,g.signal,t);if(e.type===`aborted`)return;if(e.type===`error`){Je(t,n,e.error,{flushSync:u});return}else if(!e.matches){Je(t,n,vt(404,{pathname:r}),{flushSync:u});return}else if(a=e.matches,i=Pt(a,r),m(i))return}P.set(t,h);let _=ue,v=(await Ie(`action`,O,g,[i],a,t))[i.route.id];if(g.signal.aborted){P.get(t)===h&&P.delete(t);return}if(d.v7_fetcherPersist&&ge.has(t)){if(wt(v)||Ct(v)){Ge(t,Bt(void 0));return}}else{if(wt(v))if(P.delete(t),de>_){Ge(t,Bt(void 0));return}else return pe.add(t),Ge(t,Rt(p)),Fe(g,v,!1,{fetcherSubmission:p,preventScrollReset:f});if(Ct(v)){Je(t,n,v.error);return}}if(St(v))throw vt(400,{type:`defer-action`});let y=O.navigation.location||O.location,b=lt(e.history,y,h.signal),x=s||o,C=O.navigation.state===`idle`?O.matches:ne(x,O.navigation.location,c);S(C,`Didn't find any matches after fetcher action`);let w=++ue;fe.set(t,w);let T=Rt(p,v.data);O.fetchers.set(t,T);let[E,D]=Ye(e.history,O,C,p,y,!1,d.v7_skipActionErrorRevalidation,se,ce,le,ge,me,pe,x,c,[i.route.id,v]);D.filter(e=>e.key!==t).forEach(e=>{let t=e.key,n=O.fetchers.get(t),r=Rt(void 0,n?n.data:void 0);O.fetchers.set(t,r),tt(t),e.controller&&P.set(t,e.controller)}),I({fetchers:new Map(O.fetchers)});let A=()=>D.forEach(e=>tt(e.key));h.signal.addEventListener(`abort`,A);let{loaderResults:ee,fetcherResults:te}=await Ue(O,C,E,D,b);if(h.signal.aborted)return;h.signal.removeEventListener(`abort`,A),fe.delete(t),P.delete(t),D.forEach(e=>P.delete(e.key));let M=L(ee);if(M)return Fe(b,M.result,!1,{preventScrollReset:f});if(M=L(te),M)return pe.add(M.key),Fe(b,M.result,!1,{preventScrollReset:f});let{loaderData:re,errors:ie}=pt(O,C,ee,void 0,D,te,F);if(O.fetchers.has(t)){let e=Bt(v.data);O.fetchers.set(t,e)}ut(w),O.navigation.state===`loading`&&w>de?(S(k,`Expected pending action`),j&&j.abort(),Ce(O.navigation.location,{matches:C,loaderData:re,errors:ie,fetchers:new Map(O.fetchers)})):(I({errors:ie,loaderData:mt(O.loaderData,re,C,ie),fetchers:new Map(O.fetchers)}),se=!1)}async function Pe(t,n,r,i,a,o,s,c,l){let u=O.fetchers.get(t);Ge(t,Rt(l,u?u.data:void 0),{flushSync:s});let d=new AbortController,f=lt(e.history,r,d.signal);if(o){let e=await Gt(a,new URL(f.url).pathname,f.signal,t);if(e.type===`aborted`)return;if(e.type===`error`){Je(t,n,e.error,{flushSync:s});return}else if(e.matches)a=e.matches,i=Pt(a,r);else{Je(t,n,vt(404,{pathname:r}),{flushSync:s});return}}P.set(t,d);let p=ue,m=(await Ie(`loader`,O,f,[i],a,t))[i.route.id];if(St(m)&&(m=await Mt(m,f.signal,!0)||m),P.get(t)===d&&P.delete(t),!f.signal.aborted){if(ge.has(t)){Ge(t,Bt(void 0));return}if(wt(m))if(de>p){Ge(t,Bt(void 0));return}else{pe.add(t),await Fe(f,m,!1,{preventScrollReset:c});return}if(Ct(m)){Je(t,n,m.error);return}S(!St(m),`Unhandled fetcher deferred data`),Ge(t,Bt(m.data))}}async function Fe(r,i,a,o){let{submission:s,fetcherSubmission:l,preventScrollReset:u,replace:d}=o===void 0?{}:o;i.response.headers.has(`X-Remix-Revalidate`)&&(se=!0);let f=i.response.headers.get(`Location`);S(f,`Expected a Location header on the redirect Response`),f=ct(f,new URL(r.url),c,e.history);let p=E(O.location,f,{_isRedirect:!0});if(n){let n=!1;if(i.response.headers.has(`X-Remix-Reload-Document`))n=!0;else if(Ve.test(f)){let r=e.history.createURL(f);n=r.origin!==t.location.origin||_e(r.pathname,c)==null}if(n){d?t.location.replace(f):t.location.assign(f);return}}j=null;let m=d===!0||i.response.headers.has(`X-Remix-Replace`)?y.Replace:y.Push,{formMethod:h,formAction:g,formEncType:_}=O.navigation;!s&&!l&&h&&g&&_&&(s=Ft(O.navigation));let b=s||l;Le.has(i.response.status)&&b&&kt(b.formMethod)?await Ee(m,p,{submission:v({},b,{formAction:f}),preventScrollReset:u||ee,enableViewTransition:a?ie:void 0}):await Ee(m,p,{overrideNavigation:It(p,s),fetcherSubmission:l,preventScrollReset:u||ee,enableViewTransition:a?ie:void 0})}async function Ie(e,t,n,r,o,s){let u,f={};try{u=await it(l,e,t,n,r,o,s,a,i)}catch(e){return r.forEach(t=>{f[t.route.id]={type:A.error,error:e}}),f}for(let[e,t]of Object.entries(u))if(xt(t)){let r=t.result;f[e]={type:A.redirect,response:st(r,n,e,o,c,d.v7_relativeSplatPath)}}else f[e]=await ot(t);return f}async function Ue(t,n,r,i,a){let o=t.matches,s=Ie(`loader`,t,a,r,n,null),c=Promise.all(i.map(async n=>{if(n.matches&&n.match&&n.controller){let r=(await Ie(`loader`,t,lt(e.history,n.path,n.controller.signal),[n.match],n.matches,n.key))[n.match.route.id];return{[n.key]:r}}else return Promise.resolve({[n.key]:{type:A.error,error:vt(404,{pathname:n.path})}})})),l=await s,u=(await c).reduce((e,t)=>Object.assign(e,t),{});return await Promise.all([At(n,l,a.signal,o,t.loaderData),jt(n,u,i)]),{loaderResults:l,fetcherResults:u}}function We(){se=!0,ce.push(...Dt()),me.forEach((e,t)=>{P.has(t)&&le.add(t),tt(t)})}function Ge(e,t,n){n===void 0&&(n={}),O.fetchers.set(e,t),I({fetchers:new Map(O.fetchers)},{flushSync:(n&&n.flushSync)===!0})}function Je(e,t,n,r){r===void 0&&(r={});let i=gt(O.matches,t);Qe(e),I({errors:{[i.route.id]:n},fetchers:new Map(O.fetchers)},{flushSync:(r&&r.flushSync)===!0})}function Ze(e){return he.set(e,(he.get(e)||0)+1),ge.has(e)&&ge.delete(e),O.fetchers.get(e)||ze}function Qe(e){let t=O.fetchers.get(e);P.has(e)&&!(t&&t.state===`loading`&&fe.has(e))&&tt(e),me.delete(e),fe.delete(e),pe.delete(e),d.v7_fetcherPersist&&ge.delete(e),le.delete(e),O.fetchers.delete(e)}function $e(e){let t=(he.get(e)||0)-1;t<=0?(he.delete(e),ge.add(e),d.v7_fetcherPersist||Qe(e)):he.set(e,t),I({fetchers:new Map(O.fetchers)})}function tt(e){let t=P.get(e);t&&(t.abort(),P.delete(e))}function nt(e){for(let t of e){let e=Bt(Ze(t).data);O.fetchers.set(t,e)}}function at(){let e=[],t=!1;for(let n of pe){let r=O.fetchers.get(n);S(r,`Expected fetcher: `+n),r.state===`loading`&&(pe.delete(n),e.push(n),t=!0)}return nt(e),t}function ut(e){let t=[];for(let[n,r]of fe)if(r0}function dt(e,t){let n=O.blockers.get(e)||Be;return ve.get(e)!==t&&ve.set(e,t),n}function ft(e){O.blockers.delete(e),ve.delete(e)}function yt(e,t){let n=O.blockers.get(e)||Be;S(n.state===`unblocked`&&t.state===`blocked`||n.state===`blocked`&&t.state===`blocked`||n.state===`blocked`&&t.state===`proceeding`||n.state===`blocked`&&t.state===`unblocked`||n.state===`proceeding`&&t.state===`unblocked`,`Invalid blocker state transition: `+n.state+` -> `+t.state);let r=new Map(O.blockers);r.set(e,t),I({blockers:r})}function Tt(e){let{currentLocation:t,nextLocation:n,historyAction:r}=e;if(ve.size===0)return;ve.size>1&&C(!1,`A router only supports one blocker at a time`);let i=Array.from(ve.entries()),[a,o]=i[i.length-1],s=O.blockers.get(a);if(!(s&&s.state===`proceeding`)&&o({currentLocation:t,nextLocation:n,historyAction:r}))return a}function Et(e){let t=vt(404,{pathname:e}),{matches:n,route:r}=_t(s||o);return Dt(),{notFoundMatches:n,route:r,error:t}}function Dt(e){let t=[];return F.forEach((n,r)=>{(!e||e(r))&&(n.cancel(),t.push(r),F.delete(r))}),t}function Ot(e,t,n){if(m=e,g=t,h=n||null,!_&&O.navigation===Re){_=!0;let e=R(O.location,O.matches);e!=null&&I({restoreScrollPosition:e})}return()=>{m=null,g=null,h=null}}function Nt(e,t){return h&&h(e,t.map(e=>re(e,O.loaderData)))||e.key}function Ut(e,t){if(m&&g){let n=Nt(e,t);m[n]=g()}}function R(e,t){if(m){let n=Nt(e,t),r=m[n];if(typeof r==`number`)return r}return null}function Wt(e,t,n){if(u){if(!e)return{active:!0,matches:M(t,n,c,!0)||[]};if(Object.keys(e[0].params).length>0)return{active:!0,matches:M(t,n,c,!0)}}return{active:!1,matches:null}}async function Gt(e,t,n,r){if(!u)return{type:`success`,matches:e};let l=e;for(;;){let e=s==null,d=s||o,f=a;try{await u({signal:n,path:t,matches:l,fetcherKey:r,patch:(e,t)=>{n.aborted||et(e,t,d,f,i)}})}catch(e){return{type:`error`,error:e,partialMatches:l}}finally{e&&!n.aborted&&(o=[...o])}if(n.aborted)return{type:`aborted`};let p=ne(d,t,c);if(p)return{type:`success`,matches:p};let m=M(d,t,c,!0);if(!m||l.length===m.length&&l.every((e,t)=>e.route.id===m[t].route.id))return{type:`success`,matches:null};l=m}}function Kt(e){a={},s=te(e,i,void 0,a)}function qt(e,t){let n=s==null;et(e,t,s||o,a,i),n&&(o=[...o],I({}))}return D={get basename(){return c},get future(){return d},get state(){return O},get routes(){return o},get window(){return t},initialize:be,subscribe:Se,enableScrollRestoration:Ot,navigate:we,fetch:Me,revalidate:Te,createHref:t=>e.history.createHref(t),encodeLocation:t=>e.history.encodeLocation(t),getFetcher:Ze,deleteFetcher:$e,dispose:xe,getBlocker:dt,deleteBlocker:ft,patchRoutes:qt,_internalFetchControllers:P,_internalActiveDeferreds:F,_internalSetRoutes:Kt},D}function Ge(e){return e!=null&&(`formData`in e&&e.formData!=null||`body`in e&&e.body!==void 0)}function Ke(e,t,n,r,i,a,o,s){let c,l;if(o){c=[];for(let e of t)if(c.push(e),e.route.id===o){l=e;break}}else c=t,l=t[t.length-1];let u=we(i||`.`,Ce(c,a),_e(e.pathname,n)||e.pathname,s===`path`);if(i??(u.search=e.search,u.hash=e.hash),(i==null||i===``||i===`.`)&&l){let e=Nt(u.search);if(l.route.index&&!e)u.search=u.search?u.search.replace(/^\?/,`?index&`):`?index`;else if(!l.route.index&&e){let e=new URLSearchParams(u.search),t=e.getAll(`index`);e.delete(`index`),t.filter(e=>e).forEach(t=>e.append(`index`,t));let n=e.toString();u.search=n?`?`+n:``}}return r&&n!==`/`&&(u.pathname=u.pathname===`/`?n:Ee([n,u.pathname])),D(u)}function qe(e,t,n,r){if(!r||!Ge(r))return{path:n};if(r.formMethod&&!Ot(r.formMethod))return{path:n,error:vt(405,{method:r.formMethod})};let i=()=>({path:n,error:vt(400,{type:`invalid-body`})}),a=r.formMethod||`get`,o=e?a.toUpperCase():a.toLowerCase(),s=yt(n);if(r.body!==void 0){if(r.formEncType===`text/plain`){if(!kt(o))return i();let e=typeof r.body==`string`?r.body:r.body instanceof FormData||r.body instanceof URLSearchParams?Array.from(r.body.entries()).reduce((e,t)=>{let[n,r]=t;return``+e+n+`=`+r+` -`},``):String(r.body);return{path:n,submission:{formMethod:o,formAction:s,formEncType:r.formEncType,formData:void 0,json:void 0,text:e}}}else if(r.formEncType===`application/json`){if(!kt(o))return i();try{let e=typeof r.body==`string`?JSON.parse(r.body):r.body;return{path:n,submission:{formMethod:o,formAction:s,formEncType:r.formEncType,formData:void 0,json:e,text:void 0}}}catch{return i()}}}S(typeof FormData==`function`,`FormData is not available in this environment`);let c,l;if(r.formData)c=ut(r.formData),l=r.formData;else if(r.body instanceof FormData)c=ut(r.body),l=r.body;else if(r.body instanceof URLSearchParams)c=r.body,l=dt(c);else if(r.body==null)c=new URLSearchParams,l=new FormData;else try{c=new URLSearchParams(r.body),l=dt(c)}catch{return i()}let u={formMethod:o,formAction:s,formEncType:r&&r.formEncType||`application/x-www-form-urlencoded`,formData:l,json:void 0,text:void 0};if(kt(u.formMethod))return{path:n,submission:u};let d=O(n);return t&&d.search&&Nt(d.search)&&c.append(`index`,``),d.search=`?`+c,{path:D(d),submission:u}}function Je(e,t,n){n===void 0&&(n=!1);let r=e.findIndex(e=>e.route.id===t);return r>=0?e.slice(0,n?r+1:r):e}function Ye(e,t,n,r,i,a,o,s,c,l,u,d,f,p,m,h){let g=h?Ct(h[1])?h[1].error:h[1].data:void 0,_=e.createURL(t.location),y=e.createURL(i),b=n;a&&t.errors?b=Je(n,Object.keys(t.errors)[0],!0):h&&Ct(h[1])&&(b=Je(n,h[0]));let x=h?h[1].statusCode:void 0,S=o&&x&&x>=400,C=b.filter((e,n)=>{let{route:i}=e;if(i.lazy)return!0;if(i.loader==null)return!1;if(a)return Xe(i,t.loaderData,t.errors);if(Ze(t.loaderData,t.matches[n],e)||c.some(t=>t===e.route.id))return!0;let o=t.matches[n],l=e;return $e(e,v({currentUrl:_,currentParams:o.params,nextUrl:y,nextParams:l.params},r,{actionResult:g,actionStatus:x,defaultShouldRevalidate:S?!1:s||_.pathname+_.search===y.pathname+y.search||_.search!==y.search||Qe(o,l)}))}),w=[];return d.forEach((e,i)=>{if(a||!n.some(t=>t.route.id===e.routeId)||u.has(i))return;let o=ne(p,e.path,m);if(!o){w.push({key:i,routeId:e.routeId,path:e.path,matches:null,match:null,controller:null});return}let c=t.fetchers.get(i),d=Pt(o,e.path),h=!1;f.has(i)?h=!1:l.has(i)?(l.delete(i),h=!0):h=c&&c.state!==`idle`&&c.data===void 0?s:$e(d,v({currentUrl:_,currentParams:t.matches[t.matches.length-1].params,nextUrl:y,nextParams:n[n.length-1].params},r,{actionResult:g,actionStatus:x,defaultShouldRevalidate:!S&&s})),h&&w.push({key:i,routeId:e.routeId,path:e.path,matches:o,match:d,controller:new AbortController})}),[C,w]}function Xe(e,t,n){if(e.lazy)return!0;if(!e.loader)return!1;let r=t!=null&&t[e.id]!==void 0,i=n!=null&&n[e.id]!==void 0;return!r&&i?!1:typeof e.loader==`function`&&e.loader.hydrate===!0||!r&&!i}function Ze(e,t,n){let r=!t||n.route.id!==t.route.id,i=e[n.route.id]===void 0;return r||i}function Qe(e,t){let n=e.route.path;return e.pathname!==t.pathname||n!=null&&n.endsWith(`*`)&&e.params[`*`]!==t.params[`*`]}function $e(e,t){if(e.route.shouldRevalidate){let n=e.route.shouldRevalidate(t);if(typeof n==`boolean`)return n}return t.defaultShouldRevalidate}function et(e,t,n,r,i){let a;if(e){let t=r[e];S(t,`No route found to patch children into: routeId = `+e),t.children||=[],a=t.children}else a=n;let o=te(t.filter(e=>!a.some(t=>tt(e,t))),i,[e||`_`,`patch`,String(a?.length||`0`)],r);a.push(...o)}function tt(e,t){return`id`in e&&`id`in t&&e.id===t.id?!0:e.index===t.index&&e.path===t.path&&e.caseSensitive===t.caseSensitive?(!e.children||e.children.length===0)&&(!t.children||t.children.length===0)||e.children.every((e,n)=>t.children?.some(t=>tt(e,t))):!1}async function nt(e,t,n){if(!e.lazy)return;let r=await e.lazy();if(!e.lazy)return;let i=n[e.id];S(i,`No route found in manifest`);let a={};for(let e in r){let t=i[e]!==void 0&&e!==`hasErrorBoundary`;C(!t,`Route "`+i.id+`" has a static property "`+e+`" defined but its lazy function is also returning a value for this property. `+(`The lazy route property "`+e+`" will be ignored.`)),!t&&!ee.has(e)&&(a[e]=r[e])}Object.assign(i,a),Object.assign(i,v({},t(i),{lazy:void 0}))}async function rt(e){let{matches:t}=e,n=t.filter(e=>e.shouldLoad);return(await Promise.all(n.map(e=>e.resolve()))).reduce((e,t,r)=>Object.assign(e,{[n[r].route.id]:t}),{})}async function it(e,t,n,r,i,a,o,s,c,l){let u=a.map(e=>e.route.lazy?nt(e.route,c,s):void 0),d=await e({matches:a.map((e,n)=>{let a=u[n],o=i.some(t=>t.route.id===e.route.id);return v({},e,{shouldLoad:o,resolve:async n=>(n&&r.method===`GET`&&(e.route.lazy||e.route.loader)&&(o=!0),o?at(t,r,e,a,n,l):Promise.resolve({type:A.data,result:void 0}))})}),request:r,params:a[0].params,fetcherKey:o,context:l});try{await Promise.all(u)}catch{}return d}async function at(e,t,n,r,i,a){let o,s,c=r=>{let o,c=new Promise((e,t)=>o=t);s=()=>o(),t.signal.addEventListener(`abort`,s);let l=i=>typeof r==`function`?r({request:t,params:n.params,context:a},...i===void 0?[]:[i]):Promise.reject(Error(`You cannot call the handler for a route which defines a boolean `+(`"`+e+`" [routeId: `+n.route.id+`]`))),u=(async()=>{try{return{type:`data`,result:await(i?i(e=>l(e)):l())}}catch(e){return{type:`error`,result:e}}})();return Promise.race([u,c])};try{let i=n.route[e];if(r)if(i){let e,[t]=await Promise.all([c(i).catch(t=>{e=t}),r]);if(e!==void 0)throw e;o=t}else if(await r,i=n.route[e],i)o=await c(i);else if(e===`action`){let e=new URL(t.url),r=e.pathname+e.search;throw vt(405,{method:t.method,pathname:r,routeId:n.route.id})}else return{type:A.data,result:void 0};else if(i)o=await c(i);else{let e=new URL(t.url);throw vt(404,{pathname:e.pathname+e.search})}S(o.result!==void 0,`You defined `+(e===`action`?`an action`:`a loader`)+` for route `+(`"`+n.route.id+`" but didn't return anything from your \``+e+"` ")+"function. Please return a value or `null`.")}catch(e){return{type:A.error,result:e}}finally{s&&t.signal.removeEventListener(`abort`,s)}return o}async function ot(e){let{result:t,type:n}=e;if(Dt(t)){let e;try{let n=t.headers.get(`Content-Type`);e=n&&/\bapplication\/json\b/.test(n)?t.body==null?null:await t.json():await t.text()}catch(e){return{type:A.error,error:e}}return n===A.error?{type:A.error,error:new Ae(t.status,t.statusText,e),statusCode:t.status,headers:t.headers}:{type:A.data,data:e,statusCode:t.status,headers:t.headers}}if(n===A.error){if(Tt(t)){var r;if(t.data instanceof Error){var i;return{type:A.error,error:t.data,statusCode:t.init?.status,headers:(i=t.init)!=null&&i.headers?new Headers(t.init.headers):void 0}}return{type:A.error,error:new Ae(t.init?.status||500,void 0,t.data),statusCode:je(t)?t.status:void 0,headers:(r=t.init)!=null&&r.headers?new Headers(t.init.headers):void 0}}return{type:A.error,error:t,statusCode:je(t)?t.status:void 0}}if(Et(t))return{type:A.deferred,deferredData:t,statusCode:t.init?.status,headers:t.init?.headers&&new Headers(t.init.headers)};if(Tt(t)){var a;return{type:A.data,data:t.data,statusCode:t.init?.status,headers:(a=t.init)!=null&&a.headers?new Headers(t.init.headers):void 0}}return{type:A.data,data:t}}function st(e,t,n,r,i,a){let o=e.headers.get(`Location`);if(S(o,`Redirects returned/thrown from loaders/actions must have a Location header`),!Ve.test(o)){let s=r.slice(0,r.findIndex(e=>e.route.id===n)+1);o=Ke(new URL(t.url),s,i,!0,o,a),e.headers.set(`Location`,o)}return e}function ct(e,t,n,r){let i=[`about:`,`blob:`,`chrome:`,`chrome-untrusted:`,`content:`,`data:`,`devtools:`,`file:`,`filesystem:`,`javascript:`];if(Ve.test(e)){let r=e,a=r.startsWith(`//`)?new URL(t.protocol+r):new URL(r);if(i.includes(a.protocol))throw Error(`Invalid redirect location`);let o=_e(a.pathname,n)!=null;if(a.origin===t.origin&&o)return Te(a.pathname)+a.search+a.hash}try{let t=r.createURL(e);if(i.includes(t.protocol))throw Error(`Invalid redirect location`)}catch{}return e}function lt(e,t,n,r){let i=e.createURL(yt(t)).toString(),a={signal:n};if(r&&kt(r.formMethod)){let{formMethod:e,formEncType:t}=r;a.method=e.toUpperCase(),t===`application/json`?(a.headers=new Headers({"Content-Type":t}),a.body=JSON.stringify(r.json)):t===`text/plain`?a.body=r.text:t===`application/x-www-form-urlencoded`&&r.formData?a.body=ut(r.formData):a.body=r.formData}return new Request(i,a)}function ut(e){let t=new URLSearchParams;for(let[n,r]of e.entries())t.append(n,typeof r==`string`?r:r.name);return t}function dt(e){let t=new FormData;for(let[n,r]of e.entries())t.append(n,r);return t}function ft(e,t,n,r,i){let a={},o=null,s,c=!1,l={},u=n&&Ct(n[1])?n[1].error:void 0;return e.forEach(n=>{if(!(n.route.id in t))return;let d=n.route.id,f=t[d];if(S(!wt(f),`Cannot handle redirect results in processLoaderData`),Ct(f)){let t=f.error;if(u!==void 0&&(t=u,u=void 0),o||={},i)o[d]=t;else{let n=gt(e,d);o[n.route.id]??(o[n.route.id]=t)}a[d]=void 0,c||(c=!0,s=je(f.error)?f.error.status:500),f.headers&&(l[d]=f.headers)}else St(f)?(r.set(d,f.deferredData),a[d]=f.deferredData.data,f.statusCode!=null&&f.statusCode!==200&&!c&&(s=f.statusCode),f.headers&&(l[d]=f.headers)):(a[d]=f.data,f.statusCode&&f.statusCode!==200&&!c&&(s=f.statusCode),f.headers&&(l[d]=f.headers))}),u!==void 0&&n&&(o={[n[0]]:u},a[n[0]]=void 0),{loaderData:a,errors:o,statusCode:s||200,loaderHeaders:l}}function pt(e,t,n,r,i,a,o){let{loaderData:s,errors:c}=ft(t,n,r,o,!1);return i.forEach(t=>{let{key:n,match:r,controller:i}=t,o=a[n];if(S(o,`Did not find corresponding fetcher result`),!(i&&i.signal.aborted))if(Ct(o)){let t=gt(e.matches,r?.route.id);c&&c[t.route.id]||(c=v({},c,{[t.route.id]:o.error})),e.fetchers.delete(n)}else if(wt(o))S(!1,`Unhandled fetcher revalidation redirect`);else if(St(o))S(!1,`Unhandled fetcher deferred data`);else{let t=Bt(o.data);e.fetchers.set(n,t)}}),{loaderData:s,errors:c}}function mt(e,t,n,r){let i=v({},t);for(let a of n){let n=a.route.id;if(t.hasOwnProperty(n)?t[n]!==void 0&&(i[n]=t[n]):e[n]!==void 0&&a.route.loader&&(i[n]=e[n]),r&&r.hasOwnProperty(n))break}return i}function ht(e){return e?Ct(e[1])?{actionData:{}}:{actionData:{[e[0]]:e[1].data}}:{}}function gt(e,t){return(t?e.slice(0,e.findIndex(e=>e.route.id===t)+1):[...e]).reverse().find(e=>e.route.hasErrorBoundary===!0)||e[0]}function _t(e){let t=e.length===1?e[0]:e.find(e=>e.index||!e.path||e.path===`/`)||{id:`__shim-error-route__`};return{matches:[{params:{},pathname:``,pathnameBase:``,route:t}],route:t}}function vt(e,t){let{pathname:n,routeId:r,method:i,type:a,message:o}=t===void 0?{}:t,s=`Unknown Server Error`,c=`Unknown @remix-run/router error`;return e===400?(s=`Bad Request`,i&&n&&r?c=`You made a `+i+` request to "`+n+`" but `+('did not provide a `loader` for route "'+r+`", `)+`so there is no way to handle the request.`:a===`defer-action`?c=`defer() is not supported in actions`:a===`invalid-body`&&(c=`Unable to encode submission body`)):e===403?(s=`Forbidden`,c=`Route "`+r+`" does not match URL "`+n+`"`):e===404?(s=`Not Found`,c=`No route matches URL "`+n+`"`):e===405&&(s=`Method Not Allowed`,i&&n&&r?c=`You made a `+i.toUpperCase()+` request to "`+n+`" but `+('did not provide an `action` for route "'+r+`", `)+`so there is no way to handle the request.`:i&&(c=`Invalid request method "`+i.toUpperCase()+`"`)),new Ae(e||500,s,Error(c),!0)}function L(e){let t=Object.entries(e);for(let e=t.length-1;e>=0;e--){let[n,r]=t[e];if(wt(r))return{key:n,result:r}}}function yt(e){let t=typeof e==`string`?O(e):e;return D(v({},t,{hash:``}))}function bt(e,t){return e.pathname!==t.pathname||e.search!==t.search?!1:e.hash===``?t.hash!==``:e.hash===t.hash||t.hash!==``}function xt(e){return Dt(e.result)&&Ie.has(e.result.status)}function St(e){return e.type===A.deferred}function Ct(e){return e.type===A.error}function wt(e){return(e&&e.type)===A.redirect}function Tt(e){return typeof e==`object`&&!!e&&`type`in e&&`data`in e&&`init`in e&&e.type===`DataWithResponseInit`}function Et(e){let t=e;return t&&typeof t==`object`&&typeof t.data==`object`&&typeof t.subscribe==`function`&&typeof t.cancel==`function`&&typeof t.resolveData==`function`}function Dt(e){return e!=null&&typeof e.status==`number`&&typeof e.statusText==`string`&&typeof e.headers==`object`&&e.body!==void 0}function Ot(e){return Fe.has(e.toLowerCase())}function kt(e){return Ne.has(e.toLowerCase())}async function At(e,t,n,r,i){let a=Object.entries(t);for(let o=0;oe?.route.id===s);if(!l)continue;let u=r.find(e=>e.route.id===l.route.id),d=u!=null&&!Qe(u,l)&&(i&&i[l.route.id])!==void 0;St(c)&&d&&await Mt(c,n,!1).then(e=>{e&&(t[s]=e)})}}async function jt(e,t,n){for(let r=0;re?.route.id===a)&&St(s)&&(S(o,`Expected an AbortController for revalidating fetcher deferred result`),await Mt(s,o.signal,!0).then(e=>{e&&(t[i]=e)}))}}async function Mt(e,t,n){if(n===void 0&&(n=!1),!await e.deferredData.resolveData(t)){if(n)try{return{type:A.data,data:e.deferredData.unwrappedData}}catch(e){return{type:A.error,error:e}}return{type:A.data,data:e.deferredData.data}}}function Nt(e){return new URLSearchParams(e).getAll(`index`).some(e=>e===``)}function Pt(e,t){let n=typeof t==`string`?O(t).search:t.search;if(e[e.length-1].route.index&&Nt(n||``))return e[e.length-1];let r=I(e);return r[r.length-1]}function Ft(e){let{formMethod:t,formAction:n,formEncType:r,text:i,formData:a,json:o}=e;if(!(!t||!n||!r)){if(i!=null)return{formMethod:t,formAction:n,formEncType:r,formData:void 0,json:void 0,text:i};if(a!=null)return{formMethod:t,formAction:n,formEncType:r,formData:a,json:void 0,text:void 0};if(o!==void 0)return{formMethod:t,formAction:n,formEncType:r,formData:void 0,json:o,text:void 0}}}function It(e,t){return t?{state:`loading`,location:e,formMethod:t.formMethod,formAction:t.formAction,formEncType:t.formEncType,formData:t.formData,json:t.json,text:t.text}:{state:`loading`,location:e,formMethod:void 0,formAction:void 0,formEncType:void 0,formData:void 0,json:void 0,text:void 0}}function Lt(e,t){return{state:`submitting`,location:e,formMethod:t.formMethod,formAction:t.formAction,formEncType:t.formEncType,formData:t.formData,json:t.json,text:t.text}}function Rt(e,t){return e?{state:`loading`,formMethod:e.formMethod,formAction:e.formAction,formEncType:e.formEncType,formData:e.formData,json:e.json,text:e.text,data:t}:{state:`loading`,formMethod:void 0,formAction:void 0,formEncType:void 0,formData:void 0,json:void 0,text:void 0,data:t}}function zt(e,t){return{state:`submitting`,formMethod:e.formMethod,formAction:e.formAction,formEncType:e.formEncType,formData:e.formData,json:e.json,text:e.text,data:t?t.data:void 0}}function Bt(e){return{state:`idle`,formMethod:void 0,formAction:void 0,formEncType:void 0,formData:void 0,json:void 0,text:void 0,data:e}}function Vt(e,t){try{let n=e.sessionStorage.getItem(Ue);if(n){let e=JSON.parse(n);for(let[n,r]of Object.entries(e||{}))r&&Array.isArray(r)&&t.set(n,new Set(r||[]))}}catch{}}function Ht(e,t){if(t.size>0){let n={};for(let[e,r]of t)n[e]=[...r];try{e.sessionStorage.setItem(Ue,JSON.stringify(n))}catch(e){C(!1,`Failed to save applied view transitions in sessionStorage (`+e+`).`)}}}function Ut(){return Ut=Object.assign?Object.assign.bind():function(e){for(var t=1;t{s.current=!0}),h.useCallback(function(n,i){if(i===void 0&&(i={}),!s.current)return;if(typeof n==`number`){r.go(n);return}let c=we(n,JSON.parse(o),a,i.relative===`path`);e==null&&t!==`/`&&(c.pathname=c.pathname===`/`?t:Ee([t,c.pathname])),(i.replace?r.replace:r.push)(c,i.state,i)},[t,r,o,a,e])}var tn=h.createContext(null);function nn(e){let t=h.useContext(qt).outlet;return t&&h.createElement(tn.Provider,{value:e},t)}function rn(){let{matches:e}=h.useContext(qt),t=e[e.length-1];return t?t.params:{}}function an(e,t){let{relative:n}=t===void 0?{}:t,{future:r}=h.useContext(Gt),{matches:i}=h.useContext(qt),{pathname:a}=Zt(),o=JSON.stringify(Ce(i,r.v7_relativeSplatPath));return h.useMemo(()=>we(e,JSON.parse(o),a,n===`path`),[e,o,a,n])}function on(e,t,n,r){!Xt()&&S(!1);let{navigator:i}=h.useContext(Gt),{matches:a}=h.useContext(qt),o=a[a.length-1],s=o?o.params:{};o&&o.pathname;let c=o?o.pathnameBase:`/`;o&&o.route;let l=Zt(),u;if(t){let e=typeof t==`string`?O(t):t;!(c===`/`||e.pathname?.startsWith(c))&&S(!1),u=e}else u=l;let d=u.pathname||`/`,f=d;if(c!==`/`){let e=c.replace(/^\//,``).split(`/`);f=`/`+d.replace(/^\//,``).split(`/`).slice(e.length).join(`/`)}let p=ne(e,{pathname:f}),m=dn(p&&p.map(e=>Object.assign({},e,{params:Object.assign({},s,e.params),pathname:Ee([c,i.encodeLocation?i.encodeLocation(e.pathname).pathname:e.pathname]),pathnameBase:e.pathnameBase===`/`?c:Ee([c,i.encodeLocation?i.encodeLocation(e.pathnameBase).pathname:e.pathnameBase])})),a,n,r);return t&&m?h.createElement(Kt.Provider,{value:{location:Ut({pathname:`/`,search:``,hash:``,state:null,key:`default`},u),navigationType:y.Pop}},m):m}function sn(){let e=vn(),t=je(e)?e.status+` `+e.statusText:e instanceof Error?e.message:JSON.stringify(e),n=e instanceof Error?e.stack:null;return h.createElement(h.Fragment,null,h.createElement(`h2`,null,`Unexpected Application Error!`),h.createElement(`h3`,{style:{fontStyle:`italic`}},t),n?h.createElement(`pre`,{style:{padding:`0.5rem`,backgroundColor:`rgba(200,200,200, 0.5)`}},n):null,null)}var cn=h.createElement(sn,null),ln=class extends h.Component{constructor(e){super(e),this.state={location:e.location,revalidation:e.revalidation,error:e.error}}static getDerivedStateFromError(e){return{error:e}}static getDerivedStateFromProps(e,t){return t.location!==e.location||t.revalidation!==`idle`&&e.revalidation===`idle`?{error:e.error,location:e.location,revalidation:e.revalidation}:{error:e.error===void 0?t.error:e.error,location:t.location,revalidation:e.revalidation||t.revalidation}}componentDidCatch(e,t){console.error(`React Router caught the following error during render`,e,t)}render(){return this.state.error===void 0?this.props.children:h.createElement(qt.Provider,{value:this.props.routeContext},h.createElement(Jt.Provider,{value:this.state.error,children:this.props.component}))}};function un(e){let{routeContext:t,match:n,children:r}=e,i=h.useContext(R);return i&&i.static&&i.staticContext&&(n.route.errorElement||n.route.ErrorBoundary)&&(i.staticContext._deepestRenderedBoundaryId=n.route.id),h.createElement(qt.Provider,{value:t},r)}function dn(e,t,n,r){if(t===void 0&&(t=[]),n===void 0&&(n=null),r===void 0&&(r=null),e==null){var i;if(!n)return null;if(n.errors)e=n.matches;else if((i=r)!=null&&i.v7_partialHydration&&t.length===0&&!n.initialized&&n.matches.length>0)e=n.matches;else return null}let a=e,o=n?.errors;if(o!=null){let e=a.findIndex(e=>e.route.id&&o?.[e.route.id]!==void 0);!(e>=0)&&S(!1),a=a.slice(0,Math.min(a.length,e+1))}let s=!1,c=-1;if(n&&r&&r.v7_partialHydration)for(let e=0;e=0?a.slice(0,c+1):[a[0]];break}}}return a.reduceRight((e,r,i)=>{let l,u=!1,d=null,f=null;n&&(l=o&&r.route.id?o[r.route.id]:void 0,d=r.route.errorElement||cn,s&&(c<0&&i===0?(xn(`route-fallback`,!1,"No `HydrateFallback` element provided to render during initial hydration"),u=!0,f=null):c===i&&(u=!0,f=r.route.hydrateFallbackElement||null)));let p=t.concat(a.slice(0,i+1)),m=()=>{let t;return t=l?d:u?f:r.route.Component?h.createElement(r.route.Component,null):r.route.element?r.route.element:e,h.createElement(un,{match:r,routeContext:{outlet:e,matches:p,isDataRoute:n!=null},children:t})};return n&&(r.route.ErrorBoundary||r.route.errorElement||i===0)?h.createElement(ln,{location:n.location,revalidation:n.revalidation,component:d,error:l,children:m(),routeContext:{outlet:null,matches:p,isDataRoute:!0}}):m()},null)}var fn=function(e){return e.UseBlocker=`useBlocker`,e.UseRevalidator=`useRevalidator`,e.UseNavigateStable=`useNavigate`,e}(fn||{}),pn=function(e){return e.UseBlocker=`useBlocker`,e.UseLoaderData=`useLoaderData`,e.UseActionData=`useActionData`,e.UseRouteError=`useRouteError`,e.UseNavigation=`useNavigation`,e.UseRouteLoaderData=`useRouteLoaderData`,e.UseMatches=`useMatches`,e.UseRevalidator=`useRevalidator`,e.UseNavigateStable=`useNavigate`,e.UseRouteId=`useRouteId`,e}(pn||{});function mn(e){let t=h.useContext(R);return!t&&S(!1),t}function hn(e){let t=h.useContext(Wt);return!t&&S(!1),t}function gn(e){let t=h.useContext(qt);return!t&&S(!1),t}function _n(e){let t=gn(e),n=t.matches[t.matches.length-1];return!n.route.id&&S(!1),n.route.id}function vn(){let e=h.useContext(Jt),t=hn(pn.UseRouteError),n=_n(pn.UseRouteError);return e===void 0?t.errors?.[n]:e}function yn(){let{router:e}=mn(fn.UseNavigateStable),t=_n(pn.UseNavigateStable),n=h.useRef(!1);return Qt(()=>{n.current=!0}),h.useCallback(function(r,i){i===void 0&&(i={}),n.current&&(typeof r==`number`?e.navigate(r):e.navigate(r,Ut({fromRouteId:t},i)))},[e,t])}var bn={};function xn(e,t,n){!t&&!bn[e]&&(bn[e]=!0)}var Sn=(e,t,n)=>(``+t+("You can use the `"+e+"` future flag to opt-in early. ")+(`For more information, see `+n+`.`),void 0);function Cn(e,t){e?.v7_startTransition===void 0&&Sn(`v7_startTransition`,"React Router will begin wrapping state updates in `React.startTransition` in v7",`https://reactrouter.com/v6/upgrading/future#v7_starttransition`),e?.v7_relativeSplatPath===void 0&&(!t||t.v7_relativeSplatPath===void 0)&&Sn(`v7_relativeSplatPath`,`Relative route resolution within Splat routes is changing in v7`,`https://reactrouter.com/v6/upgrading/future#v7_relativesplatpath`),t&&(t.v7_fetcherPersist===void 0&&Sn(`v7_fetcherPersist`,`The persistence behavior of fetchers is changing in v7`,`https://reactrouter.com/v6/upgrading/future#v7_fetcherpersist`),t.v7_normalizeFormMethod===void 0&&Sn(`v7_normalizeFormMethod`,"Casing of `formMethod` fields is being normalized to uppercase in v7",`https://reactrouter.com/v6/upgrading/future#v7_normalizeformmethod`),t.v7_partialHydration===void 0&&Sn(`v7_partialHydration`,"`RouterProvider` hydration behavior is changing in v7",`https://reactrouter.com/v6/upgrading/future#v7_partialhydration`),t.v7_skipActionErrorRevalidation===void 0&&Sn(`v7_skipActionErrorRevalidation`,"The revalidation behavior after 4xx/5xx `action` responses is changing in v7",`https://reactrouter.com/v6/upgrading/future#v7_skipactionerrorrevalidation`))}function wn(e){let{to:t,replace:n,state:r,relative:i}=e;!Xt()&&S(!1);let{future:a,static:o}=h.useContext(Gt),{matches:s}=h.useContext(qt),{pathname:c}=Zt(),l=$t(),u=we(t,Ce(s,a.v7_relativeSplatPath),c,i===`path`),d=JSON.stringify(u);return h.useEffect(()=>l(JSON.parse(d),{replace:n,state:r,relative:i}),[l,d,i,n,r]),null}function Tn(e){return nn(e.context)}function En(e){let{basename:t=`/`,children:n=null,location:r,navigationType:i=y.Pop,navigator:a,static:o=!1,future:s}=e;Xt()&&S(!1);let c=t.replace(/^\/*/,`/`),l=h.useMemo(()=>({basename:c,navigator:a,static:o,future:Ut({v7_relativeSplatPath:!1},s)}),[c,s,a,o]);typeof r==`string`&&(r=O(r));let{pathname:u=`/`,search:d=``,hash:f=``,state:p=null,key:m=`default`}=r,g=h.useMemo(()=>{let e=_e(u,c);return e==null?null:{location:{pathname:e,search:d,hash:f,state:p,key:m},navigationType:i}},[c,u,d,f,p,m,i]);return g==null?null:h.createElement(Gt.Provider,{value:l},h.createElement(Kt.Provider,{children:n,value:g}))}var Dn=function(e){return e[e.pending=0]=`pending`,e[e.success=1]=`success`,e[e.error=2]=`error`,e}(Dn||{});new Promise(()=>{}),h.Component;function On(e){let t={hasErrorBoundary:e.ErrorBoundary!=null||e.errorElement!=null};return e.Component&&Object.assign(t,{element:h.createElement(e.Component),Component:void 0}),e.HydrateFallback&&Object.assign(t,{hydrateFallbackElement:h.createElement(e.HydrateFallback),HydrateFallback:void 0}),e.ErrorBoundary&&Object.assign(t,{errorElement:h.createElement(e.ErrorBoundary),ErrorBoundary:void 0}),t}function kn(){return kn=Object.assign?Object.assign.bind():function(e){for(var t=1;t{this.resolve=t=>{this.status===`pending`&&(this.status=`resolved`,e(t))},this.reject=e=>{this.status===`pending`&&(this.status=`rejected`,t(e))}})}};function Gn(e){let{fallbackElement:t,router:n,future:r}=e,[i,a]=h.useState(n.state),[o,s]=h.useState(),[c,l]=h.useState({isTransitioning:!1}),[u,d]=h.useState(),[f,p]=h.useState(),[m,g]=h.useState(),_=h.useRef(new Map),{v7_startTransition:v}=r||{},y=h.useCallback(e=>{v?Hn(e):e()},[v]),b=h.useCallback((e,t)=>{let{deletedFetchers:r,flushSync:i,viewTransitionOpts:o}=t;e.fetchers.forEach((e,t)=>{e.data!==void 0&&_.current.set(t,e.data)}),r.forEach(e=>_.current.delete(e));let c=n.window==null||n.window.document==null||typeof n.window.document.startViewTransition!=`function`;if(!o||c){i?Un(()=>a(e)):y(()=>a(e));return}if(i){Un(()=>{f&&(u&&u.resolve(),f.skipTransition()),l({isTransitioning:!0,flushSync:!0,currentLocation:o.currentLocation,nextLocation:o.nextLocation})});let t=n.window.document.startViewTransition(()=>{Un(()=>a(e))});t.finished.finally(()=>{Un(()=>{d(void 0),p(void 0),s(void 0),l({isTransitioning:!1})})}),Un(()=>p(t));return}f?(u&&u.resolve(),f.skipTransition(),g({state:e,currentLocation:o.currentLocation,nextLocation:o.nextLocation})):(s(e),l({isTransitioning:!0,flushSync:!1,currentLocation:o.currentLocation,nextLocation:o.nextLocation}))},[n.window,f,u,_,y]);h.useLayoutEffect(()=>n.subscribe(b),[n,b]),h.useEffect(()=>{c.isTransitioning&&!c.flushSync&&d(new Wn)},[c]),h.useEffect(()=>{if(u&&o&&n.window){let e=o,t=u.promise,r=n.window.document.startViewTransition(async()=>{y(()=>a(e)),await t});r.finished.finally(()=>{d(void 0),p(void 0),s(void 0),l({isTransitioning:!1})}),p(r)}},[y,o,u,n.window]),h.useEffect(()=>{u&&o&&i.location.key===o.location.key&&u.resolve()},[u,f,i.location,o]),h.useEffect(()=>{!c.isTransitioning&&m&&(s(m.state),l({isTransitioning:!0,flushSync:!1,currentLocation:m.currentLocation,nextLocation:m.nextLocation}),g(void 0))},[c.isTransitioning,m]),h.useEffect(()=>{},[]);let x=h.useMemo(()=>({createHref:n.createHref,encodeLocation:n.encodeLocation,go:e=>n.navigate(e),push:(e,t,r)=>n.navigate(e,{state:t,preventScrollReset:r?.preventScrollReset}),replace:(e,t,r)=>n.navigate(e,{replace:!0,state:t,preventScrollReset:r?.preventScrollReset})}),[n]),S=n.basename||`/`,C=h.useMemo(()=>({router:n,navigator:x,static:!1,basename:S}),[n,x,S]),w=h.useMemo(()=>({v7_relativeSplatPath:n.future.v7_relativeSplatPath}),[n.future.v7_relativeSplatPath]);return h.useEffect(()=>Cn(r,n.future),[r,n.future]),h.createElement(h.Fragment,null,h.createElement(R.Provider,{value:C},h.createElement(Wt.Provider,{value:i},h.createElement(zn.Provider,{value:_.current},h.createElement(Rn.Provider,{value:c},h.createElement(En,{basename:S,location:i.location,navigationType:i.historyAction,navigator:x,future:w},i.initialized||n.future.v7_partialHydration?h.createElement(Kn,{routes:n.routes,future:n.future,state:i}):t))))),null)}var Kn=h.memo(qn);function qn(e){let{routes:t,future:n,state:r}=e;return on(t,void 0,r,n)}var Jn=typeof window<`u`&&window.document!==void 0&&window.document.createElement!==void 0,Yn=/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i,Xn=h.forwardRef(function(e,t){let{onClick:n,relative:r,reloadDocument:i,replace:a,state:o,target:s,to:c,preventScrollReset:l,viewTransition:u}=e,d=An(e,Nn),{basename:f}=h.useContext(Gt),p,m=!1;if(typeof c==`string`&&Yn.test(c)&&(p=c,Jn))try{let e=new URL(window.location.href),t=c.startsWith(`//`)?new URL(e.protocol+c):new URL(c),n=_e(t.pathname,f);t.origin===e.origin&&n!=null?c=n+t.search+t.hash:m=!0}catch{}let g=Yt(c,{relative:r}),_=$n(c,{replace:a,state:o,target:s,preventScrollReset:l,relative:r,viewTransition:u});function v(e){n&&n(e),e.defaultPrevented||_(e)}return h.createElement(`a`,kn({},d,{href:p||g,onClick:m||i?n:v,ref:t,target:s}))}),Zn;(function(e){e.UseScrollRestoration=`useScrollRestoration`,e.UseSubmit=`useSubmit`,e.UseSubmitFetcher=`useSubmitFetcher`,e.UseFetcher=`useFetcher`,e.useViewTransitionState=`useViewTransitionState`})(Zn||={});var Qn;(function(e){e.UseFetcher=`useFetcher`,e.UseFetchers=`useFetchers`,e.UseScrollRestoration=`useScrollRestoration`})(Qn||={});function $n(e,t){let{target:n,replace:r,state:i,preventScrollReset:a,relative:o,viewTransition:s}=t===void 0?{}:t,c=$t(),l=Zt(),u=an(e,{relative:o});return h.useCallback(t=>{if(Mn(t,n)){t.preventDefault();let n=r===void 0?D(l)===D(u):r;c(e,{replace:n,state:i,preventScrollReset:a,relative:o,viewTransition:s})}},[l,c,u,r,i,n,e,a,o,s])}var er=e=>{switch(e){case`success`:return rr;case`info`:return ar;case`warning`:return ir;case`error`:return or;default:return null}},tr=Array(12).fill(0),nr=({visible:e,className:t})=>h.createElement(`div`,{className:[`sonner-loading-wrapper`,t].filter(Boolean).join(` `),"data-visible":e},h.createElement(`div`,{className:`sonner-spinner`},tr.map((e,t)=>h.createElement(`div`,{className:`sonner-loading-bar`,key:`spinner-bar-${t}`})))),rr=h.createElement(`svg`,{xmlns:`http://www.w3.org/2000/svg`,viewBox:`0 0 20 20`,fill:`currentColor`,height:`20`,width:`20`},h.createElement(`path`,{fillRule:`evenodd`,d:`M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z`,clipRule:`evenodd`})),ir=h.createElement(`svg`,{xmlns:`http://www.w3.org/2000/svg`,viewBox:`0 0 24 24`,fill:`currentColor`,height:`20`,width:`20`},h.createElement(`path`,{fillRule:`evenodd`,d:`M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355 12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309 0-3.752-2.5-2.598-4.5L9.4 3.003zM12 8.25a.75.75 0 01.75.75v3.75a.75.75 0 01-1.5 0V9a.75.75 0 01.75-.75zm0 8.25a.75.75 0 100-1.5.75.75 0 000 1.5z`,clipRule:`evenodd`})),ar=h.createElement(`svg`,{xmlns:`http://www.w3.org/2000/svg`,viewBox:`0 0 20 20`,fill:`currentColor`,height:`20`,width:`20`},h.createElement(`path`,{fillRule:`evenodd`,d:`M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z`,clipRule:`evenodd`})),or=h.createElement(`svg`,{xmlns:`http://www.w3.org/2000/svg`,viewBox:`0 0 20 20`,fill:`currentColor`,height:`20`,width:`20`},h.createElement(`path`,{fillRule:`evenodd`,d:`M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-5a.75.75 0 01.75.75v4.5a.75.75 0 01-1.5 0v-4.5A.75.75 0 0110 5zm0 10a1 1 0 100-2 1 1 0 000 2z`,clipRule:`evenodd`})),sr=h.createElement(`svg`,{xmlns:`http://www.w3.org/2000/svg`,width:`12`,height:`12`,viewBox:`0 0 24 24`,fill:`none`,stroke:`currentColor`,strokeWidth:`1.5`,strokeLinecap:`round`,strokeLinejoin:`round`},h.createElement(`line`,{x1:`18`,y1:`6`,x2:`6`,y2:`18`}),h.createElement(`line`,{x1:`6`,y1:`6`,x2:`18`,y2:`18`})),cr=()=>{let[e,t]=h.useState(document.hidden);return h.useEffect(()=>{let e=()=>{t(document.hidden)};return document.addEventListener(`visibilitychange`,e),()=>window.removeEventListener(`visibilitychange`,e)},[]),e},lr=1,ur=new class{constructor(){this.subscribe=e=>(this.subscribers.push(e),()=>{let t=this.subscribers.indexOf(e);this.subscribers.splice(t,1)}),this.publish=e=>{this.subscribers.forEach(t=>t(e))},this.addToast=e=>{this.publish(e),this.toasts=[...this.toasts,e]},this.create=e=>{let{message:t,...n}=e,r=typeof e?.id==`number`||e.id?.length>0?e.id:lr++,i=this.toasts.find(e=>e.id===r),a=e.dismissible===void 0||e.dismissible;return this.dismissedToasts.has(r)&&this.dismissedToasts.delete(r),i?this.toasts=this.toasts.map(n=>n.id===r?(this.publish({...n,...e,id:r,title:t}),{...n,...e,id:r,dismissible:a,title:t}):n):this.addToast({title:t,...n,dismissible:a,id:r}),r},this.dismiss=e=>(this.dismissedToasts.add(e),e||this.toasts.forEach(e=>{this.subscribers.forEach(t=>t({id:e.id,dismiss:!0}))}),this.subscribers.forEach(t=>t({id:e,dismiss:!0})),e),this.message=(e,t)=>this.create({...t,message:e}),this.error=(e,t)=>this.create({...t,message:e,type:`error`}),this.success=(e,t)=>this.create({...t,type:`success`,message:e}),this.info=(e,t)=>this.create({...t,type:`info`,message:e}),this.warning=(e,t)=>this.create({...t,type:`warning`,message:e}),this.loading=(e,t)=>this.create({...t,type:`loading`,message:e}),this.promise=(e,t)=>{if(!t)return;let n;t.loading!==void 0&&(n=this.create({...t,promise:e,type:`loading`,message:t.loading,description:typeof t.description==`function`?void 0:t.description}));let r=e instanceof Promise?e:e(),i=n!==void 0,a,o=r.then(async e=>{if(a=[`resolve`,e],h.isValidElement(e))i=!1,this.create({id:n,type:`default`,message:e});else if(fr(e)&&!e.ok){i=!1;let r=typeof t.error==`function`?await t.error(`HTTP error! status: ${e.status}`):t.error,a=typeof t.description==`function`?await t.description(`HTTP error! status: ${e.status}`):t.description;this.create({id:n,type:`error`,message:r,description:a})}else if(t.success!==void 0){i=!1;let r=typeof t.success==`function`?await t.success(e):t.success,a=typeof t.description==`function`?await t.description(e):t.description;this.create({id:n,type:`success`,message:r,description:a})}}).catch(async e=>{if(a=[`reject`,e],t.error!==void 0){i=!1;let r=typeof t.error==`function`?await t.error(e):t.error,a=typeof t.description==`function`?await t.description(e):t.description;this.create({id:n,type:`error`,message:r,description:a})}}).finally(()=>{var e;i&&(this.dismiss(n),n=void 0),(e=t.finally)==null||e.call(t)}),s=()=>new Promise((e,t)=>o.then(()=>a[0]===`reject`?t(a[1]):e(a[1])).catch(t));return typeof n!=`string`&&typeof n!=`number`?{unwrap:s}:Object.assign(n,{unwrap:s})},this.custom=(e,t)=>{let n=t?.id||lr++;return this.create({jsx:e(n),id:n,...t}),n},this.getActiveToasts=()=>this.toasts.filter(e=>!this.dismissedToasts.has(e.id)),this.subscribers=[],this.toasts=[],this.dismissedToasts=new Set}},dr=(e,t)=>{let n=t?.id||lr++;return ur.addToast({title:e,...t,id:n}),n},fr=e=>e&&typeof e==`object`&&`ok`in e&&typeof e.ok==`boolean`&&`status`in e&&typeof e.status==`number`,pr=Object.assign(dr,{success:ur.success,info:ur.info,warning:ur.warning,error:ur.error,custom:ur.custom,message:ur.message,promise:ur.promise,dismiss:ur.dismiss,loading:ur.loading},{getHistory:()=>ur.toasts,getToasts:()=>ur.getActiveToasts()});function mr(e,{insertAt:t}={}){if(!e||typeof document>`u`)return;let n=document.head||document.getElementsByTagName(`head`)[0],r=document.createElement(`style`);r.type=`text/css`,t===`top`&&n.firstChild?n.insertBefore(r,n.firstChild):n.appendChild(r),r.styleSheet?r.styleSheet.cssText=e:r.appendChild(document.createTextNode(e))}mr(`:where(html[dir="ltr"]),:where([data-sonner-toaster][dir="ltr"]){--toast-icon-margin-start: -3px;--toast-icon-margin-end: 4px;--toast-svg-margin-start: -1px;--toast-svg-margin-end: 0px;--toast-button-margin-start: auto;--toast-button-margin-end: 0;--toast-close-button-start: 0;--toast-close-button-end: unset;--toast-close-button-transform: translate(-35%, -35%)}:where(html[dir="rtl"]),:where([data-sonner-toaster][dir="rtl"]){--toast-icon-margin-start: 4px;--toast-icon-margin-end: -3px;--toast-svg-margin-start: 0px;--toast-svg-margin-end: -1px;--toast-button-margin-start: 0;--toast-button-margin-end: auto;--toast-close-button-start: unset;--toast-close-button-end: 0;--toast-close-button-transform: translate(35%, -35%)}:where([data-sonner-toaster]){position:fixed;width:var(--width);font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,Noto Sans,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;--gray1: hsl(0, 0%, 99%);--gray2: hsl(0, 0%, 97.3%);--gray3: hsl(0, 0%, 95.1%);--gray4: hsl(0, 0%, 93%);--gray5: hsl(0, 0%, 90.9%);--gray6: hsl(0, 0%, 88.7%);--gray7: hsl(0, 0%, 85.8%);--gray8: hsl(0, 0%, 78%);--gray9: hsl(0, 0%, 56.1%);--gray10: hsl(0, 0%, 52.3%);--gray11: hsl(0, 0%, 43.5%);--gray12: hsl(0, 0%, 9%);--border-radius: 8px;box-sizing:border-box;padding:0;margin:0;list-style:none;outline:none;z-index:999999999;transition:transform .4s ease}:where([data-sonner-toaster][data-lifted="true"]){transform:translateY(-10px)}@media (hover: none) and (pointer: coarse){:where([data-sonner-toaster][data-lifted="true"]){transform:none}}:where([data-sonner-toaster][data-x-position="right"]){right:var(--offset-right)}:where([data-sonner-toaster][data-x-position="left"]){left:var(--offset-left)}:where([data-sonner-toaster][data-x-position="center"]){left:50%;transform:translate(-50%)}:where([data-sonner-toaster][data-y-position="top"]){top:var(--offset-top)}:where([data-sonner-toaster][data-y-position="bottom"]){bottom:var(--offset-bottom)}:where([data-sonner-toast]){--y: translateY(100%);--lift-amount: calc(var(--lift) * var(--gap));z-index:var(--z-index);position:absolute;opacity:0;transform:var(--y);filter:blur(0);touch-action:none;transition:transform .4s,opacity .4s,height .4s,box-shadow .2s;box-sizing:border-box;outline:none;overflow-wrap:anywhere}:where([data-sonner-toast][data-styled="true"]){padding:16px;background:var(--normal-bg);border:1px solid var(--normal-border);color:var(--normal-text);border-radius:var(--border-radius);box-shadow:0 4px 12px #0000001a;width:var(--width);font-size:13px;display:flex;align-items:center;gap:6px}:where([data-sonner-toast]:focus-visible){box-shadow:0 4px 12px #0000001a,0 0 0 2px #0003}:where([data-sonner-toast][data-y-position="top"]){top:0;--y: translateY(-100%);--lift: 1;--lift-amount: calc(1 * var(--gap))}:where([data-sonner-toast][data-y-position="bottom"]){bottom:0;--y: translateY(100%);--lift: -1;--lift-amount: calc(var(--lift) * var(--gap))}:where([data-sonner-toast]) :where([data-description]){font-weight:400;line-height:1.4;color:inherit}:where([data-sonner-toast]) :where([data-title]){font-weight:500;line-height:1.5;color:inherit}:where([data-sonner-toast]) :where([data-icon]){display:flex;height:16px;width:16px;position:relative;justify-content:flex-start;align-items:center;flex-shrink:0;margin-left:var(--toast-icon-margin-start);margin-right:var(--toast-icon-margin-end)}:where([data-sonner-toast][data-promise="true"]) :where([data-icon])>svg{opacity:0;transform:scale(.8);transform-origin:center;animation:sonner-fade-in .3s ease forwards}:where([data-sonner-toast]) :where([data-icon])>*{flex-shrink:0}:where([data-sonner-toast]) :where([data-icon]) svg{margin-left:var(--toast-svg-margin-start);margin-right:var(--toast-svg-margin-end)}:where([data-sonner-toast]) :where([data-content]){display:flex;flex-direction:column;gap:2px}[data-sonner-toast][data-styled=true] [data-button]{border-radius:4px;padding-left:8px;padding-right:8px;height:24px;font-size:12px;color:var(--normal-bg);background:var(--normal-text);margin-left:var(--toast-button-margin-start);margin-right:var(--toast-button-margin-end);border:none;cursor:pointer;outline:none;display:flex;align-items:center;flex-shrink:0;transition:opacity .4s,box-shadow .2s}:where([data-sonner-toast]) :where([data-button]):focus-visible{box-shadow:0 0 0 2px #0006}:where([data-sonner-toast]) :where([data-button]):first-of-type{margin-left:var(--toast-button-margin-start);margin-right:var(--toast-button-margin-end)}:where([data-sonner-toast]) :where([data-cancel]){color:var(--normal-text);background:rgba(0,0,0,.08)}:where([data-sonner-toast][data-theme="dark"]) :where([data-cancel]){background:rgba(255,255,255,.3)}:where([data-sonner-toast]) :where([data-close-button]){position:absolute;left:var(--toast-close-button-start);right:var(--toast-close-button-end);top:0;height:20px;width:20px;display:flex;justify-content:center;align-items:center;padding:0;color:var(--gray12);border:1px solid var(--gray4);transform:var(--toast-close-button-transform);border-radius:50%;cursor:pointer;z-index:1;transition:opacity .1s,background .2s,border-color .2s}[data-sonner-toast] [data-close-button]{background:var(--gray1)}:where([data-sonner-toast]) :where([data-close-button]):focus-visible{box-shadow:0 4px 12px #0000001a,0 0 0 2px #0003}:where([data-sonner-toast]) :where([data-disabled="true"]){cursor:not-allowed}:where([data-sonner-toast]):hover :where([data-close-button]):hover{background:var(--gray2);border-color:var(--gray5)}:where([data-sonner-toast][data-swiping="true"]):before{content:"";position:absolute;left:-50%;right:-50%;height:100%;z-index:-1}:where([data-sonner-toast][data-y-position="top"][data-swiping="true"]):before{bottom:50%;transform:scaleY(3) translateY(50%)}:where([data-sonner-toast][data-y-position="bottom"][data-swiping="true"]):before{top:50%;transform:scaleY(3) translateY(-50%)}:where([data-sonner-toast][data-swiping="false"][data-removed="true"]):before{content:"";position:absolute;inset:0;transform:scaleY(2)}:where([data-sonner-toast]):after{content:"";position:absolute;left:0;height:calc(var(--gap) + 1px);bottom:100%;width:100%}:where([data-sonner-toast][data-mounted="true"]){--y: translateY(0);opacity:1}:where([data-sonner-toast][data-expanded="false"][data-front="false"]){--scale: var(--toasts-before) * .05 + 1;--y: translateY(calc(var(--lift-amount) * var(--toasts-before))) scale(calc(-1 * var(--scale)));height:var(--front-toast-height)}:where([data-sonner-toast])>*{transition:opacity .4s}:where([data-sonner-toast][data-expanded="false"][data-front="false"][data-styled="true"])>*{opacity:0}:where([data-sonner-toast][data-visible="false"]){opacity:0;pointer-events:none}:where([data-sonner-toast][data-mounted="true"][data-expanded="true"]){--y: translateY(calc(var(--lift) * var(--offset)));height:var(--initial-height)}:where([data-sonner-toast][data-removed="true"][data-front="true"][data-swipe-out="false"]){--y: translateY(calc(var(--lift) * -100%));opacity:0}:where([data-sonner-toast][data-removed="true"][data-front="false"][data-swipe-out="false"][data-expanded="true"]){--y: translateY(calc(var(--lift) * var(--offset) + var(--lift) * -100%));opacity:0}:where([data-sonner-toast][data-removed="true"][data-front="false"][data-swipe-out="false"][data-expanded="false"]){--y: translateY(40%);opacity:0;transition:transform .5s,opacity .2s}:where([data-sonner-toast][data-removed="true"][data-front="false"]):before{height:calc(var(--initial-height) + 20%)}[data-sonner-toast][data-swiping=true]{transform:var(--y) translateY(var(--swipe-amount-y, 0px)) translate(var(--swipe-amount-x, 0px));transition:none}[data-sonner-toast][data-swiped=true]{user-select:none}[data-sonner-toast][data-swipe-out=true][data-y-position=bottom],[data-sonner-toast][data-swipe-out=true][data-y-position=top]{animation-duration:.2s;animation-timing-function:ease-out;animation-fill-mode:forwards}[data-sonner-toast][data-swipe-out=true][data-swipe-direction=left]{animation-name:swipe-out-left}[data-sonner-toast][data-swipe-out=true][data-swipe-direction=right]{animation-name:swipe-out-right}[data-sonner-toast][data-swipe-out=true][data-swipe-direction=up]{animation-name:swipe-out-up}[data-sonner-toast][data-swipe-out=true][data-swipe-direction=down]{animation-name:swipe-out-down}@keyframes swipe-out-left{0%{transform:var(--y) translate(var(--swipe-amount-x));opacity:1}to{transform:var(--y) translate(calc(var(--swipe-amount-x) - 100%));opacity:0}}@keyframes swipe-out-right{0%{transform:var(--y) translate(var(--swipe-amount-x));opacity:1}to{transform:var(--y) translate(calc(var(--swipe-amount-x) + 100%));opacity:0}}@keyframes swipe-out-up{0%{transform:var(--y) translateY(var(--swipe-amount-y));opacity:1}to{transform:var(--y) translateY(calc(var(--swipe-amount-y) - 100%));opacity:0}}@keyframes swipe-out-down{0%{transform:var(--y) translateY(var(--swipe-amount-y));opacity:1}to{transform:var(--y) translateY(calc(var(--swipe-amount-y) + 100%));opacity:0}}@media (max-width: 600px){[data-sonner-toaster]{position:fixed;right:var(--mobile-offset-right);left:var(--mobile-offset-left);width:100%}[data-sonner-toaster][dir=rtl]{left:calc(var(--mobile-offset-left) * -1)}[data-sonner-toaster] [data-sonner-toast]{left:0;right:0;width:calc(100% - var(--mobile-offset-left) * 2)}[data-sonner-toaster][data-x-position=left]{left:var(--mobile-offset-left)}[data-sonner-toaster][data-y-position=bottom]{bottom:var(--mobile-offset-bottom)}[data-sonner-toaster][data-y-position=top]{top:var(--mobile-offset-top)}[data-sonner-toaster][data-x-position=center]{left:var(--mobile-offset-left);right:var(--mobile-offset-right);transform:none}}[data-sonner-toaster][data-theme=light]{--normal-bg: #fff;--normal-border: var(--gray4);--normal-text: var(--gray12);--success-bg: hsl(143, 85%, 96%);--success-border: hsl(145, 92%, 91%);--success-text: hsl(140, 100%, 27%);--info-bg: hsl(208, 100%, 97%);--info-border: hsl(221, 91%, 91%);--info-text: hsl(210, 92%, 45%);--warning-bg: hsl(49, 100%, 97%);--warning-border: hsl(49, 91%, 91%);--warning-text: hsl(31, 92%, 45%);--error-bg: hsl(359, 100%, 97%);--error-border: hsl(359, 100%, 94%);--error-text: hsl(360, 100%, 45%)}[data-sonner-toaster][data-theme=light] [data-sonner-toast][data-invert=true]{--normal-bg: #000;--normal-border: hsl(0, 0%, 20%);--normal-text: var(--gray1)}[data-sonner-toaster][data-theme=dark] [data-sonner-toast][data-invert=true]{--normal-bg: #fff;--normal-border: var(--gray3);--normal-text: var(--gray12)}[data-sonner-toaster][data-theme=dark]{--normal-bg: #000;--normal-bg-hover: hsl(0, 0%, 12%);--normal-border: hsl(0, 0%, 20%);--normal-border-hover: hsl(0, 0%, 25%);--normal-text: var(--gray1);--success-bg: hsl(150, 100%, 6%);--success-border: hsl(147, 100%, 12%);--success-text: hsl(150, 86%, 65%);--info-bg: hsl(215, 100%, 6%);--info-border: hsl(223, 100%, 12%);--info-text: hsl(216, 87%, 65%);--warning-bg: hsl(64, 100%, 6%);--warning-border: hsl(60, 100%, 12%);--warning-text: hsl(46, 87%, 65%);--error-bg: hsl(358, 76%, 10%);--error-border: hsl(357, 89%, 16%);--error-text: hsl(358, 100%, 81%)}[data-sonner-toaster][data-theme=dark] [data-sonner-toast] [data-close-button]{background:var(--normal-bg);border-color:var(--normal-border);color:var(--normal-text)}[data-sonner-toaster][data-theme=dark] [data-sonner-toast] [data-close-button]:hover{background:var(--normal-bg-hover);border-color:var(--normal-border-hover)}[data-rich-colors=true][data-sonner-toast][data-type=success],[data-rich-colors=true][data-sonner-toast][data-type=success] [data-close-button]{background:var(--success-bg);border-color:var(--success-border);color:var(--success-text)}[data-rich-colors=true][data-sonner-toast][data-type=info],[data-rich-colors=true][data-sonner-toast][data-type=info] [data-close-button]{background:var(--info-bg);border-color:var(--info-border);color:var(--info-text)}[data-rich-colors=true][data-sonner-toast][data-type=warning],[data-rich-colors=true][data-sonner-toast][data-type=warning] [data-close-button]{background:var(--warning-bg);border-color:var(--warning-border);color:var(--warning-text)}[data-rich-colors=true][data-sonner-toast][data-type=error],[data-rich-colors=true][data-sonner-toast][data-type=error] [data-close-button]{background:var(--error-bg);border-color:var(--error-border);color:var(--error-text)}.sonner-loading-wrapper{--size: 16px;height:var(--size);width:var(--size);position:absolute;inset:0;z-index:10}.sonner-loading-wrapper[data-visible=false]{transform-origin:center;animation:sonner-fade-out .2s ease forwards}.sonner-spinner{position:relative;top:50%;left:50%;height:var(--size);width:var(--size)}.sonner-loading-bar{animation:sonner-spin 1.2s linear infinite;background:var(--gray11);border-radius:6px;height:8%;left:-10%;position:absolute;top:-3.9%;width:24%}.sonner-loading-bar:nth-child(1){animation-delay:-1.2s;transform:rotate(.0001deg) translate(146%)}.sonner-loading-bar:nth-child(2){animation-delay:-1.1s;transform:rotate(30deg) translate(146%)}.sonner-loading-bar:nth-child(3){animation-delay:-1s;transform:rotate(60deg) translate(146%)}.sonner-loading-bar:nth-child(4){animation-delay:-.9s;transform:rotate(90deg) translate(146%)}.sonner-loading-bar:nth-child(5){animation-delay:-.8s;transform:rotate(120deg) translate(146%)}.sonner-loading-bar:nth-child(6){animation-delay:-.7s;transform:rotate(150deg) translate(146%)}.sonner-loading-bar:nth-child(7){animation-delay:-.6s;transform:rotate(180deg) translate(146%)}.sonner-loading-bar:nth-child(8){animation-delay:-.5s;transform:rotate(210deg) translate(146%)}.sonner-loading-bar:nth-child(9){animation-delay:-.4s;transform:rotate(240deg) translate(146%)}.sonner-loading-bar:nth-child(10){animation-delay:-.3s;transform:rotate(270deg) translate(146%)}.sonner-loading-bar:nth-child(11){animation-delay:-.2s;transform:rotate(300deg) translate(146%)}.sonner-loading-bar:nth-child(12){animation-delay:-.1s;transform:rotate(330deg) translate(146%)}@keyframes sonner-fade-in{0%{opacity:0;transform:scale(.8)}to{opacity:1;transform:scale(1)}}@keyframes sonner-fade-out{0%{opacity:1;transform:scale(1)}to{opacity:0;transform:scale(.8)}}@keyframes sonner-spin{0%{opacity:1}to{opacity:.15}}@media (prefers-reduced-motion){[data-sonner-toast],[data-sonner-toast]>*,.sonner-loading-bar{transition:none!important;animation:none!important}}.sonner-loader{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);transform-origin:center;transition:opacity .2s,transform .2s}.sonner-loader[data-visible=false]{opacity:0;transform:scale(.8) translate(-50%,-50%)} -`);function hr(e){return e.label!==void 0}var gr=3,_r=`32px`,vr=`16px`,yr=4e3,br=356,xr=14,Sr=20,Cr=200;function wr(...e){return e.filter(Boolean).join(` `)}function Tr(e){let[t,n]=e.split(`-`),r=[];return t&&r.push(t),n&&r.push(n),r}var Er=e=>{let{invert:t,toast:n,unstyled:r,interacting:i,setHeights:a,visibleToasts:o,heights:s,index:c,toasts:l,expanded:u,removeToast:d,defaultRichColors:f,closeButton:p,style:m,cancelButtonStyle:g,actionButtonStyle:_,className:v=``,descriptionClassName:y=``,duration:b,position:x,gap:S,loadingIcon:C,expandByDefault:w,classNames:T,icons:E,closeButtonAriaLabel:D=`Close toast`,pauseWhenPageIsHidden:O}=e,[k,A]=h.useState(null),[ee,j]=h.useState(null),[te,ne]=h.useState(!1),[M,re]=h.useState(!1),[ie,ae]=h.useState(!1),[N,oe]=h.useState(!1),[se,ce]=h.useState(!1),[le,P]=h.useState(0),[ue,de]=h.useState(0),fe=h.useRef(n.duration||b||yr),pe=h.useRef(null),me=h.useRef(null),he=c===0,ge=c+1<=o,F=n.type,_e=n.dismissible!==!1,ve=n.className||``,ye=n.descriptionClassName||``,be=h.useMemo(()=>s.findIndex(e=>e.toastId===n.id)||0,[s,n.id]),xe=h.useMemo(()=>n.closeButton??p,[n.closeButton,p]),Se=h.useMemo(()=>n.duration||b||yr,[n.duration,b]),I=h.useRef(0),Ce=h.useRef(0),we=h.useRef(0),Te=h.useRef(null),[Ee,De]=x.split(`-`),Oe=h.useMemo(()=>s.reduce((e,t,n)=>n>=be?e:e+t.height,0),[s,be]),ke=cr(),Ae=n.invert||t,je=F===`loading`;Ce.current=h.useMemo(()=>be*S+Oe,[be,Oe]),h.useEffect(()=>{fe.current=Se},[Se]),h.useEffect(()=>{ne(!0)},[]),h.useEffect(()=>{let e=me.current;if(e){let t=e.getBoundingClientRect().height;return de(t),a(e=>[{toastId:n.id,height:t,position:n.position},...e]),()=>a(e=>e.filter(e=>e.toastId!==n.id))}},[a,n.id]),h.useLayoutEffect(()=>{if(!te)return;let e=me.current,t=e.style.height;e.style.height=`auto`;let r=e.getBoundingClientRect().height;e.style.height=t,de(r),a(e=>e.find(e=>e.toastId===n.id)?e.map(e=>e.toastId===n.id?{...e,height:r}:e):[{toastId:n.id,height:r,position:n.position},...e])},[te,n.title,n.description,a,n.id]);let Me=h.useCallback(()=>{re(!0),P(Ce.current),a(e=>e.filter(e=>e.toastId!==n.id)),setTimeout(()=>{d(n)},Cr)},[n,d,a,Ce]);h.useEffect(()=>{if(n.promise&&F===`loading`||n.duration===1/0||n.type===`loading`)return;let e;return u||i||O&&ke?(()=>{if(we.current{var e;(e=n.onAutoClose)==null||e.call(n,n),Me()},fe.current)),()=>clearTimeout(e)},[u,i,n,F,O,ke,Me]),h.useEffect(()=>{n.delete&&Me()},[Me,n.delete]);function Ne(){return E!=null&&E.loading?h.createElement(`div`,{className:wr(T?.loader,n?.classNames?.loader,`sonner-loader`),"data-visible":F===`loading`},E.loading):C?h.createElement(`div`,{className:wr(T?.loader,n?.classNames?.loader,`sonner-loader`),"data-visible":F===`loading`},C):h.createElement(nr,{className:wr(T?.loader,n?.classNames?.loader),visible:F===`loading`})}return h.createElement(`li`,{tabIndex:0,ref:me,className:wr(v,ve,T?.toast,n?.classNames?.toast,T?.default,T?.[F],n?.classNames?.[F]),"data-sonner-toast":``,"data-rich-colors":n.richColors??f,"data-styled":!(n.jsx||n.unstyled||r),"data-mounted":te,"data-promise":!!n.promise,"data-swiped":se,"data-removed":M,"data-visible":ge,"data-y-position":Ee,"data-x-position":De,"data-index":c,"data-front":he,"data-swiping":ie,"data-dismissible":_e,"data-type":F,"data-invert":Ae,"data-swipe-out":N,"data-swipe-direction":ee,"data-expanded":!!(u||w&&te),style:{"--index":c,"--toasts-before":c,"--z-index":l.length-c,"--offset":`${M?le:Ce.current}px`,"--initial-height":w?`auto`:`${ue}px`,...m,...n.style},onDragEnd:()=>{ae(!1),A(null),Te.current=null},onPointerDown:e=>{je||!_e||(pe.current=new Date,P(Ce.current),e.target.setPointerCapture(e.pointerId),e.target.tagName!==`BUTTON`&&(ae(!0),Te.current={x:e.clientX,y:e.clientY}))},onPointerUp:()=>{var e;if(N||!_e)return;Te.current=null;let t=Number(me.current?.style.getPropertyValue(`--swipe-amount-x`).replace(`px`,``)||0),r=Number(me.current?.style.getPropertyValue(`--swipe-amount-y`).replace(`px`,``)||0),i=new Date().getTime()-pe.current?.getTime(),a=k===`x`?t:r,o=Math.abs(a)/i;if(Math.abs(a)>=Sr||o>.11){P(Ce.current),(e=n.onDismiss)==null||e.call(n,n),j(k===`x`?t>0?`right`:`left`:r>0?`down`:`up`),Me(),oe(!0),ce(!1);return}ae(!1),A(null)},onPointerMove:t=>{var n,r;if(!Te.current||!_e||window.getSelection()?.toString().length>0)return;let i=t.clientY-Te.current.y,a=t.clientX-Te.current.x,o=e.swipeDirections??Tr(x);!k&&(Math.abs(a)>1||Math.abs(i)>1)&&A(Math.abs(a)>Math.abs(i)?`x`:`y`);let s={x:0,y:0};k===`y`?(o.includes(`top`)||o.includes(`bottom`))&&(o.includes(`top`)&&i<0||o.includes(`bottom`)&&i>0)&&(s.y=i):k===`x`&&(o.includes(`left`)||o.includes(`right`))&&(o.includes(`left`)&&a<0||o.includes(`right`)&&a>0)&&(s.x=a),(Math.abs(s.x)>0||Math.abs(s.y)>0)&&ce(!0),(n=me.current)==null||n.style.setProperty(`--swipe-amount-x`,`${s.x}px`),(r=me.current)==null||r.style.setProperty(`--swipe-amount-y`,`${s.y}px`)}},xe&&!n.jsx?h.createElement(`button`,{"aria-label":D,"data-disabled":je,"data-close-button":!0,onClick:je||!_e?()=>{}:()=>{var e;Me(),(e=n.onDismiss)==null||e.call(n,n)},className:wr(T?.closeButton,n?.classNames?.closeButton)},E?.close??sr):null,n.jsx||(0,h.isValidElement)(n.title)?n.jsx?n.jsx:typeof n.title==`function`?n.title():n.title:h.createElement(h.Fragment,null,F||n.icon||n.promise?h.createElement(`div`,{"data-icon":``,className:wr(T?.icon,n?.classNames?.icon)},n.promise||n.type===`loading`&&!n.icon?n.icon||Ne():null,n.type===`loading`?null:n.icon||E?.[F]||er(F)):null,h.createElement(`div`,{"data-content":``,className:wr(T?.content,n?.classNames?.content)},h.createElement(`div`,{"data-title":``,className:wr(T?.title,n?.classNames?.title)},typeof n.title==`function`?n.title():n.title),n.description?h.createElement(`div`,{"data-description":``,className:wr(y,ye,T?.description,n?.classNames?.description)},typeof n.description==`function`?n.description():n.description):null),(0,h.isValidElement)(n.cancel)?n.cancel:n.cancel&&hr(n.cancel)?h.createElement(`button`,{"data-button":!0,"data-cancel":!0,style:n.cancelButtonStyle||g,onClick:e=>{var t,r;hr(n.cancel)&&_e&&((r=(t=n.cancel).onClick)==null||r.call(t,e),Me())},className:wr(T?.cancelButton,n?.classNames?.cancelButton)},n.cancel.label):null,(0,h.isValidElement)(n.action)?n.action:n.action&&hr(n.action)?h.createElement(`button`,{"data-button":!0,"data-action":!0,style:n.actionButtonStyle||_,onClick:e=>{var t,r;hr(n.action)&&((r=(t=n.action).onClick)==null||r.call(t,e),!e.defaultPrevented&&Me())},className:wr(T?.actionButton,n?.classNames?.actionButton)},n.action.label):null))};function Dr(){if(typeof window>`u`||typeof document>`u`)return`ltr`;let e=document.documentElement.getAttribute(`dir`);return e===`auto`||!e?window.getComputedStyle(document.documentElement).direction:e}function Or(e,t){let n={};return[e,t].forEach((e,t)=>{let r=t===1,i=r?`--mobile-offset`:`--offset`,a=r?vr:_r;function o(e){[`top`,`right`,`bottom`,`left`].forEach(t=>{n[`${i}-${t}`]=typeof e==`number`?`${e}px`:e})}typeof e==`number`||typeof e==`string`?o(e):typeof e==`object`?[`top`,`right`,`bottom`,`left`].forEach(t=>{e[t]===void 0?n[`${i}-${t}`]=a:n[`${i}-${t}`]=typeof e[t]==`number`?`${e[t]}px`:e[t]}):o(a)}),n}var kr=(0,h.forwardRef)(function(e,t){let{invert:n,position:r=`bottom-right`,hotkey:i=[`altKey`,`KeyT`],expand:a,closeButton:o,className:s,offset:c,mobileOffset:l,theme:u=`light`,richColors:d,duration:f,style:p,visibleToasts:m=gr,toastOptions:_,dir:v=Dr(),gap:y=xr,loadingIcon:b,icons:x,containerAriaLabel:S=`Notifications`,pauseWhenPageIsHidden:C}=e,[w,T]=h.useState([]),E=h.useMemo(()=>Array.from(new Set([r].concat(w.filter(e=>e.position).map(e=>e.position)))),[w,r]),[D,O]=h.useState([]),[k,A]=h.useState(!1),[ee,j]=h.useState(!1),[te,ne]=h.useState(u===`system`?typeof window<`u`&&window.matchMedia&&window.matchMedia(`(prefers-color-scheme: dark)`).matches?`dark`:`light`:u),M=h.useRef(null),re=i.join(`+`).replace(/Key/g,``).replace(/Digit/g,``),ie=h.useRef(null),ae=h.useRef(!1),N=h.useCallback(e=>{T(t=>{var n;return(n=t.find(t=>t.id===e.id))!=null&&n.delete||ur.dismiss(e.id),t.filter(({id:t})=>t!==e.id)})},[]);return h.useEffect(()=>ur.subscribe(e=>{if(e.dismiss){T(t=>t.map(t=>t.id===e.id?{...t,delete:!0}:t));return}setTimeout(()=>{g.flushSync(()=>{T(t=>{let n=t.findIndex(t=>t.id===e.id);return n===-1?[e,...t]:[...t.slice(0,n),{...t[n],...e},...t.slice(n+1)]})})})}),[]),h.useEffect(()=>{if(u!==`system`){ne(u);return}if(u===`system`&&(window.matchMedia&&window.matchMedia(`(prefers-color-scheme: dark)`).matches?ne(`dark`):ne(`light`)),typeof window>`u`)return;let e=window.matchMedia(`(prefers-color-scheme: dark)`);try{e.addEventListener(`change`,({matches:e})=>{ne(e?`dark`:`light`)})}catch{e.addListener(({matches:e})=>{try{ne(e?`dark`:`light`)}catch(e){console.error(e)}})}},[u]),h.useEffect(()=>{w.length<=1&&A(!1)},[w]),h.useEffect(()=>{let e=e=>{var t,n;i.every(t=>e[t]||e.code===t)&&(A(!0),(t=M.current)==null||t.focus()),e.code===`Escape`&&(document.activeElement===M.current||(n=M.current)!=null&&n.contains(document.activeElement))&&A(!1)};return document.addEventListener(`keydown`,e),()=>document.removeEventListener(`keydown`,e)},[i]),h.useEffect(()=>{if(M.current)return()=>{ie.current&&(ie.current.focus({preventScroll:!0}),ie.current=null,ae.current=!1)}},[M.current]),h.createElement(`section`,{ref:t,"aria-label":`${S} ${re}`,tabIndex:-1,"aria-live":`polite`,"aria-relevant":`additions text`,"aria-atomic":`false`,suppressHydrationWarning:!0},E.map((t,r)=>{let[i,u]=t.split(`-`);return w.length?h.createElement(`ol`,{key:t,dir:v===`auto`?Dr():v,tabIndex:-1,ref:M,className:s,"data-sonner-toaster":!0,"data-theme":te,"data-y-position":i,"data-lifted":k&&w.length>1&&!a,"data-x-position":u,style:{"--front-toast-height":`${D[0]?.height||0}px`,"--width":`${br}px`,"--gap":`${y}px`,...p,...Or(c,l)},onBlur:e=>{ae.current&&!e.currentTarget.contains(e.relatedTarget)&&(ae.current=!1,ie.current&&=(ie.current.focus({preventScroll:!0}),null))},onFocus:e=>{e.target instanceof HTMLElement&&e.target.dataset.dismissible===`false`||ae.current||(ae.current=!0,ie.current=e.relatedTarget)},onMouseEnter:()=>A(!0),onMouseMove:()=>A(!0),onMouseLeave:()=>{ee||A(!1)},onDragEnd:()=>A(!1),onPointerDown:e=>{e.target instanceof HTMLElement&&e.target.dataset.dismissible===`false`||j(!0)},onPointerUp:()=>j(!1)},w.filter(e=>!e.position&&r===0||e.position===t).map((r,i)=>h.createElement(Er,{key:r.id,icons:x,index:i,toast:r,defaultRichColors:d,duration:_?.duration??f,className:_?.className,descriptionClassName:_?.descriptionClassName,invert:n,visibleToasts:m,closeButton:_?.closeButton??o,interacting:ee,position:t,style:_?.style,unstyled:_?.unstyled,classNames:_?.classNames,cancelButtonStyle:_?.cancelButtonStyle,actionButtonStyle:_?.actionButtonStyle,removeToast:N,toasts:w.filter(e=>e.position==r.position),heights:D.filter(e=>e.position==r.position),setHeights:O,expandByDefault:a,gap:y,loadingIcon:b,expanded:k,pauseWhenPageIsHidden:C,swipeDirections:e.swipeDirections}))):null}))}),Ar=(0,h.createContext)({});function jr(e){let t=(0,h.useRef)(null);return t.current===null&&(t.current=e()),t.current}var Mr=(0,h.createContext)(null),Nr=(0,h.createContext)({transformPagePoint:e=>e,isStatic:!1,reducedMotion:`never`}),z=s(),Pr=class extends h.Component{getSnapshotBeforeUpdate(e){let t=this.props.childRef.current;if(t&&e.isPresent&&!this.props.isPresent){let e=this.props.sizeRef.current;e.height=t.offsetHeight||0,e.width=t.offsetWidth||0,e.top=t.offsetTop,e.left=t.offsetLeft}return null}componentDidUpdate(){}render(){return this.props.children}};function Fr({children:e,isPresent:t}){let n=(0,h.useId)(),r=(0,h.useRef)(null),i=(0,h.useRef)({width:0,height:0,top:0,left:0}),{nonce:a}=(0,h.useContext)(Nr);return(0,h.useInsertionEffect)(()=>{let{width:e,height:o,top:s,left:c}=i.current;if(t||!r.current||!e||!o)return;r.current.dataset.motionPopId=n;let l=document.createElement(`style`);return a&&(l.nonce=a),document.head.appendChild(l),l.sheet&&l.sheet.insertRule(` - [data-motion-pop-id="${n}"] { - position: absolute !important; - width: ${e}px !important; - height: ${o}px !important; - top: ${s}px !important; - left: ${c}px !important; - } - `),()=>{document.head.removeChild(l)}},[t]),(0,z.jsx)(Pr,{isPresent:t,childRef:r,sizeRef:i,children:h.cloneElement(e,{ref:r})})}var Ir=({children:e,initial:t,isPresent:n,onExitComplete:r,custom:i,presenceAffectsLayout:a,mode:o})=>{let s=jr(Lr),c=(0,h.useId)(),l=(0,h.useCallback)(e=>{s.set(e,!0);for(let e of s.values())if(!e)return;r&&r()},[s,r]),u=(0,h.useMemo)(()=>({id:c,initial:t,isPresent:n,custom:i,onExitComplete:l,register:e=>(s.set(e,!1),()=>s.delete(e))}),a?[Math.random(),l]:[n,l]);return(0,h.useMemo)(()=>{s.forEach((e,t)=>s.set(t,!1))},[n]),h.useEffect(()=>{!n&&!s.size&&r&&r()},[n]),o===`popLayout`&&(e=(0,z.jsx)(Fr,{isPresent:n,children:e})),(0,z.jsx)(Mr.Provider,{value:u,children:e})};function Lr(){return new Map}function Rr(e=!0){let t=(0,h.useContext)(Mr);if(t===null)return[!0,null];let{isPresent:n,onExitComplete:r,register:i}=t,a=(0,h.useId)();(0,h.useEffect)(()=>{e&&i(a)},[e]);let o=(0,h.useCallback)(()=>e&&r&&r(a),[a,r,e]);return!n&&r?[!1,o]:[!0]}var zr=e=>e.key||``;function Br(e){let t=[];return h.Children.forEach(e,e=>{(0,h.isValidElement)(e)&&t.push(e)}),t}var Vr=typeof window<`u`,Hr=Vr?h.useLayoutEffect:h.useEffect,Ur=({children:e,custom:t,initial:n=!0,onExitComplete:r,presenceAffectsLayout:i=!0,mode:a=`sync`,propagate:o=!1})=>{let[s,c]=Rr(o),l=(0,h.useMemo)(()=>Br(e),[e]),u=o&&!s?[]:l.map(zr),d=(0,h.useRef)(!0),f=(0,h.useRef)(l),p=jr(()=>new Map),[m,g]=(0,h.useState)(l),[_,v]=(0,h.useState)(l);Hr(()=>{d.current=!1,f.current=l;for(let e=0;e<_.length;e++){let t=zr(_[e]);u.includes(t)?p.delete(t):p.get(t)!==!0&&p.set(t,!1)}},[_,u.length,u.join(`-`)]);let y=[];if(l!==m){let e=[...l];for(let t=0;t<_.length;t++){let n=_[t],r=zr(n);u.includes(r)||(e.splice(t,0,n),y.push(n))}a===`wait`&&y.length&&(e=y),v(Br(e)),g(l);return}let{forceRender:b}=(0,h.useContext)(Ar);return(0,z.jsx)(z.Fragment,{children:_.map(e=>{let m=zr(e),h=o&&!s?!1:l===_||u.includes(m);return(0,z.jsx)(Ir,{isPresent:h,initial:!d.current||n?void 0:!1,custom:h?void 0:t,presenceAffectsLayout:i,mode:a,onExitComplete:h?void 0:()=>{if(p.has(m))p.set(m,!0);else return;let e=!0;p.forEach(t=>{t||(e=!1)}),e&&(b?.(),v(f.current),o&&c?.(),r&&r())},children:e},m)})})},Wr=e=>e,Gr=Wr,Kr=Wr;function qr(e){let t;return()=>(t===void 0&&(t=e()),t)}var Jr=(e,t,n)=>{let r=t-e;return r===0?1:(n-e)/r},Yr=e=>e*1e3,Xr=e=>e/1e3,Zr={skipAnimations:!1,useManualTiming:!1};function Qr(e){let t=new Set,n=new Set,r=!1,i=!1,a=new WeakSet,o={delta:0,timestamp:0,isProcessing:!1};function s(t){a.has(t)&&(c.schedule(t),e()),t(o)}let c={schedule:(e,i=!1,o=!1)=>{let s=o&&r?t:n;return i&&a.add(e),s.has(e)||s.add(e),e},cancel:e=>{n.delete(e),a.delete(e)},process:e=>{if(o=e,r){i=!0;return}r=!0,[t,n]=[n,t],t.forEach(s),t.clear(),r=!1,i&&(i=!1,c.process(e))}};return c}var $r=[`read`,`resolveKeyframes`,`update`,`preRender`,`render`,`postRender`],ei=40;function ti(e,t){let n=!1,r=!0,i={delta:0,timestamp:0,isProcessing:!1},a=()=>n=!0,o=$r.reduce((e,t)=>(e[t]=Qr(a),e),{}),{read:s,resolveKeyframes:c,update:l,preRender:u,render:d,postRender:f}=o,p=()=>{let a=Zr.useManualTiming?i.timestamp:performance.now();n=!1,i.delta=r?1e3/60:Math.max(Math.min(a-i.timestamp,ei),1),i.timestamp=a,i.isProcessing=!0,s.process(i),c.process(i),l.process(i),u.process(i),d.process(i),f.process(i),i.isProcessing=!1,n&&t&&(r=!1,e(p))},m=()=>{n=!0,r=!0,i.isProcessing||e(p)};return{schedule:$r.reduce((e,t)=>{let r=o[t];return e[t]=(e,t=!1,i=!1)=>(n||m(),r.schedule(e,t,i)),e},{}),cancel:e=>{for(let t=0;t<$r.length;t++)o[$r[t]].cancel(e)},state:i,steps:o}}var{schedule:B,cancel:ni,state:ri,steps:ii}=ti(typeof requestAnimationFrame<`u`?requestAnimationFrame:Wr,!0),ai=(0,h.createContext)({strict:!1}),oi={animation:[`animate`,`variants`,`whileHover`,`whileTap`,`exit`,`whileInView`,`whileFocus`,`whileDrag`],exit:[`exit`],drag:[`drag`,`dragControls`],focus:[`whileFocus`],hover:[`whileHover`,`onHoverStart`,`onHoverEnd`],tap:[`whileTap`,`onTap`,`onTapStart`,`onTapCancel`],pan:[`onPan`,`onPanStart`,`onPanSessionStart`,`onPanEnd`],inView:[`whileInView`,`onViewportEnter`,`onViewportLeave`],layout:[`layout`,`layoutId`]},si={};for(let e in oi)si[e]={isEnabled:t=>oi[e].some(e=>!!t[e])};function V(e){for(let t in e)si[t]={...si[t],...e[t]}}var ci=new Set(`animate.exit.variants.initial.style.values.variants.transition.transformTemplate.custom.inherit.onBeforeLayoutMeasure.onAnimationStart.onAnimationComplete.onUpdate.onDragStart.onDrag.onDragEnd.onMeasureDragConstraints.onDirectionLock.onDragTransitionEnd._dragX._dragY.onHoverStart.onHoverEnd.onViewportEnter.onViewportLeave.globalTapTarget.ignoreStrict.viewport`.split(`.`));function li(e){return e.startsWith(`while`)||e.startsWith(`drag`)&&e!==`draggable`||e.startsWith(`layout`)||e.startsWith(`onTap`)||e.startsWith(`onPan`)||e.startsWith(`onLayout`)||ci.has(e)}var ui=n({default:()=>di}),di,fi=l((()=>{throw di={},Error(`Could not resolve "@emotion/is-prop-valid" imported by "framer-motion". Is it installed?`)})),pi=e=>!li(e);function mi(e){e&&(pi=t=>t.startsWith(`on`)?!li(t):e(t))}try{mi((fi(),r(ui)).default)}catch{}function hi(e,t,n){let r={};for(let i in e)i===`values`&&typeof e.values==`object`||(pi(i)||n===!0&&li(i)||!t&&!li(i)||e.draggable&&i.startsWith(`onDrag`))&&(r[i]=e[i]);return r}function gi(e){if(typeof Proxy>`u`)return e;let t=new Map;return new Proxy((...t)=>e(...t),{get:(n,r)=>r===`create`?e:(t.has(r)||t.set(r,e(r)),t.get(r))})}var _i=(0,h.createContext)({});function vi(e){return typeof e==`string`||Array.isArray(e)}function yi(e){return typeof e==`object`&&!!e&&typeof e.start==`function`}var bi=[`animate`,`whileInView`,`whileFocus`,`whileHover`,`whileTap`,`whileDrag`,`exit`],xi=[`initial`,...bi];function Si(e){return yi(e.animate)||xi.some(t=>vi(e[t]))}function Ci(e){return!!(Si(e)||e.variants)}function wi(e,t){if(Si(e)){let{initial:t,animate:n}=e;return{initial:t===!1||vi(t)?t:void 0,animate:vi(n)?n:void 0}}return e.inherit===!1?{}:t}function Ti(e){let{initial:t,animate:n}=wi(e,(0,h.useContext)(_i));return(0,h.useMemo)(()=>({initial:t,animate:n}),[Ei(t),Ei(n)])}function Ei(e){return Array.isArray(e)?e.join(` `):e}var Di=Symbol.for(`motionComponentSymbol`);function Oi(e){return e&&typeof e==`object`&&Object.prototype.hasOwnProperty.call(e,`current`)}function ki(e,t,n){return(0,h.useCallback)(r=>{r&&e.onMount&&e.onMount(r),t&&(r?t.mount(r):t.unmount()),n&&(typeof n==`function`?n(r):Oi(n)&&(n.current=r))},[t])}var Ai=e=>e.replace(/([a-z])([A-Z])/gu,`$1-$2`).toLowerCase(),ji=`data-`+Ai(`framerAppearId`),{schedule:Mi,cancel:Ni}=ti(queueMicrotask,!1),Pi=(0,h.createContext)({});function Fi(e,t,n,r,i){let{visualElement:a}=(0,h.useContext)(_i),o=(0,h.useContext)(ai),s=(0,h.useContext)(Mr),c=(0,h.useContext)(Nr).reducedMotion,l=(0,h.useRef)(null);r||=o.renderer,!l.current&&r&&(l.current=r(e,{visualState:t,parent:a,props:n,presenceContext:s,blockInitialAnimation:s?s.initial===!1:!1,reducedMotionConfig:c}));let u=l.current,d=(0,h.useContext)(Pi);u&&!u.projection&&i&&(u.type===`html`||u.type===`svg`)&&Ii(l.current,n,i,d);let f=(0,h.useRef)(!1);(0,h.useInsertionEffect)(()=>{u&&f.current&&u.update(n,s)});let p=n[ji],m=(0,h.useRef)(!!p&&!window.MotionHandoffIsComplete?.call(window,p)&&window.MotionHasOptimisedAnimation?.call(window,p));return Hr(()=>{u&&(f.current=!0,window.MotionIsMounted=!0,u.updateFeatures(),Mi.render(u.render),m.current&&u.animationState&&u.animationState.animateChanges())}),(0,h.useEffect)(()=>{u&&(!m.current&&u.animationState&&u.animationState.animateChanges(),m.current&&=(queueMicrotask(()=>{var e;(e=window.MotionHandoffMarkAsComplete)==null||e.call(window,p)}),!1))}),u}function Ii(e,t,n,r){let{layoutId:i,layout:a,drag:o,dragConstraints:s,layoutScroll:c,layoutRoot:l}=t;e.projection=new n(e.latestValues,t[`data-framer-portal-id`]?void 0:Li(e.parent)),e.projection.setOptions({layoutId:i,layout:a,alwaysMeasureLayout:!!o||s&&Oi(s),visualElement:e,animationType:typeof a==`string`?a:`both`,initialPromotionConfig:r,layoutScroll:c,layoutRoot:l})}function Li(e){if(e)return e.options.allowProjection===!1?Li(e.parent):e.projection}function Ri({preloadedFeatures:e,createVisualElement:t,useRender:n,useVisualState:r,Component:i}){e&&V(e);function a(a,o){let s,c={...(0,h.useContext)(Nr),...a,layoutId:zi(a)},{isStatic:l}=c,u=Ti(a),d=r(a,l);if(!l&&Vr){Bi(c,e);let n=Vi(c);s=n.MeasureLayout,u.visualElement=Fi(i,d,c,t,n.ProjectionNode)}return(0,z.jsxs)(_i.Provider,{value:u,children:[s&&u.visualElement?(0,z.jsx)(s,{visualElement:u.visualElement,...c}):null,n(i,a,ki(d,u.visualElement,o),d,l,u.visualElement)]})}a.displayName=`motion.${typeof i==`string`?i:`create(${i.displayName??i.name??``})`}`;let o=(0,h.forwardRef)(a);return o[Di]=i,o}function zi({layoutId:e}){let t=(0,h.useContext)(Ar).id;return t&&e!==void 0?t+`-`+e:e}function Bi(e,t){(0,h.useContext)(ai).strict}function Vi(e){let{drag:t,layout:n}=si;if(!t&&!n)return{};let r={...t,...n};return{MeasureLayout:t?.isEnabled(e)||n?.isEnabled(e)?r.MeasureLayout:void 0,ProjectionNode:r.ProjectionNode}}var Hi=[`animate`,`circle`,`defs`,`desc`,`ellipse`,`g`,`image`,`line`,`filter`,`marker`,`mask`,`metadata`,`path`,`pattern`,`polygon`,`polyline`,`rect`,`stop`,`switch`,`symbol`,`svg`,`text`,`tspan`,`use`,`view`];function Ui(e){return typeof e!=`string`||e.includes(`-`)?!1:!!(Hi.indexOf(e)>-1||/[A-Z]/u.test(e))}function Wi(e){let t=[{},{}];return e?.values.forEach((e,n)=>{t[0][n]=e.get(),t[1][n]=e.getVelocity()}),t}function Gi(e,t,n,r){if(typeof t==`function`){let[i,a]=Wi(r);t=t(n===void 0?e.custom:n,i,a)}if(typeof t==`string`&&(t=e.variants&&e.variants[t]),typeof t==`function`){let[i,a]=Wi(r);t=t(n===void 0?e.custom:n,i,a)}return t}var Ki=e=>Array.isArray(e),H=e=>!!(e&&typeof e==`object`&&e.mix&&e.toValue),U=e=>Ki(e)?e[e.length-1]||0:e,W=e=>!!(e&&e.getVelocity);function qi(e){let t=W(e)?e.get():e;return H(t)?t.toValue():t}function Ji({scrapeMotionValuesFromProps:e,createRenderState:t,onUpdate:n},r,i,a){let o={latestValues:Xi(r,i,a,e),renderState:t()};return n&&(o.onMount=e=>n({props:r,current:e,...o}),o.onUpdate=e=>n(e)),o}var Yi=e=>(t,n)=>{let r=(0,h.useContext)(_i),i=(0,h.useContext)(Mr),a=()=>Ji(e,t,r,i);return n?a():jr(a)};function Xi(e,t,n,r){let i={},a=r(e,{});for(let e in a)i[e]=qi(a[e]);let{initial:o,animate:s}=e,c=Si(e),l=Ci(e);t&&l&&!c&&e.inherit!==!1&&(o===void 0&&(o=t.initial),s===void 0&&(s=t.animate));let u=n?n.initial===!1:!1;u||=o===!1;let d=u?s:o;if(d&&typeof d!=`boolean`&&!yi(d)){let t=Array.isArray(d)?d:[d];for(let n=0;nt=>typeof t==`string`&&t.startsWith(e),ea=$i(`--`),ta=$i(`var(--`),na=e=>ta(e)?ra.test(e.split(`/*`)[0].trim()):!1,ra=/var\(--(?:[\w-]+\s*|[\w-]+\s*,(?:\s*[^)(\s]|\s*\((?:[^)(]|\([^)(]*\))*\))+\s*)\)$/iu,ia=(e,t)=>t&&typeof e==`number`?t.transform(e):e,aa=(e,t,n)=>n>t?t:ntypeof e==`number`,parse:parseFloat,transform:e=>e},sa={...oa,transform:e=>aa(0,1,e)},ca={...oa,default:1},la=e=>({test:t=>typeof t==`string`&&t.endsWith(e)&&t.split(` `).length===1,parse:parseFloat,transform:t=>`${t}${e}`}),ua=la(`deg`),da=la(`%`),G=la(`px`),fa=la(`vh`),pa=la(`vw`),ma={...da,parse:e=>da.parse(e)/100,transform:e=>da.transform(e*100)},ha={borderWidth:G,borderTopWidth:G,borderRightWidth:G,borderBottomWidth:G,borderLeftWidth:G,borderRadius:G,radius:G,borderTopLeftRadius:G,borderTopRightRadius:G,borderBottomRightRadius:G,borderBottomLeftRadius:G,width:G,maxWidth:G,height:G,maxHeight:G,top:G,right:G,bottom:G,left:G,padding:G,paddingTop:G,paddingRight:G,paddingBottom:G,paddingLeft:G,margin:G,marginTop:G,marginRight:G,marginBottom:G,marginLeft:G,backgroundPositionX:G,backgroundPositionY:G},ga={rotate:ua,rotateX:ua,rotateY:ua,rotateZ:ua,scale:ca,scaleX:ca,scaleY:ca,scaleZ:ca,skew:ua,skewX:ua,skewY:ua,distance:G,translateX:G,translateY:G,translateZ:G,x:G,y:G,z:G,perspective:G,transformPerspective:G,opacity:sa,originX:ma,originY:ma,originZ:G},_a={...oa,transform:Math.round},va={...ha,...ga,zIndex:_a,size:G,fillOpacity:sa,strokeOpacity:sa,numOctaves:_a},ya={x:`translateX`,y:`translateY`,z:`translateZ`,transformPerspective:`perspective`},ba=Zi.length;function xa(e,t,n){let r=``,i=!0;for(let a=0;a({style:{},transform:{},transformOrigin:{},vars:{}}),ka=()=>({...Oa(),attrs:{}}),Aa=e=>typeof e==`string`&&e.toLowerCase()===`svg`;function ja(e,{style:t,vars:n},r,i){Object.assign(e.style,t,i&&i.getProjectionStyles(r));for(let t in n)e.style.setProperty(t,n[t])}var Ma=new Set([`baseFrequency`,`diffuseConstant`,`kernelMatrix`,`kernelUnitLength`,`keySplines`,`keyTimes`,`limitingConeAngle`,`markerHeight`,`markerWidth`,`numOctaves`,`targetX`,`targetY`,`surfaceScale`,`specularConstant`,`specularExponent`,`stdDeviation`,`tableValues`,`viewBox`,`gradientTransform`,`pathLength`,`startOffset`,`textLength`,`lengthAdjust`]);function Na(e,t,n,r){ja(e,t,void 0,r);for(let n in t.attrs)e.setAttribute(Ma.has(n)?n:Ai(n),t.attrs[n])}var Pa={};function Fa(e){Object.assign(Pa,e)}function Ia(e,{layout:t,layoutId:n}){return Qi.has(e)||e.startsWith(`origin`)||(t||n!==void 0)&&(!!Pa[e]||e===`opacity`)}function La(e,t,n){let{style:r}=e,i={};for(let a in r)(W(r[a])||t.style&&W(t.style[a])||Ia(a,e)||n?.getValue(a)?.liveStyle!==void 0)&&(i[a]=r[a]);return i}function Ra(e,t,n){let r=La(e,t,n);for(let n in e)if(W(e[n])||W(t[n])){let t=Zi.indexOf(n)===-1?n:`attr`+n.charAt(0).toUpperCase()+n.substring(1);r[t]=e[n]}return r}function za(e,t){try{t.dimensions=typeof e.getBBox==`function`?e.getBBox():e.getBoundingClientRect()}catch{t.dimensions={x:0,y:0,width:0,height:0}}}var Ba=[`x`,`y`,`width`,`height`,`cx`,`cy`,`r`],Va={useVisualState:Yi({scrapeMotionValuesFromProps:Ra,createRenderState:ka,onUpdate:({props:e,prevProps:t,current:n,renderState:r,latestValues:i})=>{if(!n)return;let a=!!e.drag;if(!a){for(let e in i)if(Qi.has(e)){a=!0;break}}if(!a)return;let o=!t;if(t)for(let n=0;n{za(n,r),B.render(()=>{Da(r,i,Aa(n.tagName),e.transformTemplate),Na(n,r)})})}})},Ha={useVisualState:Yi({scrapeMotionValuesFromProps:La,createRenderState:Oa})};function Ua(e,t,n){for(let r in t)!W(t[r])&&!Ia(r,n)&&(e[r]=t[r])}function Wa({transformTemplate:e},t){return(0,h.useMemo)(()=>{let n=Oa();return Sa(n,t,e),Object.assign({},n.vars,n.style)},[t])}function Ga(e,t){let n=e.style||{},r={};return Ua(r,n,e),Object.assign(r,Wa(e,t)),r}function Ka(e,t){let n={},r=Ga(e,t);return e.drag&&e.dragListener!==!1&&(n.draggable=!1,r.userSelect=r.WebkitUserSelect=r.WebkitTouchCallout=`none`,r.touchAction=e.drag===!0?`none`:`pan-${e.drag===`x`?`y`:`x`}`),e.tabIndex===void 0&&(e.onTap||e.onTapStart||e.whileTap)&&(n.tabIndex=0),n.style=r,n}function qa(e,t,n,r){let i=(0,h.useMemo)(()=>{let n=ka();return Da(n,t,Aa(r),e.transformTemplate),{...n.attrs,style:{...n.style}}},[t]);if(e.style){let t={};Ua(t,e.style,e),i.style={...t,...i.style}}return i}function Ja(e=!1){return(t,n,r,{latestValues:i},a)=>{let o=(Ui(t)?qa:Ka)(n,i,a,t),s=hi(n,typeof t==`string`,e),c=t===h.Fragment?{}:{...s,...o,ref:r},{children:l}=n,u=(0,h.useMemo)(()=>W(l)?l.get():l,[l]);return(0,h.createElement)(t,{...c,children:u})}}function Ya(e,t){return function(n,{forwardMotionProps:r}={forwardMotionProps:!1}){return Ri({...Ui(n)?Va:Ha,preloadedFeatures:e,useRender:Ja(r),createVisualElement:t,Component:n})}}function Xa(e,t){if(!Array.isArray(t))return!1;let n=t.length;if(n!==e.length)return!1;for(let r=0;rwindow.ScrollTimeline!==void 0),$a=class{constructor(e){this.stop=()=>this.runAll(`stop`),this.animations=e.filter(Boolean)}get finished(){return Promise.all(this.animations.map(e=>`finished`in e?e.finished:e))}getAll(e){return this.animations[0][e]}setAll(e,t){for(let n=0;n{if(Qa()&&n.attachTimeline)return n.attachTimeline(e);if(typeof t==`function`)return t(n)});return()=>{n.forEach((e,t)=>{e&&e(),this.animations[t].stop()})}}get time(){return this.getAll(`time`)}set time(e){this.setAll(`time`,e)}get speed(){return this.getAll(`speed`)}set speed(e){this.setAll(`speed`,e)}get startTime(){return this.getAll(`startTime`)}get duration(){let e=0;for(let t=0;tt[e]())}flatten(){this.runAll(`flatten`)}play(){this.runAll(`play`)}pause(){this.runAll(`pause`)}cancel(){this.runAll(`cancel`)}complete(){this.runAll(`complete`)}},eo=class extends $a{then(e,t){return Promise.all(this.animations).then(e).catch(t)}};function to(e,t){return e?e[t]||e.default||e:void 0}var no=2e4;function ro(e){let t=0,n=e.next(t);for(;!n.done&&t<2e4;)t+=50,n=e.next(t);return t>=2e4?1/0:t}function io(e){return typeof e==`function`}function ao(e,t){e.timeline=t,e.onfinish=null}var oo=e=>Array.isArray(e)&&typeof e[0]==`number`,so={linearEasing:void 0};function co(e,t){let n=qr(e);return()=>so[t]??n()}var lo=co(()=>{try{document.createElement(`div`).animate({opacity:0},{easing:`linear(0, 1)`})}catch{return!1}return!0},`linearEasing`),uo=(e,t,n=10)=>{let r=``,i=Math.max(Math.round(t/n),2);for(let t=0;t`cubic-bezier(${e}, ${t}, ${n}, ${r})`,mo={linear:`linear`,ease:`ease`,easeIn:`ease-in`,easeOut:`ease-out`,easeInOut:`ease-in-out`,circIn:po([0,.65,.55,1]),circOut:po([.55,0,1,.45]),backIn:po([.31,.01,.66,-.59]),backOut:po([.33,1.53,.69,.99])};function ho(e,t){if(e)return typeof e==`function`&&lo()?uo(e,t):oo(e)?po(e):Array.isArray(e)?e.map(e=>ho(e,t)||mo.easeOut):mo[e]}var go={x:!1,y:!1};function _o(){return go.x||go.y}function q(e,t,n){if(e instanceof Element)return[e];if(typeof e==`string`){let r=document;t&&(r=t.current);let i=n?.[e]??r.querySelectorAll(e);return i?Array.from(i):[]}return Array.from(e)}function vo(e,t){let n=q(e),r=new AbortController;return[n,{passive:!0,...t,signal:r.signal},()=>r.abort()]}function yo(e){return t=>{t.pointerType===`touch`||_o()||e(t)}}function bo(e,t,n={}){let[r,i,a]=vo(e,n),o=yo(e=>{let{target:n}=e,r=t(e);if(typeof r!=`function`||!n)return;let a=yo(e=>{r(e),n.removeEventListener(`pointerleave`,a)});n.addEventListener(`pointerleave`,a,i)});return r.forEach(e=>{e.addEventListener(`pointerenter`,o,i)}),a}var xo=(e,t)=>t?e===t||xo(e,t.parentElement):!1,So=e=>e.pointerType===`mouse`?typeof e.button!=`number`||e.button<=0:e.isPrimary!==!1,Co=new Set([`BUTTON`,`INPUT`,`SELECT`,`TEXTAREA`,`A`]);function J(e){return Co.has(e.tagName)||e.tabIndex!==-1}var Y=new WeakSet;function wo(e){return t=>{t.key===`Enter`&&e(t)}}function To(e,t){e.dispatchEvent(new PointerEvent(`pointer`+t,{isPrimary:!0,bubbles:!0}))}var Eo=(e,t)=>{let n=e.currentTarget;if(!n)return;let r=wo(()=>{if(Y.has(n))return;To(n,`down`);let e=wo(()=>{To(n,`up`)});n.addEventListener(`keyup`,e,t),n.addEventListener(`blur`,()=>To(n,`cancel`),t)});n.addEventListener(`keydown`,r,t),n.addEventListener(`blur`,()=>n.removeEventListener(`keydown`,r),t)};function Do(e){return So(e)&&!_o()}function Oo(e,t,n={}){let[r,i,a]=vo(e,n),o=e=>{let r=e.currentTarget;if(!Do(e)||Y.has(r))return;Y.add(r);let a=t(e),o=(e,t)=>{window.removeEventListener(`pointerup`,s),window.removeEventListener(`pointercancel`,c),!(!Do(e)||!Y.has(r))&&(Y.delete(r),typeof a==`function`&&a(e,{success:t}))},s=e=>{o(e,n.useGlobalTarget||xo(r,e.target))},c=e=>{o(e,!1)};window.addEventListener(`pointerup`,s,i),window.addEventListener(`pointercancel`,c,i)};return r.forEach(e=>{!J(e)&&e.getAttribute(`tabindex`)===null&&(e.tabIndex=0),(n.useGlobalTarget?window:e).addEventListener(`pointerdown`,o,i),e.addEventListener(`focus`,e=>Eo(e,i),i)}),a}function ko(e){return e===`x`||e===`y`?go[e]?null:(go[e]=!0,()=>{go[e]=!1}):go.x||go.y?null:(go.x=go.y=!0,()=>{go.x=go.y=!1})}var Ao=new Set([`width`,`height`,`top`,`left`,`right`,`bottom`,...Zi]),jo;function Mo(){jo=void 0}var No={now:()=>(jo===void 0&&No.set(ri.isProcessing||Zr.useManualTiming?ri.timestamp:performance.now()),jo),set:e=>{jo=e,queueMicrotask(Mo)}};function Po(e,t){e.indexOf(t)===-1&&e.push(t)}function Fo(e,t){let n=e.indexOf(t);n>-1&&e.splice(n,1)}var Io=class{constructor(){this.subscriptions=[]}add(e){return Po(this.subscriptions,e),()=>Fo(this.subscriptions,e)}notify(e,t,n){let r=this.subscriptions.length;if(r)if(r===1)this.subscriptions[0](e,t,n);else for(let i=0;i!isNaN(parseFloat(e)),Bo={current:void 0},Vo=class{constructor(e,t={}){this.version=`11.18.2`,this.canTrackVelocity=null,this.events={},this.updateAndNotify=(e,t=!0)=>{let n=No.now();this.updatedAt!==n&&this.setPrevFrameValue(),this.prev=this.current,this.setCurrent(e),this.current!==this.prev&&this.events.change&&this.events.change.notify(this.current),t&&this.events.renderRequest&&this.events.renderRequest.notify(this.current)},this.hasAnimated=!1,this.setCurrent(e),this.owner=t.owner}setCurrent(e){this.current=e,this.updatedAt=No.now(),this.canTrackVelocity===null&&e!==void 0&&(this.canTrackVelocity=zo(this.current))}setPrevFrameValue(e=this.current){this.prevFrameValue=e,this.prevUpdatedAt=this.updatedAt}onChange(e){return this.on(`change`,e)}on(e,t){this.events[e]||(this.events[e]=new Io);let n=this.events[e].add(t);return e===`change`?()=>{n(),B.read(()=>{this.events.change.getSize()||this.stop()})}:n}clearListeners(){for(let e in this.events)this.events[e].clear()}attach(e,t){this.passiveEffect=e,this.stopPassiveEffect=t}set(e,t=!0){!t||!this.passiveEffect?this.updateAndNotify(e,t):this.passiveEffect(e,this.updateAndNotify)}setWithVelocity(e,t,n){this.set(t),this.prev=void 0,this.prevFrameValue=e,this.prevUpdatedAt=this.updatedAt-n}jump(e,t=!0){this.updateAndNotify(e),this.prev=e,this.prevUpdatedAt=this.prevFrameValue=void 0,t&&this.stop(),this.stopPassiveEffect&&this.stopPassiveEffect()}get(){return Bo.current&&Bo.current.push(this),this.current}getPrevious(){return this.prev}getVelocity(){let e=No.now();if(!this.canTrackVelocity||this.prevFrameValue===void 0||e-this.updatedAt>Ro)return 0;let t=Math.min(this.updatedAt-this.prevUpdatedAt,Ro);return Lo(parseFloat(this.current)-parseFloat(this.prevFrameValue),t)}start(e){return this.stop(),new Promise(t=>{this.hasAnimated=!0,this.animation=e(t),this.events.animationStart&&this.events.animationStart.notify()}).then(()=>{this.events.animationComplete&&this.events.animationComplete.notify(),this.clearAnimation()})}stop(){this.animation&&(this.animation.stop(),this.events.animationCancel&&this.events.animationCancel.notify()),this.clearAnimation()}isAnimating(){return!!this.animation}clearAnimation(){delete this.animation}destroy(){this.clearListeners(),this.stop(),this.stopPassiveEffect&&this.stopPassiveEffect()}};function Ho(e,t){return new Vo(e,t)}function Uo(e,t,n){e.hasValue(t)?e.getValue(t).set(n):e.addValue(t,Ho(n))}function Wo(e,t){let{transitionEnd:n={},transition:r={},...i}=Za(e,t)||{};i={...i,...n};for(let t in i)Uo(e,t,U(i[t]))}function Go(e){return!!(W(e)&&e.add)}function Ko(e,t){let n=e.getValue(`willChange`);if(Go(n))return n.add(t)}function qo(e){return e.props[ji]}var Jo={current:!1},Yo=(e,t,n)=>(((1-3*n+3*t)*e+(3*n-6*t))*e+3*t)*e,Xo=1e-7,Zo=12;function Qo(e,t,n,r,i){let a,o,s=0;do o=t+(n-t)/2,a=Yo(o,r,i)-e,a>0?n=o:t=o;while(Math.abs(a)>Xo&&++sQo(t,0,1,e,n);return e=>e===0||e===1?e:Yo(i(e),t,r)}var es=e=>t=>t<=.5?e(2*t)/2:(2-e(2*(1-t)))/2,ts=e=>t=>1-e(1-t),ns=$o(.33,1.53,.69,.99),rs=ts(ns),is=es(rs),as=e=>(e*=2)<1?.5*rs(e):.5*(2-2**(-10*(e-1))),os=e=>1-Math.sin(Math.acos(e)),ss=ts(os),cs=es(os),ls=e=>/^0[^.\s]+$/u.test(e);function us(e){return typeof e==`number`?e===0:e===null||e===`none`||e===`0`||ls(e)}var ds=e=>Math.round(e*1e5)/1e5,fs=/-?(?:\d+(?:\.\d+)?|\.\d+)/gu;function ps(e){return e==null}var ms=/^(?:#[\da-f]{3,8}|(?:rgb|hsl)a?\((?:-?[\d.]+%?[,\s]+){2}-?[\d.]+%?\s*(?:[,/]\s*)?(?:\b\d+(?:\.\d+)?|\.\d+)?%?\))$/iu,hs=(e,t)=>n=>!!(typeof n==`string`&&ms.test(n)&&n.startsWith(e)||t&&!ps(n)&&Object.prototype.hasOwnProperty.call(n,t)),gs=(e,t,n)=>r=>{if(typeof r!=`string`)return r;let[i,a,o,s]=r.match(fs);return{[e]:parseFloat(i),[t]:parseFloat(a),[n]:parseFloat(o),alpha:s===void 0?1:parseFloat(s)}},_s=e=>aa(0,255,e),vs={...oa,transform:e=>Math.round(_s(e))},ys={test:hs(`rgb`,`red`),parse:gs(`red`,`green`,`blue`),transform:({red:e,green:t,blue:n,alpha:r=1})=>`rgba(`+vs.transform(e)+`, `+vs.transform(t)+`, `+vs.transform(n)+`, `+ds(sa.transform(r))+`)`};function bs(e){let t=``,n=``,r=``,i=``;return e.length>5?(t=e.substring(1,3),n=e.substring(3,5),r=e.substring(5,7),i=e.substring(7,9)):(t=e.substring(1,2),n=e.substring(2,3),r=e.substring(3,4),i=e.substring(4,5),t+=t,n+=n,r+=r,i+=i),{red:parseInt(t,16),green:parseInt(n,16),blue:parseInt(r,16),alpha:i?parseInt(i,16)/255:1}}var xs={test:hs(`#`),parse:bs,transform:ys.transform},Ss={test:hs(`hsl`,`hue`),parse:gs(`hue`,`saturation`,`lightness`),transform:({hue:e,saturation:t,lightness:n,alpha:r=1})=>`hsla(`+Math.round(e)+`, `+da.transform(ds(t))+`, `+da.transform(ds(n))+`, `+ds(sa.transform(r))+`)`},Cs={test:e=>ys.test(e)||xs.test(e)||Ss.test(e),parse:e=>ys.test(e)?ys.parse(e):Ss.test(e)?Ss.parse(e):xs.parse(e),transform:e=>typeof e==`string`?e:e.hasOwnProperty(`red`)?ys.transform(e):Ss.transform(e)},ws=/(?:#[\da-f]{3,8}|(?:rgb|hsl)a?\((?:-?[\d.]+%?[,\s]+){2}-?[\d.]+%?\s*(?:[,/]\s*)?(?:\b\d+(?:\.\d+)?|\.\d+)?%?\))/giu;function Ts(e){return isNaN(e)&&typeof e==`string`&&(e.match(fs)?.length||0)+(e.match(ws)?.length||0)>0}var Es=`number`,Ds=`color`,Os=`var`,ks=`var(`,As="${}",js=/var\s*\(\s*--(?:[\w-]+\s*|[\w-]+\s*,(?:\s*[^)(\s]|\s*\((?:[^)(]|\([^)(]*\))*\))+\s*)\)|#[\da-f]{3,8}|(?:rgb|hsl)a?\((?:-?[\d.]+%?[,\s]+){2}-?[\d.]+%?\s*(?:[,/]\s*)?(?:\b\d+(?:\.\d+)?|\.\d+)?%?\)|-?(?:\d+(?:\.\d+)?|\.\d+)/giu;function Ms(e){let t=e.toString(),n=[],r={color:[],number:[],var:[]},i=[],a=0;return{values:n,split:t.replace(js,e=>(Cs.test(e)?(r.color.push(a),i.push(Ds),n.push(Cs.parse(e))):e.startsWith(ks)?(r.var.push(a),i.push(Os),n.push(e)):(r.number.push(a),i.push(Es),n.push(parseFloat(e))),++a,As)).split(As),indexes:r,types:i}}function Ns(e){return Ms(e).values}function Ps(e){let{split:t,types:n}=Ms(e),r=t.length;return e=>{let i=``;for(let a=0;atypeof e==`number`?0:e;function Is(e){let t=Ns(e);return Ps(e)(t.map(Fs))}var Ls={test:Ts,parse:Ns,createTransformer:Ps,getAnimatableNone:Is},Rs=new Set([`brightness`,`contrast`,`saturate`,`opacity`]);function zs(e){let[t,n]=e.slice(0,-1).split(`(`);if(t===`drop-shadow`)return e;let[r]=n.match(fs)||[];if(!r)return e;let i=n.replace(r,``),a=+!!Rs.has(t);return r!==n&&(a*=100),t+`(`+a+i+`)`}var Bs=/\b([a-z-]*)\(.*?\)/gu,Vs={...Ls,getAnimatableNone:e=>{let t=e.match(Bs);return t?t.map(zs).join(` `):e}},Hs={...va,color:Cs,backgroundColor:Cs,outlineColor:Cs,fill:Cs,stroke:Cs,borderColor:Cs,borderTopColor:Cs,borderRightColor:Cs,borderBottomColor:Cs,borderLeftColor:Cs,filter:Vs,WebkitFilter:Vs},Us=e=>Hs[e];function Ws(e,t){let n=Us(e);return n!==Vs&&(n=Ls),n.getAnimatableNone?n.getAnimatableNone(t):void 0}var Gs=new Set([`auto`,`none`,`0`]);function Ks(e,t,n){let r=0,i;for(;re===oa||e===G,Js=(e,t)=>parseFloat(e.split(`, `)[t]),Ys=(e,t)=>(n,{transform:r})=>{if(r===`none`||!r)return 0;let i=r.match(/^matrix3d\((.+)\)$/u);if(i)return Js(i[1],t);{let t=r.match(/^matrix\((.+)\)$/u);return t?Js(t[1],e):0}},Xs=new Set([`x`,`y`,`z`]),Zs=Zi.filter(e=>!Xs.has(e));function Qs(e){let t=[];return Zs.forEach(n=>{let r=e.getValue(n);r!==void 0&&(t.push([n,r.get()]),r.set(+!!n.startsWith(`scale`)))}),t}var $s={width:({x:e},{paddingLeft:t=`0`,paddingRight:n=`0`})=>e.max-e.min-parseFloat(t)-parseFloat(n),height:({y:e},{paddingTop:t=`0`,paddingBottom:n=`0`})=>e.max-e.min-parseFloat(t)-parseFloat(n),top:(e,{top:t})=>parseFloat(t),left:(e,{left:t})=>parseFloat(t),bottom:({y:e},{top:t})=>parseFloat(t)+(e.max-e.min),right:({x:e},{left:t})=>parseFloat(t)+(e.max-e.min),x:Ys(4,13),y:Ys(5,14)};$s.translateX=$s.x,$s.translateY=$s.y;var ec=new Set,tc=!1,nc=!1;function rc(){if(nc){let e=Array.from(ec).filter(e=>e.needsMeasurement),t=new Set(e.map(e=>e.element)),n=new Map;t.forEach(e=>{let t=Qs(e);t.length&&(n.set(e,t),e.render())}),e.forEach(e=>e.measureInitialState()),t.forEach(e=>{e.render();let t=n.get(e);t&&t.forEach(([t,n])=>{var r;(r=e.getValue(t))==null||r.set(n)})}),e.forEach(e=>e.measureEndState()),e.forEach(e=>{e.suspendedScrollY!==void 0&&window.scrollTo(0,e.suspendedScrollY)})}nc=!1,tc=!1,ec.forEach(e=>e.complete()),ec.clear()}function ic(){ec.forEach(e=>{e.readKeyframes(),e.needsMeasurement&&(nc=!0)})}function ac(){ic(),rc()}var oc=class{constructor(e,t,n,r,i,a=!1){this.isComplete=!1,this.isAsync=!1,this.needsMeasurement=!1,this.isScheduled=!1,this.unresolvedKeyframes=[...e],this.onComplete=t,this.name=n,this.motionValue=r,this.element=i,this.isAsync=a}scheduleResolve(){this.isScheduled=!0,this.isAsync?(ec.add(this),tc||(tc=!0,B.read(ic),B.resolveKeyframes(rc))):(this.readKeyframes(),this.complete())}readKeyframes(){let{unresolvedKeyframes:e,name:t,element:n,motionValue:r}=this;for(let i=0;i/^-?(?:\d+(?:\.\d+)?|\.\d+)$/u.test(e),cc=/^var\(--(?:([\w-]+)|([\w-]+), ?([a-zA-Z\d ()%#.,-]+))\)/u;function lc(e){let t=cc.exec(e);if(!t)return[,];let[,n,r,i]=t;return[`--${n??r}`,i]}var uc=4;function dc(e,t,n=1){Kr(n<=uc,`Max CSS variable fallback depth detected in property "${e}". This may indicate a circular fallback dependency.`);let[r,i]=lc(e);if(!r)return;let a=window.getComputedStyle(t).getPropertyValue(r);if(a){let e=a.trim();return sc(e)?parseFloat(e):e}return na(i)?dc(i,t,n+1):i}var fc=e=>t=>t.test(e),pc=[oa,G,da,ua,pa,fa,{test:e=>e===`auto`,parse:e=>e}],X=e=>pc.find(fc(e)),mc=class extends oc{constructor(e,t,n,r,i){super(e,t,n,r,i,!0)}readKeyframes(){let{unresolvedKeyframes:e,element:t,name:n}=this;if(!t||!t.current)return;super.readKeyframes();for(let n=0;n{e.getValue(t).set(n)}),this.resolveNoneKeyframes()}},hc=(e,t)=>t!==`zIndex`&&!!(typeof e==`number`||Array.isArray(e)||typeof e==`string`&&(Ls.test(e)||e===`0`)&&!e.startsWith(`url(`));function gc(e){let t=e[0];if(e.length===1)return!0;for(let n=0;ne!==null;function yc(e,{repeat:t,repeatType:n=`loop`},r){let i=e.filter(vc),a=t&&n!==`loop`&&t%2==1?0:i.length-1;return!a||r===void 0?i[a]:r}var bc=40,xc=class{constructor({autoplay:e=!0,delay:t=0,type:n=`keyframes`,repeat:r=0,repeatDelay:i=0,repeatType:a=`loop`,...o}){this.isStopped=!1,this.hasAttemptedResolve=!1,this.createdAt=No.now(),this.options={autoplay:e,delay:t,type:n,repeat:r,repeatDelay:i,repeatType:a,...o},this.updateFinishedPromise()}calcStartTime(){return this.resolvedAt&&this.resolvedAt-this.createdAt>bc?this.resolvedAt:this.createdAt}get resolved(){return!this._resolved&&!this.hasAttemptedResolve&&ac(),this._resolved}onKeyframesResolved(e,t){this.resolvedAt=No.now(),this.hasAttemptedResolve=!0;let{name:n,type:r,velocity:i,delay:a,onComplete:o,onUpdate:s,isGenerator:c}=this.options;if(!c&&!_c(e,n,r,i))if(Jo.current||!a){s&&s(yc(e,this.options,t)),o&&o(),this.resolveFinishedPromise();return}else this.options.duration=0;let l=this.initPlayback(e,t);l!==!1&&(this._resolved={keyframes:e,finalKeyframe:t,...l},this.onPostResolved())}onPostResolved(){}then(e,t){return this.currentFinishedPromise.then(e,t)}flatten(){this.options.type=`keyframes`,this.options.ease=`linear`}updateFinishedPromise(){this.currentFinishedPromise=new Promise(e=>{this.resolveFinishedPromise=e})}},Z=(e,t,n)=>e+(t-e)*n;function Sc(e,t,n){return n<0&&(n+=1),n>1&&--n,n<1/6?e+(t-e)*6*n:n<1/2?t:n<2/3?e+(t-e)*(2/3-n)*6:e}function Cc({hue:e,saturation:t,lightness:n,alpha:r}){e/=360,t/=100,n/=100;let i=0,a=0,o=0;if(!t)i=a=o=n;else{let r=n<.5?n*(1+t):n+t-n*t,s=2*n-r;i=Sc(s,r,e+1/3),a=Sc(s,r,e),o=Sc(s,r,e-1/3)}return{red:Math.round(i*255),green:Math.round(a*255),blue:Math.round(o*255),alpha:r}}function wc(e,t){return n=>n>0?t:e}var Tc=(e,t,n)=>{let r=e*e,i=n*(t*t-r)+r;return i<0?0:Math.sqrt(i)},Ec=[xs,ys,Ss],Dc=e=>Ec.find(t=>t.test(e));function Oc(e){let t=Dc(e);if(Gr(!!t,`'${e}' is not an animatable color. Use the equivalent color code instead.`),!t)return!1;let n=t.parse(e);return t===Ss&&(n=Cc(n)),n}var kc=(e,t)=>{let n=Oc(e),r=Oc(t);if(!n||!r)return wc(e,t);let i={...n};return e=>(i.red=Tc(n.red,r.red,e),i.green=Tc(n.green,r.green,e),i.blue=Tc(n.blue,r.blue,e),i.alpha=Z(n.alpha,r.alpha,e),ys.transform(i))},Ac=(e,t)=>n=>t(e(n)),jc=(...e)=>e.reduce(Ac),Mc=new Set([`none`,`hidden`]);function Nc(e,t){return Mc.has(e)?n=>n<=0?e:t:n=>n>=1?t:e}function Pc(e,t){return n=>Z(e,t,n)}function Fc(e){return typeof e==`number`?Pc:typeof e==`string`?na(e)?wc:Cs.test(e)?kc:zc:Array.isArray(e)?Ic:typeof e==`object`?Cs.test(e)?kc:Lc:wc}function Ic(e,t){let n=[...e],r=n.length,i=e.map((e,n)=>Fc(e)(e,t[n]));return e=>{for(let t=0;t{for(let t in r)n[t]=r[t](e);return n}}function Rc(e,t){let n=[],r={color:0,var:0,number:0};for(let i=0;i{let n=Ls.createTransformer(t),r=Ms(e),i=Ms(t);return r.indexes.var.length===i.indexes.var.length&&r.indexes.color.length===i.indexes.color.length&&r.indexes.number.length>=i.indexes.number.length?Mc.has(e)&&!i.values.length||Mc.has(t)&&!r.values.length?Nc(e,t):jc(Ic(Rc(r,i),i.values),n):(Gr(!0,`Complex values '${e}' and '${t}' too different to mix. Ensure all colors are of the same type, and that each contains the same quantity of number and color values. Falling back to instant transition.`),wc(e,t))};function Bc(e,t,n){return typeof e==`number`&&typeof t==`number`&&typeof n==`number`?Z(e,t,n):Fc(e)(e,t)}var Vc=5;function Q(e,t,n){let r=Math.max(t-Vc,0);return Lo(n-e(r),t-r)}var $={stiffness:100,damping:10,mass:1,velocity:0,duration:800,bounce:.3,visualDuration:.3,restSpeed:{granular:.01,default:2},restDelta:{granular:.005,default:.5},minDuration:.01,maxDuration:10,minDamping:.05,maxDamping:1},Hc=.001;function Uc({duration:e=$.duration,bounce:t=$.bounce,velocity:n=$.velocity,mass:r=$.mass}){let i,a;Gr(e<=Yr($.maxDuration),`Spring duration must be 10 seconds or less`);let o=1-t;o=aa($.minDamping,$.maxDamping,o),e=aa($.minDuration,$.maxDuration,Xr(e)),o<1?(i=t=>{let r=t*o,i=r*e,a=r-n,s=Kc(t,o),c=Math.exp(-i);return Hc-a/s*c},a=t=>{let r=t*o*e,a=r*n+n,s=o**2*t**2*e,c=Math.exp(-r),l=Kc(t**2,o);return(-i(t)+Hc>0?-1:1)*((a-s)*c)/l}):(i=t=>-.001+Math.exp(-t*e)*((t-n)*e+1),a=t=>Math.exp(-t*e)*((n-t)*(e*e)));let s=5/e,c=Gc(i,a,s);if(e=Yr(e),isNaN(c))return{stiffness:$.stiffness,damping:$.damping,duration:e};{let t=c**2*r;return{stiffness:t,damping:o*2*Math.sqrt(r*t),duration:e}}}var Wc=12;function Gc(e,t,n){let r=n;for(let n=1;ne[t]!==void 0)}function Xc(e){let t={velocity:$.velocity,stiffness:$.stiffness,damping:$.damping,mass:$.mass,isResolvedFromDuration:!1,...e};if(!Yc(e,Jc)&&Yc(e,qc))if(e.visualDuration){let n=e.visualDuration,r=2*Math.PI/(n*1.2),i=r*r,a=2*aa(.05,1,1-(e.bounce||0))*Math.sqrt(i);t={...t,mass:$.mass,stiffness:i,damping:a}}else{let n=Uc(e);t={...t,...n,mass:$.mass},t.isResolvedFromDuration=!0}return t}function Zc(e=$.visualDuration,t=$.bounce){let n=typeof e==`object`?e:{visualDuration:e,keyframes:[0,1],bounce:t},{restSpeed:r,restDelta:i}=n,a=n.keyframes[0],o=n.keyframes[n.keyframes.length-1],s={done:!1,value:a},{stiffness:c,damping:l,mass:u,duration:d,velocity:f,isResolvedFromDuration:p}=Xc({...n,velocity:-Xr(n.velocity||0)}),m=f||0,h=l/(2*Math.sqrt(c*u)),g=o-a,_=Xr(Math.sqrt(c/u)),v=Math.abs(g)<5;r||=v?$.restSpeed.granular:$.restSpeed.default,i||=v?$.restDelta.granular:$.restDelta.default;let y;if(h<1){let e=Kc(_,h);y=t=>{let n=Math.exp(-h*_*t);return o-n*((m+h*_*g)/e*Math.sin(e*t)+g*Math.cos(e*t))}}else if(h===1)y=e=>o-Math.exp(-_*e)*(g+(m+_*g)*e);else{let e=_*Math.sqrt(h*h-1);y=t=>{let n=Math.exp(-h*_*t),r=Math.min(e*t,300);return o-n*((m+h*_*g)*Math.sinh(r)+e*g*Math.cosh(r))/e}}let b={calculatedDuration:p&&d||null,next:e=>{let t=y(e);if(p)s.done=e>=d;else{let n=0;h<1&&(n=e===0?Yr(m):Q(y,e,t));let a=Math.abs(n)<=r,c=Math.abs(o-t)<=i;s.done=a&&c}return s.value=s.done?o:t,s},toString:()=>{let e=Math.min(ro(b),no),t=uo(t=>b.next(e*t).value,e,30);return e+`ms `+t}};return b}function Qc({keyframes:e,velocity:t=0,power:n=.8,timeConstant:r=325,bounceDamping:i=10,bounceStiffness:a=500,modifyTarget:o,min:s,max:c,restDelta:l=.5,restSpeed:u}){let d=e[0],f={done:!1,value:d},p=e=>s!==void 0&&ec,m=e=>s===void 0?c:c===void 0||Math.abs(s-e)-h*Math.exp(-e/r),y=e=>_+v(e),b=e=>{let t=v(e),n=y(e);f.done=Math.abs(t)<=l,f.value=f.done?_:n},x,S,C=e=>{p(f.value)&&(x=e,S=Zc({keyframes:[f.value,m(f.value)],velocity:Q(y,e,f.value),damping:i,stiffness:a,restDelta:l,restSpeed:u}))};return C(0),{calculatedDuration:null,next:e=>{let t=!1;return!S&&x===void 0&&(t=!0,b(e),C(e)),x!==void 0&&e>=x?S.next(e-x):(!t&&b(e),f)}}}var $c=$o(.42,0,1,1),el=$o(0,0,.58,1),tl=$o(.42,0,.58,1),nl=e=>Array.isArray(e)&&typeof e[0]!=`number`,rl={linear:Wr,easeIn:$c,easeInOut:tl,easeOut:el,circIn:os,circInOut:cs,circOut:ss,backIn:rs,backInOut:is,backOut:ns,anticipate:as},il=e=>{if(oo(e)){Kr(e.length===4,`Cubic bezier arrays must contain four numerical values.`);let[t,n,r,i]=e;return $o(t,n,r,i)}else if(typeof e==`string`)return Kr(rl[e]!==void 0,`Invalid easing type '${e}'`),rl[e];return e};function al(e,t,n){let r=[],i=n||Bc,a=e.length-1;for(let n=0;nt[0];if(a===2&&t[0]===t[1])return()=>t[1];let o=e[0]===e[1];e[0]>e[a-1]&&(e=[...e].reverse(),t=[...t].reverse());let s=al(t,r,i),c=s.length,l=n=>{if(o&&n1)for(;rl(aa(e[0],e[a-1],t)):l}function sl(e,t){let n=e[e.length-1];for(let r=1;r<=t;r++){let i=Jr(0,t,r);e.push(Z(n,1,i))}}function cl(e){let t=[0];return sl(t,e.length-1),t}function ll(e,t){return e.map(e=>e*t)}function ul(e,t){return e.map(()=>t||tl).splice(0,e.length-1)}function dl({duration:e=300,keyframes:t,times:n,ease:r=`easeInOut`}){let i=nl(r)?r.map(il):il(r),a={done:!1,value:t[0]},o=ol(ll(n&&n.length===t.length?n:cl(t),e),t,{ease:Array.isArray(i)?i:ul(t,i)});return{calculatedDuration:e,next:t=>(a.value=o(t),a.done=t>=e,a)}}var fl=e=>{let t=({timestamp:t})=>e(t);return{start:()=>B.update(t,!0),stop:()=>ni(t),now:()=>ri.isProcessing?ri.timestamp:No.now()}},pl={decay:Qc,inertia:Qc,tween:dl,keyframes:dl,spring:Zc},ml=e=>e/100,hl=class extends xc{constructor(e){super(e),this.holdTime=null,this.cancelTime=null,this.currentTime=0,this.playbackSpeed=1,this.pendingPlayState=`running`,this.startTime=null,this.state=`idle`,this.stop=()=>{if(this.resolver.cancel(),this.isStopped=!0,this.state===`idle`)return;this.teardown();let{onStop:e}=this.options;e&&e()};let{name:t,motionValue:n,element:r,keyframes:i}=this.options,a=r?.KeyframeResolver||oc,o=(e,t)=>this.onKeyframesResolved(e,t);this.resolver=new a(i,o,t,n,r),this.resolver.scheduleResolve()}flatten(){super.flatten(),this._resolved&&Object.assign(this._resolved,this.initPlayback(this._resolved.keyframes))}initPlayback(e){let{type:t=`keyframes`,repeat:n=0,repeatDelay:r=0,repeatType:i,velocity:a=0}=this.options,o=io(t)?t:pl[t]||dl,s,c;o!==dl&&typeof e[0]!=`number`&&(s=jc(ml,Bc(e[0],e[1])),e=[0,100]);let l=o({...this.options,keyframes:e});i===`mirror`&&(c=o({...this.options,keyframes:[...e].reverse(),velocity:-a})),l.calculatedDuration===null&&(l.calculatedDuration=ro(l));let{calculatedDuration:u}=l,d=u+r,f=d*(n+1)-r;return{generator:l,mirroredGenerator:c,mapPercentToKeyframes:s,calculatedDuration:u,resolvedDuration:d,totalDuration:f}}onPostResolved(){let{autoplay:e=!0}=this.options;this.play(),this.pendingPlayState===`paused`||!e?this.pause():this.state=this.pendingPlayState}tick(e,t=!1){let{resolved:n}=this;if(!n){let{keyframes:e}=this.options;return{done:!0,value:e[e.length-1]}}let{finalKeyframe:r,generator:i,mirroredGenerator:a,mapPercentToKeyframes:o,keyframes:s,calculatedDuration:c,totalDuration:l,resolvedDuration:u}=n;if(this.startTime===null)return i.next(0);let{delay:d,repeat:f,repeatType:p,repeatDelay:m,onUpdate:h}=this.options;this.speed>0?this.startTime=Math.min(this.startTime,e):this.speed<0&&(this.startTime=Math.min(e-l/this.speed,this.startTime)),t?this.currentTime=e:this.holdTime===null?this.currentTime=Math.round(e-this.startTime)*this.speed:this.currentTime=this.holdTime;let g=this.currentTime-d*(this.speed>=0?1:-1),_=this.speed>=0?g<0:g>l;this.currentTime=Math.max(g,0),this.state===`finished`&&this.holdTime===null&&(this.currentTime=l);let v=this.currentTime,y=i;if(f){let e=Math.min(this.currentTime,l)/u,t=Math.floor(e),n=e%1;!n&&e>=1&&(n=1),n===1&&t--,t=Math.min(t,f+1),t%2&&(p===`reverse`?(n=1-n,m&&(n-=m/u)):p===`mirror`&&(y=a)),v=aa(0,1,n)*u}let b=_?{done:!1,value:s[0]}:y.next(v);o&&(b.value=o(b.value));let{done:x}=b;!_&&c!==null&&(x=this.speed>=0?this.currentTime>=l:this.currentTime<=0);let S=this.holdTime===null&&(this.state===`finished`||this.state===`running`&&x);return S&&r!==void 0&&(b.value=yc(s,this.options,r)),h&&h(b.value),S&&this.finish(),b}get duration(){let{resolved:e}=this;return e?Xr(e.calculatedDuration):0}get time(){return Xr(this.currentTime)}set time(e){e=Yr(e),this.currentTime=e,this.holdTime!==null||this.speed===0?this.holdTime=e:this.driver&&(this.startTime=this.driver.now()-e/this.speed)}get speed(){return this.playbackSpeed}set speed(e){let t=this.playbackSpeed!==e;this.playbackSpeed=e,t&&(this.time=Xr(this.currentTime))}play(){if(this.resolver.isScheduled||this.resolver.resume(),!this._resolved){this.pendingPlayState=`running`;return}if(this.isStopped)return;let{driver:e=fl,onPlay:t,startTime:n}=this.options;this.driver||=e(e=>this.tick(e)),t&&t();let r=this.driver.now();this.holdTime===null?this.startTime?this.state===`finished`&&(this.startTime=r):this.startTime=n??this.calcStartTime():this.startTime=r-this.holdTime,this.state===`finished`&&this.updateFinishedPromise(),this.cancelTime=this.startTime,this.holdTime=null,this.state=`running`,this.driver.start()}pause(){if(!this._resolved){this.pendingPlayState=`paused`;return}this.state=`paused`,this.holdTime=this.currentTime??0}complete(){this.state!==`running`&&this.play(),this.pendingPlayState=this.state=`finished`,this.holdTime=null}finish(){this.teardown(),this.state=`finished`;let{onComplete:e}=this.options;e&&e()}cancel(){this.cancelTime!==null&&this.tick(this.cancelTime),this.teardown(),this.updateFinishedPromise()}teardown(){this.state=`idle`,this.stopDriver(),this.resolveFinishedPromise(),this.updateFinishedPromise(),this.startTime=this.cancelTime=null,this.resolver.cancel()}stopDriver(){this.driver&&=(this.driver.stop(),void 0)}sample(e){return this.startTime=0,this.tick(e,!0)}},gl=new Set([`opacity`,`clipPath`,`filter`,`transform`]);function _l(e,t,n,{delay:r=0,duration:i=300,repeat:a=0,repeatType:o=`loop`,ease:s=`easeInOut`,times:c}={}){let l={[t]:n};c&&(l.offset=c);let u=ho(s,i);return Array.isArray(u)&&(l.easing=u),e.animate(l,{delay:r,duration:i,easing:Array.isArray(u)?`linear`:u,fill:`both`,iterations:a+1,direction:o===`reverse`?`alternate`:`normal`})}var vl=qr(()=>Object.hasOwnProperty.call(Element.prototype,`animate`)),yl=10,bl=2e4;function xl(e){return io(e.type)||e.type===`spring`||!fo(e.ease)}function Sl(e,t){let n=new hl({...t,keyframes:e,repeat:0,delay:0,isGenerator:!0}),r={done:!1,value:e[0]},i=[],a=0;for(;!r.done&&athis.onKeyframesResolved(e,t),t,n,r),this.resolver.scheduleResolve()}initPlayback(e,t){let{duration:n=300,times:r,ease:i,type:a,motionValue:o,name:s,startTime:c}=this.options;if(!o.owner||!o.owner.current)return!1;if(typeof i==`string`&&lo()&&wl(i)&&(i=Cl[i]),xl(this.options)){let{onComplete:t,onUpdate:o,motionValue:s,element:c,...l}=this.options,u=Sl(e,l);e=u.keyframes,e.length===1&&(e[1]=e[0]),n=u.duration,r=u.times,i=u.ease,a=`keyframes`}let l=_l(o.owner.current,s,e,{...this.options,duration:n,times:r,ease:i});return l.startTime=c??this.calcStartTime(),this.pendingTimeline?(ao(l,this.pendingTimeline),this.pendingTimeline=void 0):l.onfinish=()=>{let{onComplete:n}=this.options;o.set(yc(e,this.options,t)),n&&n(),this.cancel(),this.resolveFinishedPromise()},{animation:l,duration:n,times:r,type:a,ease:i,keyframes:e}}get duration(){let{resolved:e}=this;if(!e)return 0;let{duration:t}=e;return Xr(t)}get time(){let{resolved:e}=this;if(!e)return 0;let{animation:t}=e;return Xr(t.currentTime||0)}set time(e){let{resolved:t}=this;if(!t)return;let{animation:n}=t;n.currentTime=Yr(e)}get speed(){let{resolved:e}=this;if(!e)return 1;let{animation:t}=e;return t.playbackRate}set speed(e){let{resolved:t}=this;if(!t)return;let{animation:n}=t;n.playbackRate=e}get state(){let{resolved:e}=this;if(!e)return`idle`;let{animation:t}=e;return t.playState}get startTime(){let{resolved:e}=this;if(!e)return null;let{animation:t}=e;return t.startTime}attachTimeline(e){if(!this._resolved)this.pendingTimeline=e;else{let{resolved:t}=this;if(!t)return Wr;let{animation:n}=t;ao(n,e)}return Wr}play(){if(this.isStopped)return;let{resolved:e}=this;if(!e)return;let{animation:t}=e;t.playState===`finished`&&this.updateFinishedPromise(),t.play()}pause(){let{resolved:e}=this;if(!e)return;let{animation:t}=e;t.pause()}stop(){if(this.resolver.cancel(),this.isStopped=!0,this.state===`idle`)return;this.resolveFinishedPromise(),this.updateFinishedPromise();let{resolved:e}=this;if(!e)return;let{animation:t,keyframes:n,duration:r,type:i,ease:a,times:o}=e;if(t.playState===`idle`||t.playState===`finished`)return;if(this.time){let{motionValue:e,onUpdate:t,onComplete:s,element:c,...l}=this.options,u=new hl({...l,keyframes:n,duration:r,type:i,ease:a,times:o,isGenerator:!0}),d=Yr(this.time);e.setWithVelocity(u.sample(d-yl).value,u.sample(d).value,yl)}let{onStop:s}=this.options;s&&s(),this.cancel()}complete(){let{resolved:e}=this;e&&e.animation.finish()}cancel(){let{resolved:e}=this;e&&e.animation.cancel()}static supports(e){let{motionValue:t,name:n,repeatDelay:r,repeatType:i,damping:a,type:o}=e;if(!t||!t.owner||!(t.owner.current instanceof HTMLElement))return!1;let{onUpdate:s,transformTemplate:c}=t.owner.getProps();return vl()&&n&&gl.has(n)&&!s&&!c&&!r&&i!==`mirror`&&a!==0&&o!==`inertia`}},El={type:`spring`,stiffness:500,damping:25,restSpeed:10},Dl=e=>({type:`spring`,stiffness:550,damping:e===0?2*Math.sqrt(550):30,restSpeed:10}),Ol={type:`keyframes`,duration:.8},kl={type:`keyframes`,ease:[.25,.1,.35,1],duration:.3},Al=(e,{keyframes:t})=>t.length>2?Ol:Qi.has(e)?e.startsWith(`scale`)?Dl(t[1]):El:kl;function jl({when:e,delay:t,delayChildren:n,staggerChildren:r,staggerDirection:i,repeat:a,repeatType:o,repeatDelay:s,from:c,elapsed:l,...u}){return!!Object.keys(u).length}var Ml=(e,t,n,r={},i,a)=>o=>{let s=to(r,e)||{},c=s.delay||r.delay||0,{elapsed:l=0}=r;l-=Yr(c);let u={keyframes:Array.isArray(n)?n:[null,n],ease:`easeOut`,velocity:t.getVelocity(),...s,delay:-l,onUpdate:e=>{t.set(e),s.onUpdate&&s.onUpdate(e)},onComplete:()=>{o(),s.onComplete&&s.onComplete()},name:e,motionValue:t,element:a?void 0:i};jl(s)||(u={...u,...Al(e,u)}),u.duration&&=Yr(u.duration),u.repeatDelay&&=Yr(u.repeatDelay),u.from!==void 0&&(u.keyframes[0]=u.from);let d=!1;if((u.type===!1||u.duration===0&&!u.repeatDelay)&&(u.duration=0,u.delay===0&&(d=!0)),(Jo.current||Zr.skipAnimations)&&(d=!0,u.duration=0,u.delay=0),d&&!a&&t.get()!==void 0){let e=yc(u.keyframes,s);if(e!==void 0)return B.update(()=>{u.onUpdate(e),u.onComplete()}),new eo([])}return!a&&Tl.supports(u)?new Tl(u):new hl(u)};function Nl({protectedKeys:e,needsAnimating:t},n){let r=e.hasOwnProperty(n)&&t[n]!==!0;return t[n]=!1,r}function Pl(e,t,{delay:n=0,transitionOverride:r,type:i}={}){let{transition:a=e.getDefaultTransition(),transitionEnd:o,...s}=t;r&&(a=r);let c=[],l=i&&e.animationState&&e.animationState.getState()[i];for(let t in s){let r=e.getValue(t,e.latestValues[t]??null),i=s[t];if(i===void 0||l&&Nl(l,t))continue;let o={delay:n,...to(a||{},t)},u=!1;if(window.MotionHandoffAnimation){let n=qo(e);if(n){let e=window.MotionHandoffAnimation(n,t,B);e!==null&&(o.startTime=e,u=!0)}}Ko(e,t),r.start(Ml(t,r,i,e.shouldReduceMotion&&Ao.has(t)?{type:!1}:o,e,u));let d=r.animation;d&&c.push(d)}return o&&Promise.all(c).then(()=>{B.update(()=>{o&&Wo(e,o)})}),c}function Fl(e,t,n={}){let r=Za(e,t,n.type===`exit`?e.presenceContext?.custom:void 0),{transition:i=e.getDefaultTransition()||{}}=r||{};n.transitionOverride&&(i=n.transitionOverride);let a=r?()=>Promise.all(Pl(e,r,n)):()=>Promise.resolve(),o=e.variantChildren&&e.variantChildren.size?(r=0)=>{let{delayChildren:a=0,staggerChildren:o,staggerDirection:s}=i;return Il(e,t,a+r,o,s,n)}:()=>Promise.resolve(),{when:s}=i;if(s){let[e,t]=s===`beforeChildren`?[a,o]:[o,a];return e().then(()=>t())}else return Promise.all([a(),o(n.delay)])}function Il(e,t,n=0,r=0,i=1,a){let o=[],s=(e.variantChildren.size-1)*r,c=i===1?(e=0)=>e*r:(e=0)=>s-e*r;return Array.from(e.variantChildren).sort(Ll).forEach((e,r)=>{e.notify(`AnimationStart`,t),o.push(Fl(e,t,{...a,delay:n+c(r)}).then(()=>e.notify(`AnimationComplete`,t)))}),Promise.all(o)}function Ll(e,t){return e.sortNodePosition(t)}function Rl(e,t,n={}){e.notify(`AnimationStart`,t);let r;if(Array.isArray(t)){let i=t.map(t=>Fl(e,t,n));r=Promise.all(i)}else if(typeof t==`string`)r=Fl(e,t,n);else{let i=typeof t==`function`?Za(e,t,n.custom):t;r=Promise.all(Pl(e,i,n))}return r.then(()=>{e.notify(`AnimationComplete`,t)})}var zl=xi.length;function Bl(e){if(!e)return;if(!e.isControllingVariants){let t=e.parent&&Bl(e.parent)||{};return e.props.initial!==void 0&&(t.initial=e.props.initial),t}let t={};for(let n=0;nPromise.all(t.map(({animation:t,options:n})=>Rl(e,t,n)))}function Wl(e){let t=Ul(e),n=ql(),r=!0,i=t=>(n,r)=>{let i=Za(e,r,t===`exit`?e.presenceContext?.custom:void 0);if(i){let{transition:e,transitionEnd:t,...r}=i;n={...n,...r,...t}}return n};function a(n){t=n(e)}function o(a){let{props:o}=e,s=Bl(e.parent)||{},c=[],l=new Set,u={},d=1/0;for(let t=0;td&&h,b=!1,x=Array.isArray(m)?m:[m],S=x.reduce(i(f),{});g===!1&&(S={});let{prevResolvedValues:C={}}=p,w={...C,...S},T=t=>{y=!0,l.has(t)&&(b=!0,l.delete(t)),p.needsAnimating[t]=!0;let n=e.getValue(t);n&&(n.liveStyle=!1)};for(let e in w){let t=S[e],n=C[e];if(u.hasOwnProperty(e))continue;let r=!1;r=Ki(t)&&Ki(n)?!Xa(t,n):t!==n,r?t==null?l.add(e):T(e):t!==void 0&&l.has(e)?T(e):p.protectedKeys[e]=!0}p.prevProp=m,p.prevResolvedValues=S,p.isActive&&(u={...u,...S}),r&&e.blockInitialAnimation&&(y=!1),y&&(!(_&&v)||b)&&c.push(...x.map(e=>({animation:e,options:{type:f}})))}if(l.size){let t={};l.forEach(n=>{let r=e.getBaseTarget(n),i=e.getValue(n);i&&(i.liveStyle=!0),t[n]=r??null}),c.push({animation:t})}let f=!!c.length;return r&&(o.initial===!1||o.initial===o.animate)&&!e.manuallyAnimateOnMount&&(f=!1),r=!1,f?t(c):Promise.resolve()}function s(t,r){var i;if(n[t].isActive===r)return Promise.resolve();(i=e.variantChildren)==null||i.forEach(e=>e.animationState?.setActive(t,r)),n[t].isActive=r;let a=o(t);for(let e in n)n[e].protectedKeys={};return a}return{animateChanges:o,setActive:s,setAnimateFunction:a,getState:()=>n,reset:()=>{n=ql(),r=!0}}}function Gl(e,t){return typeof t==`string`?t!==e:Array.isArray(t)?!Xa(t,e):!1}function Kl(e=!1){return{isActive:e,protectedKeys:{},needsAnimating:{},prevResolvedValues:{}}}function ql(){return{animate:Kl(!0),whileInView:Kl(),whileHover:Kl(),whileTap:Kl(),whileDrag:Kl(),whileFocus:Kl(),exit:Kl()}}var Jl=class{constructor(e){this.isMounted=!1,this.node=e}update(){}},Yl=class extends Jl{constructor(e){super(e),e.animationState||=Wl(e)}updateAnimationControlsSubscription(){let{animate:e}=this.node.getProps();yi(e)&&(this.unmountControls=e.subscribe(this.node))}mount(){this.updateAnimationControlsSubscription()}update(){let{animate:e}=this.node.getProps(),{animate:t}=this.node.prevProps||{};e!==t&&this.updateAnimationControlsSubscription()}unmount(){var e;this.node.animationState.reset(),(e=this.unmountControls)==null||e.call(this)}},Xl=0,Zl={animation:{Feature:Yl},exit:{Feature:class extends Jl{constructor(){super(...arguments),this.id=Xl++}update(){if(!this.node.presenceContext)return;let{isPresent:e,onExitComplete:t}=this.node.presenceContext,{isPresent:n}=this.node.prevPresenceContext||{};if(!this.node.animationState||e===n)return;let r=this.node.animationState.setActive(`exit`,!e);t&&!e&&r.then(()=>t(this.id))}mount(){let{register:e}=this.node.presenceContext||{};e&&(this.unmount=e(this.id))}unmount(){}}}};function Ql(e,t,n,r={passive:!0}){return e.addEventListener(t,n,r),()=>e.removeEventListener(t,n)}function $l(e){return{point:{x:e.pageX,y:e.pageY}}}var eu=e=>t=>So(t)&&e(t,$l(t));function tu(e,t,n,r){return Ql(e,t,eu(n),r)}var nu=(e,t)=>Math.abs(e-t);function ru(e,t){let n=nu(e.x,t.x),r=nu(e.y,t.y);return Math.sqrt(n**2+r**2)}var iu=class{constructor(e,t,{transformPagePoint:n,contextWindow:r,dragSnapToOrigin:i=!1}={}){if(this.startEvent=null,this.lastMoveEvent=null,this.lastMoveEventInfo=null,this.handlers={},this.contextWindow=window,this.updatePoint=()=>{if(!(this.lastMoveEvent&&this.lastMoveEventInfo))return;let e=su(this.lastMoveEventInfo,this.history),t=this.startEvent!==null,n=ru(e.offset,{x:0,y:0})>=3;if(!t&&!n)return;let{point:r}=e,{timestamp:i}=ri;this.history.push({...r,timestamp:i});let{onStart:a,onMove:o}=this.handlers;t||(a&&a(this.lastMoveEvent,e),this.startEvent=this.lastMoveEvent),o&&o(this.lastMoveEvent,e)},this.handlePointerMove=(e,t)=>{this.lastMoveEvent=e,this.lastMoveEventInfo=au(t,this.transformPagePoint),B.update(this.updatePoint,!0)},this.handlePointerUp=(e,t)=>{this.end();let{onEnd:n,onSessionEnd:r,resumeAnimation:i}=this.handlers;if(this.dragSnapToOrigin&&i&&i(),!(this.lastMoveEvent&&this.lastMoveEventInfo))return;let a=su(e.type===`pointercancel`?this.lastMoveEventInfo:au(t,this.transformPagePoint),this.history);this.startEvent&&n&&n(e,a),r&&r(e,a)},!So(e))return;this.dragSnapToOrigin=i,this.handlers=t,this.transformPagePoint=n,this.contextWindow=r||window;let a=au($l(e),this.transformPagePoint),{point:o}=a,{timestamp:s}=ri;this.history=[{...o,timestamp:s}];let{onSessionStart:c}=t;c&&c(e,su(a,this.history)),this.removeListeners=jc(tu(this.contextWindow,`pointermove`,this.handlePointerMove),tu(this.contextWindow,`pointerup`,this.handlePointerUp),tu(this.contextWindow,`pointercancel`,this.handlePointerUp))}updateHandlers(e){this.handlers=e}end(){this.removeListeners&&this.removeListeners(),ni(this.updatePoint)}};function au(e,t){return t?{point:t(e.point)}:e}function ou(e,t){return{x:e.x-t.x,y:e.y-t.y}}function su({point:e},t){return{point:e,delta:ou(e,lu(t)),offset:ou(e,cu(t)),velocity:uu(t,.1)}}function cu(e){return e[0]}function lu(e){return e[e.length-1]}function uu(e,t){if(e.length<2)return{x:0,y:0};let n=e.length-1,r=null,i=lu(e);for(;n>=0&&(r=e[n],!(i.timestamp-r.timestamp>Yr(t)));)n--;if(!r)return{x:0,y:0};let a=Xr(i.timestamp-r.timestamp);if(a===0)return{x:0,y:0};let o={x:(i.x-r.x)/a,y:(i.y-r.y)/a};return o.x===1/0&&(o.x=0),o.y===1/0&&(o.y=0),o}var du=.9999,fu=1.0001,pu=-.01,mu=.01;function hu(e){return e.max-e.min}function gu(e,t,n){return Math.abs(e-t)<=n}function _u(e,t,n,r=.5){e.origin=r,e.originPoint=Z(t.min,t.max,e.origin),e.scale=hu(n)/hu(t),e.translate=Z(n.min,n.max,e.origin)-e.originPoint,(e.scale>=du&&e.scale<=fu||isNaN(e.scale))&&(e.scale=1),(e.translate>=pu&&e.translate<=mu||isNaN(e.translate))&&(e.translate=0)}function vu(e,t,n,r){_u(e.x,t.x,n.x,r?r.originX:void 0),_u(e.y,t.y,n.y,r?r.originY:void 0)}function yu(e,t,n){e.min=n.min+t.min,e.max=e.min+hu(t)}function bu(e,t,n){yu(e.x,t.x,n.x),yu(e.y,t.y,n.y)}function xu(e,t,n){e.min=t.min-n.min,e.max=e.min+hu(t)}function Su(e,t,n){xu(e.x,t.x,n.x),xu(e.y,t.y,n.y)}function Cu(e,{min:t,max:n},r){return t!==void 0&&en&&(e=r?Z(n,e,r.max):Math.min(e,n)),e}function wu(e,t,n){return{min:t===void 0?void 0:e.min+t,max:n===void 0?void 0:e.max+n-(e.max-e.min)}}function Tu(e,{top:t,left:n,bottom:r,right:i}){return{x:wu(e.x,n,i),y:wu(e.y,t,r)}}function Eu(e,t){let n=t.min-e.min,r=t.max-e.max;return t.max-t.minr?n=Jr(t.min,t.max-r,e.min):r>i&&(n=Jr(e.min,e.max-i,t.min)),aa(0,1,n)}function ku(e,t){let n={};return t.min!==void 0&&(n.min=t.min-e.min),t.max!==void 0&&(n.max=t.max-e.min),n}var Au=.35;function ju(e=Au){return e===!1?e=0:e===!0&&(e=Au),{x:Mu(e,`left`,`right`),y:Mu(e,`top`,`bottom`)}}function Mu(e,t,n){return{min:Nu(e,t),max:Nu(e,n)}}function Nu(e,t){return typeof e==`number`?e:e[t]||0}var Pu=()=>({translate:0,scale:1,origin:0,originPoint:0}),Fu=()=>({x:Pu(),y:Pu()}),Iu=()=>({min:0,max:0}),Lu=()=>({x:Iu(),y:Iu()});function Ru(e){return[e(`x`),e(`y`)]}function zu({top:e,left:t,right:n,bottom:r}){return{x:{min:t,max:n},y:{min:e,max:r}}}function Bu({x:e,y:t}){return{top:t.min,right:e.max,bottom:t.max,left:e.min}}function Vu(e,t){if(!t)return e;let n=t({x:e.left,y:e.top}),r=t({x:e.right,y:e.bottom});return{top:n.y,left:n.x,bottom:r.y,right:r.x}}function Hu(e){return e===void 0||e===1}function Uu({scale:e,scaleX:t,scaleY:n}){return!Hu(e)||!Hu(t)||!Hu(n)}function Wu(e){return Uu(e)||Gu(e)||e.z||e.rotate||e.rotateX||e.rotateY||e.skewX||e.skewY}function Gu(e){return Ku(e.x)||Ku(e.y)}function Ku(e){return e&&e!==`0%`}function qu(e,t,n){return n+t*(e-n)}function Ju(e,t,n,r,i){return i!==void 0&&(e=qu(e,i,r)),qu(e,n,r)+t}function Yu(e,t=0,n=1,r,i){e.min=Ju(e.min,t,n,r,i),e.max=Ju(e.max,t,n,r,i)}function Xu(e,{x:t,y:n}){Yu(e.x,t.translate,t.scale,t.originPoint),Yu(e.y,n.translate,n.scale,n.originPoint)}var Zu=.999999999999,Qu=1.0000000000001;function $u(e,t,n,r=!1){let i=n.length;if(!i)return;t.x=t.y=1;let a,o;for(let s=0;sZu&&(t.x=1),t.yZu&&(t.y=1)}function ed(e,t){e.min+=t,e.max+=t}function td(e,t,n,r,i=.5){Yu(e,t,n,Z(e.min,e.max,i),r)}function nd(e,t){td(e.x,t.x,t.scaleX,t.scale,t.originX),td(e.y,t.y,t.scaleY,t.scale,t.originY)}function rd(e,t){return zu(Vu(e.getBoundingClientRect(),t))}function id(e,t,n){let r=rd(e,n),{scroll:i}=t;return i&&(ed(r.x,i.offset.x),ed(r.y,i.offset.y)),r}var ad=({current:e})=>e?e.ownerDocument.defaultView:null,od=new WeakMap,sd=class{constructor(e){this.openDragLock=null,this.isDragging=!1,this.currentDirection=null,this.originPoint={x:0,y:0},this.constraints=!1,this.hasMutatedConstraints=!1,this.elastic=Lu(),this.visualElement=e}start(e,{snapToCursor:t=!1}={}){let{presenceContext:n}=this.visualElement;if(n&&n.isPresent===!1)return;let r=e=>{let{dragSnapToOrigin:n}=this.getProps();n?this.pauseAnimation():this.stopAnimation(),t&&this.snapToCursor($l(e).point)},i=(e,t)=>{let{drag:n,dragPropagation:r,onDragStart:i}=this.getProps();if(n&&!r&&(this.openDragLock&&this.openDragLock(),this.openDragLock=ko(n),!this.openDragLock))return;this.isDragging=!0,this.currentDirection=null,this.resolveConstraints(),this.visualElement.projection&&(this.visualElement.projection.isAnimationBlocked=!0,this.visualElement.projection.target=void 0),Ru(e=>{let t=this.getAxisMotionValue(e).get()||0;if(da.test(t)){let{projection:n}=this.visualElement;if(n&&n.layout){let r=n.layout.layoutBox[e];r&&(t=hu(r)*(parseFloat(t)/100))}}this.originPoint[e]=t}),i&&B.postRender(()=>i(e,t)),Ko(this.visualElement,`transform`);let{animationState:a}=this.visualElement;a&&a.setActive(`whileDrag`,!0)},a=(e,t)=>{let{dragPropagation:n,dragDirectionLock:r,onDirectionLock:i,onDrag:a}=this.getProps();if(!n&&!this.openDragLock)return;let{offset:o}=t;if(r&&this.currentDirection===null){this.currentDirection=ld(o),this.currentDirection!==null&&i&&i(this.currentDirection);return}this.updateAxis(`x`,t.point,o),this.updateAxis(`y`,t.point,o),this.visualElement.render(),a&&a(e,t)},o=(e,t)=>this.stop(e,t),s=()=>Ru(e=>this.getAnimationState(e)===`paused`&&this.getAxisMotionValue(e).animation?.play()),{dragSnapToOrigin:c}=this.getProps();this.panSession=new iu(e,{onSessionStart:r,onStart:i,onMove:a,onSessionEnd:o,resumeAnimation:s},{transformPagePoint:this.visualElement.getTransformPagePoint(),dragSnapToOrigin:c,contextWindow:ad(this.visualElement)})}stop(e,t){let n=this.isDragging;if(this.cancel(),!n)return;let{velocity:r}=t;this.startAnimation(r);let{onDragEnd:i}=this.getProps();i&&B.postRender(()=>i(e,t))}cancel(){this.isDragging=!1;let{projection:e,animationState:t}=this.visualElement;e&&(e.isAnimationBlocked=!1),this.panSession&&this.panSession.end(),this.panSession=void 0;let{dragPropagation:n}=this.getProps();!n&&this.openDragLock&&(this.openDragLock(),this.openDragLock=null),t&&t.setActive(`whileDrag`,!1)}updateAxis(e,t,n){let{drag:r}=this.getProps();if(!n||!cd(e,r,this.currentDirection))return;let i=this.getAxisMotionValue(e),a=this.originPoint[e]+n[e];this.constraints&&this.constraints[e]&&(a=Cu(a,this.constraints[e],this.elastic[e])),i.set(a)}resolveConstraints(){let{dragConstraints:e,dragElastic:t}=this.getProps(),n=this.visualElement.projection&&!this.visualElement.projection.layout?this.visualElement.projection.measure(!1):this.visualElement.projection?.layout,r=this.constraints;e&&Oi(e)?this.constraints||=this.resolveRefConstraints():e&&n?this.constraints=Tu(n.layoutBox,e):this.constraints=!1,this.elastic=ju(t),r!==this.constraints&&n&&this.constraints&&!this.hasMutatedConstraints&&Ru(e=>{this.constraints!==!1&&this.getAxisMotionValue(e)&&(this.constraints[e]=ku(n.layoutBox[e],this.constraints[e]))})}resolveRefConstraints(){let{dragConstraints:e,onMeasureDragConstraints:t}=this.getProps();if(!e||!Oi(e))return!1;let n=e.current;Kr(n!==null,"If `dragConstraints` is set as a React ref, that ref must be passed to another component's `ref` prop.");let{projection:r}=this.visualElement;if(!r||!r.layout)return!1;let i=id(n,r.root,this.visualElement.getTransformPagePoint()),a=Du(r.layout.layoutBox,i);if(t){let e=t(Bu(a));this.hasMutatedConstraints=!!e,e&&(a=zu(e))}return a}startAnimation(e){let{drag:t,dragMomentum:n,dragElastic:r,dragTransition:i,dragSnapToOrigin:a,onDragTransitionEnd:o}=this.getProps(),s=this.constraints||{},c=Ru(o=>{if(!cd(o,t,this.currentDirection))return;let c=s&&s[o]||{};a&&(c={min:0,max:0});let l=r?200:1e6,u=r?40:1e7,d={type:`inertia`,velocity:n?e[o]:0,bounceStiffness:l,bounceDamping:u,timeConstant:750,restDelta:1,restSpeed:10,...i,...c};return this.startAxisValueAnimation(o,d)});return Promise.all(c).then(o)}startAxisValueAnimation(e,t){let n=this.getAxisMotionValue(e);return Ko(this.visualElement,e),n.start(Ml(e,n,0,t,this.visualElement,!1))}stopAnimation(){Ru(e=>this.getAxisMotionValue(e).stop())}pauseAnimation(){Ru(e=>this.getAxisMotionValue(e).animation?.pause())}getAnimationState(e){return this.getAxisMotionValue(e).animation?.state}getAxisMotionValue(e){let t=`_drag${e.toUpperCase()}`,n=this.visualElement.getProps();return n[t]||this.visualElement.getValue(e,(n.initial?n.initial[e]:void 0)||0)}snapToCursor(e){Ru(t=>{let{drag:n}=this.getProps();if(!cd(t,n,this.currentDirection))return;let{projection:r}=this.visualElement,i=this.getAxisMotionValue(t);if(r&&r.layout){let{min:n,max:a}=r.layout.layoutBox[t];i.set(e[t]-Z(n,a,.5))}})}scalePositionWithinConstraints(){if(!this.visualElement.current)return;let{drag:e,dragConstraints:t}=this.getProps(),{projection:n}=this.visualElement;if(!Oi(t)||!n||!this.constraints)return;this.stopAnimation();let r={x:0,y:0};Ru(e=>{let t=this.getAxisMotionValue(e);if(t&&this.constraints!==!1){let n=t.get();r[e]=Ou({min:n,max:n},this.constraints[e])}});let{transformTemplate:i}=this.visualElement.getProps();this.visualElement.current.style.transform=i?i({},``):`none`,n.root&&n.root.updateScroll(),n.updateLayout(),this.resolveConstraints(),Ru(t=>{if(!cd(t,e,null))return;let n=this.getAxisMotionValue(t),{min:i,max:a}=this.constraints[t];n.set(Z(i,a,r[t]))})}addListeners(){if(!this.visualElement.current)return;od.set(this.visualElement,this);let e=this.visualElement.current,t=tu(e,`pointerdown`,e=>{let{drag:t,dragListener:n=!0}=this.getProps();t&&n&&this.start(e)}),n=()=>{let{dragConstraints:e}=this.getProps();Oi(e)&&e.current&&(this.constraints=this.resolveRefConstraints())},{projection:r}=this.visualElement,i=r.addEventListener(`measure`,n);r&&!r.layout&&(r.root&&r.root.updateScroll(),r.updateLayout()),B.read(n);let a=Ql(window,`resize`,()=>this.scalePositionWithinConstraints()),o=r.addEventListener(`didUpdate`,(({delta:e,hasLayoutChanged:t})=>{this.isDragging&&t&&(Ru(t=>{let n=this.getAxisMotionValue(t);n&&(this.originPoint[t]+=e[t].translate,n.set(n.get()+e[t].translate))}),this.visualElement.render())}));return()=>{a(),t(),i(),o&&o()}}getProps(){let e=this.visualElement.getProps(),{drag:t=!1,dragDirectionLock:n=!1,dragPropagation:r=!1,dragConstraints:i=!1,dragElastic:a=Au,dragMomentum:o=!0}=e;return{...e,drag:t,dragDirectionLock:n,dragPropagation:r,dragConstraints:i,dragElastic:a,dragMomentum:o}}};function cd(e,t,n){return(t===!0||t===e)&&(n===null||n===e)}function ld(e,t=10){let n=null;return Math.abs(e.y)>t?n=`y`:Math.abs(e.x)>t&&(n=`x`),n}var ud=class extends Jl{constructor(e){super(e),this.removeGroupControls=Wr,this.removeListeners=Wr,this.controls=new sd(e)}mount(){let{dragControls:e}=this.node.getProps();e&&(this.removeGroupControls=e.subscribe(this.controls)),this.removeListeners=this.controls.addListeners()||Wr}unmount(){this.removeGroupControls(),this.removeListeners()}},dd=e=>(t,n)=>{e&&B.postRender(()=>e(t,n))},fd=class extends Jl{constructor(){super(...arguments),this.removePointerDownListener=Wr}onPointerDown(e){this.session=new iu(e,this.createPanHandlers(),{transformPagePoint:this.node.getTransformPagePoint(),contextWindow:ad(this.node)})}createPanHandlers(){let{onPanSessionStart:e,onPanStart:t,onPan:n,onPanEnd:r}=this.node.getProps();return{onSessionStart:dd(e),onStart:dd(t),onMove:n,onEnd:(e,t)=>{delete this.session,r&&B.postRender(()=>r(e,t))}}}mount(){this.removePointerDownListener=tu(this.node.current,`pointerdown`,e=>this.onPointerDown(e))}update(){this.session&&this.session.updateHandlers(this.createPanHandlers())}unmount(){this.removePointerDownListener(),this.session&&this.session.end()}},pd={hasAnimatedSinceResize:!0,hasEverUpdated:!1};function md(e,t){return t.max===t.min?0:e/(t.max-t.min)*100}var hd={correct:(e,t)=>{if(!t.target)return e;if(typeof e==`string`)if(G.test(e))e=parseFloat(e);else return e;return`${md(e,t.target.x)}% ${md(e,t.target.y)}%`}},gd={correct:(e,{treeScale:t,projectionDelta:n})=>{let r=e,i=Ls.parse(e);if(i.length>5)return r;let a=Ls.createTransformer(e),o=typeof i[0]==`number`?0:1,s=n.x.scale*t.x,c=n.y.scale*t.y;i[0+o]/=s,i[1+o]/=c;let l=Z(s,c,.5);return typeof i[2+o]==`number`&&(i[2+o]/=l),typeof i[3+o]==`number`&&(i[3+o]/=l),a(i)}},_d=class extends h.Component{componentDidMount(){let{visualElement:e,layoutGroup:t,switchLayoutGroup:n,layoutId:r}=this.props,{projection:i}=e;Fa(yd),i&&(t.group&&t.group.add(i),n&&n.register&&r&&n.register(i),i.root.didUpdate(),i.addEventListener(`animationComplete`,()=>{this.safeToRemove()}),i.setOptions({...i.options,onExitComplete:()=>this.safeToRemove()})),pd.hasEverUpdated=!0}getSnapshotBeforeUpdate(e){let{layoutDependency:t,visualElement:n,drag:r,isPresent:i}=this.props,a=n.projection;return a?(a.isPresent=i,r||e.layoutDependency!==t||t===void 0?a.willUpdate():this.safeToRemove(),e.isPresent!==i&&(i?a.promote():a.relegate()||B.postRender(()=>{let e=a.getStack();(!e||!e.members.length)&&this.safeToRemove()})),null):null}componentDidUpdate(){let{projection:e}=this.props.visualElement;e&&(e.root.didUpdate(),Mi.postRender(()=>{!e.currentAnimation&&e.isLead()&&this.safeToRemove()}))}componentWillUnmount(){let{visualElement:e,layoutGroup:t,switchLayoutGroup:n}=this.props,{projection:r}=e;r&&(r.scheduleCheckAfterUnmount(),t&&t.group&&t.group.remove(r),n&&n.deregister&&n.deregister(r))}safeToRemove(){let{safeToRemove:e}=this.props;e&&e()}render(){return null}};function vd(e){let[t,n]=Rr(),r=(0,h.useContext)(Ar);return(0,z.jsx)(_d,{...e,layoutGroup:r,switchLayoutGroup:(0,h.useContext)(Pi),isPresent:t,safeToRemove:n})}var yd={borderRadius:{...hd,applyTo:[`borderTopLeftRadius`,`borderTopRightRadius`,`borderBottomLeftRadius`,`borderBottomRightRadius`]},borderTopLeftRadius:hd,borderTopRightRadius:hd,borderBottomLeftRadius:hd,borderBottomRightRadius:hd,boxShadow:gd};function bd(e,t,n){let r=W(e)?e:Ho(e);return r.start(Ml(``,r,t,n)),r.animation}function xd(e){return e instanceof SVGElement&&e.tagName!==`svg`}var Sd=(e,t)=>e.depth-t.depth,Cd=class{constructor(){this.children=[],this.isDirty=!1}add(e){Po(this.children,e),this.isDirty=!0}remove(e){Fo(this.children,e),this.isDirty=!0}forEach(e){this.isDirty&&this.children.sort(Sd),this.isDirty=!1,this.children.forEach(e)}};function wd(e,t){let n=No.now(),r=({timestamp:i})=>{let a=i-n;a>=t&&(ni(r),e(a-t))};return B.read(r,!0),()=>ni(r)}var Td=[`TopLeft`,`TopRight`,`BottomLeft`,`BottomRight`],Ed=Td.length,Dd=e=>typeof e==`string`?parseFloat(e):e,Od=e=>typeof e==`number`||G.test(e);function kd(e,t,n,r,i,a){i?(e.opacity=Z(0,n.opacity===void 0?1:n.opacity,jd(r)),e.opacityExit=Z(t.opacity===void 0?1:t.opacity,0,Md(r))):a&&(e.opacity=Z(t.opacity===void 0?1:t.opacity,n.opacity===void 0?1:n.opacity,r));for(let i=0;irt?1:n(Jr(e,t,r))}function Pd(e,t){e.min=t.min,e.max=t.max}function Fd(e,t){Pd(e.x,t.x),Pd(e.y,t.y)}function Id(e,t){e.translate=t.translate,e.scale=t.scale,e.originPoint=t.originPoint,e.origin=t.origin}function Ld(e,t,n,r,i){return e-=t,e=qu(e,1/n,r),i!==void 0&&(e=qu(e,1/i,r)),e}function Rd(e,t=0,n=1,r=.5,i,a=e,o=e){if(da.test(t)&&(t=parseFloat(t),t=Z(o.min,o.max,t/100)-o.min),typeof t!=`number`)return;let s=Z(a.min,a.max,r);e===a&&(s-=t),e.min=Ld(e.min,t,n,s,i),e.max=Ld(e.max,t,n,s,i)}function zd(e,t,[n,r,i],a,o){Rd(e,t[n],t[r],t[i],t.scale,a,o)}var Bd=[`x`,`scaleX`,`originX`],Vd=[`y`,`scaleY`,`originY`];function Hd(e,t,n,r){zd(e.x,t,Bd,n?n.x:void 0,r?r.x:void 0),zd(e.y,t,Vd,n?n.y:void 0,r?r.y:void 0)}function Ud(e){return e.translate===0&&e.scale===1}function Wd(e){return Ud(e.x)&&Ud(e.y)}function Gd(e,t){return e.min===t.min&&e.max===t.max}function Kd(e,t){return Gd(e.x,t.x)&&Gd(e.y,t.y)}function qd(e,t){return Math.round(e.min)===Math.round(t.min)&&Math.round(e.max)===Math.round(t.max)}function Jd(e,t){return qd(e.x,t.x)&&qd(e.y,t.y)}function Yd(e){return hu(e.x)/hu(e.y)}function Xd(e,t){return e.translate===t.translate&&e.scale===t.scale&&e.originPoint===t.originPoint}var Zd=class{constructor(){this.members=[]}add(e){Po(this.members,e),e.scheduleRender()}remove(e){if(Fo(this.members,e),e===this.prevLead&&(this.prevLead=void 0),e===this.lead){let e=this.members[this.members.length-1];e&&this.promote(e)}}relegate(e){let t=this.members.findIndex(t=>e===t);if(t===0)return!1;let n;for(let e=t;e>=0;e--){let t=this.members[e];if(t.isPresent!==!1){n=t;break}}return n?(this.promote(n),!0):!1}promote(e,t){let n=this.lead;if(e!==n&&(this.prevLead=n,this.lead=e,e.show(),n)){n.instance&&n.scheduleRender(),e.scheduleRender(),e.resumeFrom=n,t&&(e.resumeFrom.preserveOpacity=!0),n.snapshot&&(e.snapshot=n.snapshot,e.snapshot.latestValues=n.animationValues||n.latestValues),e.root&&e.root.isUpdating&&(e.isLayoutDirty=!0);let{crossfade:r}=e.options;r===!1&&n.hide()}}exitAnimationComplete(){this.members.forEach(e=>{let{options:t,resumingFrom:n}=e;t.onExitComplete&&t.onExitComplete(),n&&n.options.onExitComplete&&n.options.onExitComplete()})}scheduleRender(){this.members.forEach(e=>{e.instance&&e.scheduleRender(!1)})}removeLeadSnapshot(){this.lead&&this.lead.snapshot&&(this.lead.snapshot=void 0)}};function Qd(e,t,n){let r=``,i=e.x.translate/t.x,a=e.y.translate/t.y,o=n?.z||0;if((i||a||o)&&(r=`translate3d(${i}px, ${a}px, ${o}px) `),(t.x!==1||t.y!==1)&&(r+=`scale(${1/t.x}, ${1/t.y}) `),n){let{transformPerspective:e,rotate:t,rotateX:i,rotateY:a,skewX:o,skewY:s}=n;e&&(r=`perspective(${e}px) ${r}`),t&&(r+=`rotate(${t}deg) `),i&&(r+=`rotateX(${i}deg) `),a&&(r+=`rotateY(${a}deg) `),o&&(r+=`skewX(${o}deg) `),s&&(r+=`skewY(${s}deg) `)}let s=e.x.scale*t.x,c=e.y.scale*t.y;return(s!==1||c!==1)&&(r+=`scale(${s}, ${c})`),r||`none`}var $d={type:`projectionFrame`,totalNodes:0,resolvedTargetDeltas:0,recalculatedProjection:0},ef=typeof window<`u`&&window.MotionDebug!==void 0,tf=[``,`X`,`Y`,`Z`],nf={visibility:`hidden`},rf=1e3,af=0;function of(e,t,n,r){let{latestValues:i}=t;i[e]&&(n[e]=i[e],t.setStaticValue(e,0),r&&(r[e]=0))}function sf(e){if(e.hasCheckedOptimisedAppear=!0,e.root===e)return;let{visualElement:t}=e.options;if(!t)return;let n=qo(t);if(window.MotionHasOptimisedAnimation(n,`transform`)){let{layout:t,layoutId:r}=e.options;window.MotionCancelOptimisedAnimation(n,`transform`,B,!(t||r))}let{parent:r}=e;r&&!r.hasCheckedOptimisedAppear&&sf(r)}function cf({attachResizeListener:e,defaultParent:t,measureScroll:n,checkIsScrollRoot:r,resetTransform:i}){return class{constructor(e={},n=t?.()){this.id=af++,this.animationId=0,this.children=new Set,this.options={},this.isTreeAnimating=!1,this.isAnimationBlocked=!1,this.isLayoutDirty=!1,this.isProjectionDirty=!1,this.isSharedProjectionDirty=!1,this.isTransformDirty=!1,this.updateManuallyBlocked=!1,this.updateBlockedByResize=!1,this.isUpdating=!1,this.isSVG=!1,this.needsReset=!1,this.shouldResetTransform=!1,this.hasCheckedOptimisedAppear=!1,this.treeScale={x:1,y:1},this.eventHandlers=new Map,this.hasTreeAnimated=!1,this.updateScheduled=!1,this.scheduleUpdate=()=>this.update(),this.projectionUpdateScheduled=!1,this.checkUpdateFailed=()=>{this.isUpdating&&(this.isUpdating=!1,this.clearAllSnapshots())},this.updateProjection=()=>{this.projectionUpdateScheduled=!1,ef&&($d.totalNodes=$d.resolvedTargetDeltas=$d.recalculatedProjection=0),this.nodes.forEach(df),this.nodes.forEach(vf),this.nodes.forEach(yf),this.nodes.forEach(ff),ef&&window.MotionDebug.record($d)},this.resolvedRelativeTargetAt=0,this.hasProjected=!1,this.isVisible=!0,this.animationProgress=0,this.sharedNodes=new Map,this.latestValues=e,this.root=n?n.root||n:this,this.path=n?[...n.path,n]:[],this.parent=n,this.depth=n?n.depth+1:0;for(let e=0;ethis.root.updateBlockedByResize=!1;e(t,()=>{this.root.updateBlockedByResize=!0,n&&n(),n=wd(r,250),pd.hasAnimatedSinceResize&&(pd.hasAnimatedSinceResize=!1,this.nodes.forEach(_f))})}r&&this.root.registerSharedNode(r,this),this.options.animate!==!1&&a&&(r||i)&&this.addEventListener(`didUpdate`,({delta:e,hasLayoutChanged:t,hasRelativeTargetChanged:n,layout:r})=>{if(this.isTreeAnimationBlocked()){this.target=void 0,this.relativeTarget=void 0;return}let i=this.options.transition||a.getDefaultTransition()||Ef,{onLayoutAnimationStart:o,onLayoutAnimationComplete:s}=a.getProps(),c=!this.targetLayout||!Jd(this.targetLayout,r)||n,l=!t&&n;if(this.options.layoutRoot||this.resumeFrom&&this.resumeFrom.instance||l||t&&(c||!this.currentAnimation)){this.resumeFrom&&(this.resumingFrom=this.resumeFrom,this.resumingFrom.resumingFrom=void 0),this.setAnimationOrigin(e,l);let t={...to(i,`layout`),onPlay:o,onComplete:s};(a.shouldReduceMotion||this.options.layoutRoot)&&(t.delay=0,t.type=!1),this.startAnimation(t)}else t||_f(this),this.isLead()&&this.options.onExitComplete&&this.options.onExitComplete();this.targetLayout=r})}unmount(){this.options.layoutId&&this.willUpdate(),this.root.nodes.remove(this);let e=this.getStack();e&&e.remove(this),this.parent&&this.parent.children.delete(this),this.instance=void 0,ni(this.updateProjection)}blockUpdate(){this.updateManuallyBlocked=!0}unblockUpdate(){this.updateManuallyBlocked=!1}isUpdateBlocked(){return this.updateManuallyBlocked||this.updateBlockedByResize}isTreeAnimationBlocked(){return this.isAnimationBlocked||this.parent&&this.parent.isTreeAnimationBlocked()||!1}startUpdate(){this.isUpdateBlocked()||(this.isUpdating=!0,this.nodes&&this.nodes.forEach(bf),this.animationId++)}getTransformTemplate(){let{visualElement:e}=this.options;return e&&e.getProps().transformTemplate}willUpdate(e=!0){if(this.root.hasTreeAnimated=!0,this.root.isUpdateBlocked()){this.options.onExitComplete&&this.options.onExitComplete();return}if(window.MotionCancelOptimisedAnimation&&!this.hasCheckedOptimisedAppear&&sf(this),!this.root.isUpdating&&this.root.startUpdate(),this.isLayoutDirty)return;this.isLayoutDirty=!0;for(let e=0;e{this.isLayoutDirty?this.root.didUpdate():this.root.checkUpdateFailed()})}updateSnapshot(){this.snapshot||!this.instance||(this.snapshot=this.measure())}updateLayout(){if(!this.instance||(this.updateScroll(),!(this.options.alwaysMeasureLayout&&this.isLead())&&!this.isLayoutDirty))return;if(this.resumeFrom&&!this.resumeFrom.instance)for(let e=0;e{let n=t/1e3;Sf(a.x,e.x,n),Sf(a.y,e.y,n),this.setTargetDelta(a),this.relativeTarget&&this.relativeTargetOrigin&&this.layout&&this.relativeParent&&this.relativeParent.layout&&(Su(o,this.layout.layoutBox,this.relativeParent.layout.layoutBox),wf(this.relativeTarget,this.relativeTargetOrigin,o,n),d&&Kd(this.relativeTarget,d)&&(this.isProjectionDirty=!1),d||=Lu(),Fd(d,this.relativeTarget)),s&&(this.animationValues=i,kd(i,r,this.latestValues,n,u,l)),this.root.scheduleUpdateProjection(),this.scheduleRender(),this.animationProgress=n},this.mixTargetDelta(this.options.layoutRoot?1e3:0)}startAnimation(e){this.notifyListeners(`animationStart`),this.currentAnimation&&this.currentAnimation.stop(),this.resumingFrom&&this.resumingFrom.currentAnimation&&this.resumingFrom.currentAnimation.stop(),this.pendingAnimation&&=(ni(this.pendingAnimation),void 0),this.pendingAnimation=B.update(()=>{pd.hasAnimatedSinceResize=!0,this.currentAnimation=bd(0,rf,{...e,onUpdate:t=>{this.mixTargetDelta(t),e.onUpdate&&e.onUpdate(t)},onComplete:()=>{e.onComplete&&e.onComplete(),this.completeAnimation()}}),this.resumingFrom&&(this.resumingFrom.currentAnimation=this.currentAnimation),this.pendingAnimation=void 0})}completeAnimation(){this.resumingFrom&&(this.resumingFrom.currentAnimation=void 0,this.resumingFrom.preserveOpacity=void 0);let e=this.getStack();e&&e.exitAnimationComplete(),this.resumingFrom=this.currentAnimation=this.animationValues=void 0,this.notifyListeners(`animationComplete`)}finishAnimation(){this.currentAnimation&&(this.mixTargetDelta&&this.mixTargetDelta(rf),this.currentAnimation.stop()),this.completeAnimation()}applyTransformsToTarget(){let e=this.getLead(),{targetWithTransforms:t,target:n,layout:r,latestValues:i}=e;if(!(!t||!n||!r)){if(this!==e&&this.layout&&r&&jf(this.options.animationType,this.layout.layoutBox,r.layoutBox)){n=this.target||Lu();let t=hu(this.layout.layoutBox.x);n.x.min=e.target.x.min,n.x.max=n.x.min+t;let r=hu(this.layout.layoutBox.y);n.y.min=e.target.y.min,n.y.max=n.y.min+r}Fd(t,n),nd(t,i),vu(this.projectionDeltaWithTransform,this.layoutCorrected,t,i)}}registerSharedNode(e,t){this.sharedNodes.has(e)||this.sharedNodes.set(e,new Zd),this.sharedNodes.get(e).add(t);let n=t.options.initialPromotionConfig;t.promote({transition:n?n.transition:void 0,preserveFollowOpacity:n&&n.shouldPreserveFollowOpacity?n.shouldPreserveFollowOpacity(t):void 0})}isLead(){let e=this.getStack();return!e||e.lead===this}getLead(){let{layoutId:e}=this.options;return e&&this.getStack()?.lead||this}getPrevLead(){let{layoutId:e}=this.options;return e?this.getStack()?.prevLead:void 0}getStack(){let{layoutId:e}=this.options;if(e)return this.root.sharedNodes.get(e)}promote({needsReset:e,transition:t,preserveFollowOpacity:n}={}){let r=this.getStack();r&&r.promote(this,n),e&&(this.projectionDelta=void 0,this.needsReset=!0),t&&this.setOptions({transition:t})}relegate(){let e=this.getStack();return e?e.relegate(this):!1}resetSkewAndRotation(){let{visualElement:e}=this.options;if(!e)return;let t=!1,{latestValues:n}=e;if((n.z||n.rotate||n.rotateX||n.rotateY||n.rotateZ||n.skewX||n.skewY)&&(t=!0),!t)return;let r={};n.z&&of(`z`,e,r,this.animationValues);for(let t=0;te.currentAnimation?.stop()),this.root.nodes.forEach(mf),this.root.sharedNodes.clear()}}}function lf(e){e.updateLayout()}function uf(e){let t=e.resumeFrom?.snapshot||e.snapshot;if(e.isLead()&&e.layout&&t&&e.hasListeners(`didUpdate`)){let{layoutBox:n,measuredBox:r}=e.layout,{animationType:i}=e.options,a=t.source!==e.layout.source;i===`size`?Ru(e=>{let r=a?t.measuredBox[e]:t.layoutBox[e],i=hu(r);r.min=n[e].min,r.max=r.min+i}):jf(i,t.layoutBox,n)&&Ru(r=>{let i=a?t.measuredBox[r]:t.layoutBox[r],o=hu(n[r]);i.max=i.min+o,e.relativeTarget&&!e.currentAnimation&&(e.isProjectionDirty=!0,e.relativeTarget[r].max=e.relativeTarget[r].min+o)});let o=Fu();vu(o,n,t.layoutBox);let s=Fu();a?vu(s,e.applyTransform(r,!0),t.measuredBox):vu(s,n,t.layoutBox);let c=!Wd(o),l=!1;if(!e.resumeFrom){let r=e.getClosestProjectingParent();if(r&&!r.resumeFrom){let{snapshot:i,layout:a}=r;if(i&&a){let o=Lu();Su(o,t.layoutBox,i.layoutBox);let s=Lu();Su(s,n,a.layoutBox),Jd(o,s)||(l=!0),r.options.layoutRoot&&(e.relativeTarget=s,e.relativeTargetOrigin=o,e.relativeParent=r)}}}e.notifyListeners(`didUpdate`,{layout:n,snapshot:t,delta:s,layoutDelta:o,hasLayoutChanged:c,hasRelativeTargetChanged:l})}else if(e.isLead()){let{onExitComplete:t}=e.options;t&&t()}e.options.transition=void 0}function df(e){ef&&$d.totalNodes++,e.parent&&(e.isProjecting()||(e.isProjectionDirty=e.parent.isProjectionDirty),e.isSharedProjectionDirty||=!!(e.isProjectionDirty||e.parent.isProjectionDirty||e.parent.isSharedProjectionDirty),e.isTransformDirty||=e.parent.isTransformDirty)}function ff(e){e.isProjectionDirty=e.isSharedProjectionDirty=e.isTransformDirty=!1}function pf(e){e.clearSnapshot()}function mf(e){e.clearMeasurements()}function hf(e){e.isLayoutDirty=!1}function gf(e){let{visualElement:t}=e.options;t&&t.getProps().onBeforeLayoutMeasure&&t.notify(`BeforeLayoutMeasure`),e.resetTransform()}function _f(e){e.finishAnimation(),e.targetDelta=e.relativeTarget=e.target=void 0,e.isProjectionDirty=!0}function vf(e){e.resolveTargetDelta()}function yf(e){e.calcProjection()}function bf(e){e.resetSkewAndRotation()}function xf(e){e.removeLeadSnapshot()}function Sf(e,t,n){e.translate=Z(t.translate,0,n),e.scale=Z(t.scale,1,n),e.origin=t.origin,e.originPoint=t.originPoint}function Cf(e,t,n,r){e.min=Z(t.min,n.min,r),e.max=Z(t.max,n.max,r)}function wf(e,t,n,r){Cf(e.x,t.x,n.x,r),Cf(e.y,t.y,n.y,r)}function Tf(e){return e.animationValues&&e.animationValues.opacityExit!==void 0}var Ef={duration:.45,ease:[.4,0,.1,1]},Df=e=>typeof navigator<`u`&&navigator.userAgent&&navigator.userAgent.toLowerCase().includes(e),Of=Df(`applewebkit/`)&&!Df(`chrome/`)?Math.round:Wr;function kf(e){e.min=Of(e.min),e.max=Of(e.max)}function Af(e){kf(e.x),kf(e.y)}function jf(e,t,n){return e===`position`||e===`preserve-aspect`&&!gu(Yd(t),Yd(n),.2)}function Mf(e){return e!==e.root&&e.scroll?.wasRoot}var Nf=cf({attachResizeListener:(e,t)=>Ql(e,`resize`,t),measureScroll:()=>({x:document.documentElement.scrollLeft||document.body.scrollLeft,y:document.documentElement.scrollTop||document.body.scrollTop}),checkIsScrollRoot:()=>!0}),Pf={current:void 0},Ff=cf({measureScroll:e=>({x:e.scrollLeft,y:e.scrollTop}),defaultParent:()=>{if(!Pf.current){let e=new Nf({});e.mount(window),e.setOptions({layoutScroll:!0}),Pf.current=e}return Pf.current},resetTransform:(e,t)=>{e.style.transform=t===void 0?`none`:t},checkIsScrollRoot:e=>window.getComputedStyle(e).position===`fixed`}),If={pan:{Feature:fd},drag:{Feature:ud,ProjectionNode:Ff,MeasureLayout:vd}};function Lf(e,t,n){let{props:r}=e;e.animationState&&r.whileHover&&e.animationState.setActive(`whileHover`,n===`Start`);let i=r[`onHover`+n];i&&B.postRender(()=>i(t,$l(t)))}var Rf=class extends Jl{mount(){let{current:e}=this.node;e&&(this.unmount=bo(e,e=>(Lf(this.node,e,`Start`),e=>Lf(this.node,e,`End`))))}unmount(){}},zf=class extends Jl{constructor(){super(...arguments),this.isActive=!1}onFocus(){let e=!1;try{e=this.node.current.matches(`:focus-visible`)}catch{e=!0}!e||!this.node.animationState||(this.node.animationState.setActive(`whileFocus`,!0),this.isActive=!0)}onBlur(){!this.isActive||!this.node.animationState||(this.node.animationState.setActive(`whileFocus`,!1),this.isActive=!1)}mount(){this.unmount=jc(Ql(this.node.current,`focus`,()=>this.onFocus()),Ql(this.node.current,`blur`,()=>this.onBlur()))}unmount(){}};function Bf(e,t,n){let{props:r}=e;e.animationState&&r.whileTap&&e.animationState.setActive(`whileTap`,n===`Start`);let i=r[`onTap`+(n===`End`?``:n)];i&&B.postRender(()=>i(t,$l(t)))}var Vf=class extends Jl{mount(){let{current:e}=this.node;e&&(this.unmount=Oo(e,e=>(Bf(this.node,e,`Start`),(e,{success:t})=>Bf(this.node,e,t?`End`:`Cancel`)),{useGlobalTarget:this.node.props.globalTapTarget}))}unmount(){}},Hf=new WeakMap,Uf=new WeakMap,Wf=e=>{let t=Hf.get(e.target);t&&t(e)},Gf=e=>{e.forEach(Wf)};function Kf({root:e,...t}){let n=e||document;Uf.has(n)||Uf.set(n,{});let r=Uf.get(n),i=JSON.stringify(t);return r[i]||(r[i]=new IntersectionObserver(Gf,{root:e,...t})),r[i]}function qf(e,t,n){let r=Kf(t);return Hf.set(e,n),r.observe(e),()=>{Hf.delete(e),r.unobserve(e)}}var Jf={some:0,all:1},Yf=class extends Jl{constructor(){super(...arguments),this.hasEnteredView=!1,this.isInView=!1}startObserver(){this.unmount();let{viewport:e={}}=this.node.getProps(),{root:t,margin:n,amount:r=`some`,once:i}=e,a={root:t?t.current:void 0,rootMargin:n,threshold:typeof r==`number`?r:Jf[r]};return qf(this.node.current,a,e=>{let{isIntersecting:t}=e;if(this.isInView===t||(this.isInView=t,i&&!t&&this.hasEnteredView))return;t&&(this.hasEnteredView=!0),this.node.animationState&&this.node.animationState.setActive(`whileInView`,t);let{onViewportEnter:n,onViewportLeave:r}=this.node.getProps(),a=t?n:r;a&&a(e)})}mount(){this.startObserver()}update(){if(typeof IntersectionObserver>`u`)return;let{props:e,prevProps:t}=this.node;[`amount`,`margin`,`root`].some(Xf(e,t))&&this.startObserver()}unmount(){}};function Xf({viewport:e={}},{viewport:t={}}={}){return n=>e[n]!==t[n]}var Zf={inView:{Feature:Yf},tap:{Feature:Vf},focus:{Feature:zf},hover:{Feature:Rf}},Qf={layout:{ProjectionNode:Ff,MeasureLayout:vd}},$f={current:null},ep={current:!1};function tp(){if(ep.current=!0,Vr)if(window.matchMedia){let e=window.matchMedia(`(prefers-reduced-motion)`),t=()=>$f.current=e.matches;e.addListener(t),t()}else $f.current=!1}var np=[...pc,Cs,Ls],rp=e=>np.find(fc(e)),ip=new WeakMap;function ap(e,t,n){for(let r in t){let i=t[r],a=n[r];if(W(i))e.addValue(r,i);else if(W(a))e.addValue(r,Ho(i,{owner:e}));else if(a!==i)if(e.hasValue(r)){let t=e.getValue(r);t.liveStyle===!0?t.jump(i):t.hasAnimated||t.set(i)}else{let t=e.getStaticValue(r);e.addValue(r,Ho(t===void 0?i:t,{owner:e}))}}for(let r in n)t[r]===void 0&&e.removeValue(r);return t}var op=[`AnimationStart`,`AnimationComplete`,`Update`,`BeforeLayoutMeasure`,`LayoutMeasure`,`LayoutAnimationStart`,`LayoutAnimationComplete`],sp=class{scrapeMotionValuesFromProps(e,t,n){return{}}constructor({parent:e,props:t,presenceContext:n,reducedMotionConfig:r,blockInitialAnimation:i,visualState:a},o={}){this.current=null,this.children=new Set,this.isVariantNode=!1,this.isControllingVariants=!1,this.shouldReduceMotion=null,this.values=new Map,this.KeyframeResolver=oc,this.features={},this.valueSubscriptions=new Map,this.prevMotionValues={},this.events={},this.propEventSubscriptions={},this.notifyUpdate=()=>this.notify(`Update`,this.latestValues),this.render=()=>{this.current&&(this.triggerBuild(),this.renderInstance(this.current,this.renderState,this.props.style,this.projection))},this.renderScheduledAt=0,this.scheduleRender=()=>{let e=No.now();this.renderScheduledAtthis.bindToMotionValue(t,e)),ep.current||tp(),this.shouldReduceMotion=this.reducedMotionConfig===`never`?!1:this.reducedMotionConfig===`always`||$f.current,this.parent&&this.parent.children.add(this),this.update(this.props,this.presenceContext)}unmount(){ip.delete(this.current),this.projection&&this.projection.unmount(),ni(this.notifyUpdate),ni(this.render),this.valueSubscriptions.forEach(e=>e()),this.valueSubscriptions.clear(),this.removeFromVariantTree&&this.removeFromVariantTree(),this.parent&&this.parent.children.delete(this);for(let e in this.events)this.events[e].clear();for(let e in this.features){let t=this.features[e];t&&(t.unmount(),t.isMounted=!1)}this.current=null}bindToMotionValue(e,t){this.valueSubscriptions.has(e)&&this.valueSubscriptions.get(e)();let n=Qi.has(e),r=t.on(`change`,t=>{this.latestValues[e]=t,this.props.onUpdate&&B.preRender(this.notifyUpdate),n&&this.projection&&(this.projection.isTransformDirty=!0)}),i=t.on(`renderRequest`,this.scheduleRender),a;window.MotionCheckAppearSync&&(a=window.MotionCheckAppearSync(this,e,t)),this.valueSubscriptions.set(e,()=>{r(),i(),a&&a(),t.owner&&t.stop()})}sortNodePosition(e){return!this.current||!this.sortInstanceNodePosition||this.type!==e.type?0:this.sortInstanceNodePosition(this.current,e.current)}updateFeatures(){let e=`animation`;for(e in si){let t=si[e];if(!t)continue;let{isEnabled:n,Feature:r}=t;if(!this.features[e]&&r&&n(this.props)&&(this.features[e]=new r(this)),this.features[e]){let t=this.features[e];t.isMounted?t.update():(t.mount(),t.isMounted=!0)}}}triggerBuild(){this.build(this.renderState,this.latestValues,this.props)}measureViewportBox(){return this.current?this.measureInstanceViewportBox(this.current,this.props):Lu()}getStaticValue(e){return this.latestValues[e]}setStaticValue(e,t){this.latestValues[e]=t}update(e,t){(e.transformTemplate||this.props.transformTemplate)&&this.scheduleRender(),this.prevProps=this.props,this.props=e,this.prevPresenceContext=this.presenceContext,this.presenceContext=t;for(let t=0;tt.variantChildren.delete(e)}addValue(e,t){let n=this.values.get(e);t!==n&&(n&&this.removeValue(e),this.bindToMotionValue(e,t),this.values.set(e,t),this.latestValues[e]=t.get())}removeValue(e){this.values.delete(e);let t=this.valueSubscriptions.get(e);t&&(t(),this.valueSubscriptions.delete(e)),delete this.latestValues[e],this.removeValueFromRenderState(e,this.renderState)}hasValue(e){return this.values.has(e)}getValue(e,t){if(this.props.values&&this.props.values[e])return this.props.values[e];let n=this.values.get(e);return n===void 0&&t!==void 0&&(n=Ho(t===null?void 0:t,{owner:this}),this.addValue(e,n)),n}readValue(e,t){let n=this.latestValues[e]!==void 0||!this.current?this.latestValues[e]:this.getBaseTargetFromProps(this.props,e)??this.readValueFromInstance(this.current,e,this.options);return n!=null&&(typeof n==`string`&&(sc(n)||ls(n))?n=parseFloat(n):!rp(n)&&Ls.test(t)&&(n=Ws(e,t)),this.setBaseTarget(e,W(n)?n.get():n)),W(n)?n.get():n}setBaseTarget(e,t){this.baseTarget[e]=t}getBaseTarget(e){let{initial:t}=this.props,n;if(typeof t==`string`||typeof t==`object`){let r=Gi(this.props,t,this.presenceContext?.custom);r&&(n=r[e])}if(t&&n!==void 0)return n;let r=this.getBaseTargetFromProps(this.props,e);return r!==void 0&&!W(r)?r:this.initialValues[e]!==void 0&&n===void 0?void 0:this.baseTarget[e]}on(e,t){return this.events[e]||(this.events[e]=new Io),this.events[e].add(t)}notify(e,...t){this.events[e]&&this.events[e].notify(...t)}},cp=class extends sp{constructor(){super(...arguments),this.KeyframeResolver=mc}sortInstanceNodePosition(e,t){return e.compareDocumentPosition(t)&2?1:-1}getBaseTargetFromProps(e,t){return e.style?e.style[t]:void 0}removeValueFromRenderState(e,{vars:t,style:n}){delete t[e],delete n[e]}handleChildMotionValue(){this.childSubscription&&(this.childSubscription(),delete this.childSubscription);let{children:e}=this.props;W(e)&&(this.childSubscription=e.on(`change`,e=>{this.current&&(this.current.textContent=`${e}`)}))}};function lp(e){return window.getComputedStyle(e)}var up=class extends cp{constructor(){super(...arguments),this.type=`html`,this.renderInstance=ja}readValueFromInstance(e,t){if(Qi.has(t)){let e=Us(t);return e&&e.default||0}else{let n=lp(e),r=(ea(t)?n.getPropertyValue(t):n[t])||0;return typeof r==`string`?r.trim():r}}measureInstanceViewportBox(e,{transformPagePoint:t}){return rd(e,t)}build(e,t,n){Sa(e,t,n.transformTemplate)}scrapeMotionValuesFromProps(e,t,n){return La(e,t,n)}},dp=class extends cp{constructor(){super(...arguments),this.type=`svg`,this.isSVGTag=!1,this.measureInstanceViewportBox=Lu}getBaseTargetFromProps(e,t){return e[t]}readValueFromInstance(e,t){if(Qi.has(t)){let e=Us(t);return e&&e.default||0}return t=Ma.has(t)?t:Ai(t),e.getAttribute(t)}scrapeMotionValuesFromProps(e,t,n){return Ra(e,t,n)}build(e,t,n){Da(e,t,this.isSVGTag,n.transformTemplate)}renderInstance(e,t,n,r){Na(e,t,n,r)}mount(e){this.isSVGTag=Aa(e.tagName),super.mount(e)}},fp=(e,t)=>Ui(e)?new dp(t):new up(t,{allowProjection:e!==h.Fragment}),pp=gi(Ya({...Zl,...Zf,...If,...Qf},fp)),mp=e=>e.replace(/([a-z0-9])([A-Z])/g,`$1-$2`).toLowerCase(),hp=(...e)=>e.filter((e,t,n)=>!!e&&n.indexOf(e)===t).join(` `),gp={xmlns:`http://www.w3.org/2000/svg`,width:24,height:24,viewBox:`0 0 24 24`,fill:`none`,stroke:`currentColor`,strokeWidth:2,strokeLinecap:`round`,strokeLinejoin:`round`},_p=(0,h.forwardRef)(({color:e=`currentColor`,size:t=24,strokeWidth:n=2,absoluteStrokeWidth:r,className:i=``,children:a,iconNode:o,...s},c)=>(0,h.createElement)(`svg`,{ref:c,...gp,width:t,height:t,stroke:e,strokeWidth:r?Number(n)*24/Number(t):n,className:hp(`lucide`,i),...s},[...o.map(([e,t])=>(0,h.createElement)(e,t)),...Array.isArray(a)?a:[a]])),vp=(e,t)=>{let n=(0,h.forwardRef)(({className:n,...r},i)=>(0,h.createElement)(_p,{ref:i,iconNode:t,className:hp(`lucide-${mp(e)}`,n),...r}));return n.displayName=`${e}`,n},yp=vp(`Bell`,[[`path`,{d:`M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9`,key:`1qo2s2`}],[`path`,{d:`M10.3 21a1.94 1.94 0 0 0 3.4 0`,key:`qgo35s`}]]),bp=vp(`Bot`,[[`path`,{d:`M12 8V4H8`,key:`hb8ula`}],[`rect`,{width:`16`,height:`12`,x:`4`,y:`8`,rx:`2`,key:`enze0r`}],[`path`,{d:`M2 14h2`,key:`vft8re`}],[`path`,{d:`M20 14h2`,key:`4cs60a`}],[`path`,{d:`M15 13v2`,key:`1xurst`}],[`path`,{d:`M9 13v2`,key:`rq6x2g`}]]),xp=vp(`Check`,[[`path`,{d:`M20 6 9 17l-5-5`,key:`1gmf2c`}]]),Sp=vp(`ChevronDown`,[[`path`,{d:`m6 9 6 6 6-6`,key:`qrunsl`}]]),Cp=vp(`ChevronLeft`,[[`path`,{d:`m15 18-6-6 6-6`,key:`1wnfg3`}]]),wp=vp(`FileText`,[[`path`,{d:`M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z`,key:`1rqfz7`}],[`path`,{d:`M14 2v4a2 2 0 0 0 2 2h4`,key:`tnqrlb`}],[`path`,{d:`M10 9H8`,key:`b1mrlr`}],[`path`,{d:`M16 13H8`,key:`t4e002`}],[`path`,{d:`M16 17H8`,key:`z1uh3a`}]]),Tp=vp(`FolderGit2`,[[`path`,{d:`M9 20H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H20a2 2 0 0 1 2 2v5`,key:`1w6njk`}],[`circle`,{cx:`13`,cy:`12`,r:`2`,key:`1j92g6`}],[`path`,{d:`M18 19c-2.8 0-5-2.2-5-5v8`,key:`pkpw2h`}],[`circle`,{cx:`20`,cy:`19`,r:`2`,key:`1obnsp`}]]),Ep=vp(`GitBranch`,[[`line`,{x1:`6`,x2:`6`,y1:`3`,y2:`15`,key:`17qcm7`}],[`circle`,{cx:`18`,cy:`6`,r:`3`,key:`1h7g24`}],[`circle`,{cx:`6`,cy:`18`,r:`3`,key:`fqmcym`}],[`path`,{d:`M18 9a9 9 0 0 1-9 9`,key:`n2h4wq`}]]),Dp=vp(`Github`,[[`path`,{d:`M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4`,key:`tonef`}],[`path`,{d:`M9 18c-4.51 2-5-2-7-2`,key:`9comsn`}]]),Op=vp(`Hexagon`,[[`path`,{d:`M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z`,key:`yt0hxn`}]]),kp=vp(`LayoutDashboard`,[[`rect`,{width:`7`,height:`9`,x:`3`,y:`3`,rx:`1`,key:`10lvy0`}],[`rect`,{width:`7`,height:`5`,x:`14`,y:`3`,rx:`1`,key:`16une8`}],[`rect`,{width:`7`,height:`9`,x:`14`,y:`12`,rx:`1`,key:`1hutg5`}],[`rect`,{width:`7`,height:`5`,x:`3`,y:`16`,rx:`1`,key:`ldoo1y`}]]),Ap=vp(`Lightbulb`,[[`path`,{d:`M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5`,key:`1gvzjb`}],[`path`,{d:`M9 18h6`,key:`x1upvd`}],[`path`,{d:`M10 22h4`,key:`ceow96`}]]),jp=vp(`LoaderCircle`,[[`path`,{d:`M21 12a9 9 0 1 1-6.219-8.56`,key:`13zald`}]]),Mp=vp(`LogOut`,[[`path`,{d:`M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4`,key:`1uf3rs`}],[`polyline`,{points:`16 17 21 12 16 7`,key:`1gabdz`}],[`line`,{x1:`21`,x2:`9`,y1:`12`,y2:`12`,key:`1uyos4`}]]),Np=vp(`Network`,[[`rect`,{x:`16`,y:`16`,width:`6`,height:`6`,rx:`1`,key:`4q2zg0`}],[`rect`,{x:`2`,y:`16`,width:`6`,height:`6`,rx:`1`,key:`8cvhb9`}],[`rect`,{x:`9`,y:`2`,width:`6`,height:`6`,rx:`1`,key:`1egb70`}],[`path`,{d:`M5 16v-3a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v3`,key:`1jsf9p`}],[`path`,{d:`M12 12V8`,key:`2874zd`}]]),Pp=vp(`Search`,[[`circle`,{cx:`11`,cy:`11`,r:`8`,key:`4ej97u`}],[`path`,{d:`m21 21-4.3-4.3`,key:`1qie3q`}]]),Fp=vp(`Settings`,[[`path`,{d:`M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z`,key:`1qme2f`}],[`circle`,{cx:`12`,cy:`12`,r:`3`,key:`1v7zrd`}]]),Ip=vp(`ShieldCheck`,[[`path`,{d:`M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z`,key:`oel41y`}],[`path`,{d:`m9 12 2 2 4-4`,key:`dzmm74`}]]),Lp=vp(`Upload`,[[`path`,{d:`M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4`,key:`ih7n3h`}],[`polyline`,{points:`17 8 12 3 7 8`,key:`t8dd8p`}],[`line`,{x1:`12`,x2:`12`,y1:`3`,y2:`15`,key:`widbto`}]]),Rp=vp(`User`,[[`path`,{d:`M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2`,key:`975kel`}],[`circle`,{cx:`12`,cy:`7`,r:`4`,key:`17ys0d`}]]),zp=e=>{let t,n=new Set,r=(e,r)=>{let i=typeof e==`function`?e(t):e;if(!Object.is(i,t)){let e=t;t=r??(typeof i!=`object`||!i)?i:Object.assign({},t,i),n.forEach(n=>n(t,e))}},i=()=>t,a={setState:r,getState:i,getInitialState:()=>o,subscribe:e=>(n.add(e),()=>n.delete(e)),destroy:()=>{n.clear()}},o=t=e(r,i,a);return a},Bp=e=>e?zp(e):zp,Vp=i((e=>{var n=t();function r(e,t){return e===t&&(e!==0||1/e==1/t)||e!==e&&t!==t}var i=typeof Object.is==`function`?Object.is:r,a=n.useState,o=n.useEffect,s=n.useLayoutEffect,c=n.useDebugValue;function l(e,t){var n=t(),r=a({inst:{value:n,getSnapshot:t}}),i=r[0].inst,l=r[1];return s(function(){i.value=n,i.getSnapshot=t,u(i)&&l({inst:i})},[e,n,t]),o(function(){return u(i)&&l({inst:i}),e(function(){u(i)&&l({inst:i})})},[e]),c(n),n}function u(e){var t=e.getSnapshot;e=e.value;try{var n=t();return!i(e,n)}catch{return!0}}function d(e,t){return t()}var f=typeof window>`u`||window.document===void 0||window.document.createElement===void 0?d:l;e.useSyncExternalStore=n.useSyncExternalStore===void 0?f:n.useSyncExternalStore})),Hp=i(((e,t)=>{t.exports=Vp()})),Up=i((e=>{var n=t(),r=Hp();function i(e,t){return e===t&&(e!==0||1/e==1/t)||e!==e&&t!==t}var a=typeof Object.is==`function`?Object.is:i,o=r.useSyncExternalStore,s=n.useRef,c=n.useEffect,l=n.useMemo,u=n.useDebugValue;e.useSyncExternalStoreWithSelector=function(e,t,n,r,i){var d=s(null);if(d.current===null){var f={hasValue:!1,value:null};d.current=f}else f=d.current;d=l(function(){function e(e){if(!o){if(o=!0,s=e,e=r(e),i!==void 0&&f.hasValue){var t=f.value;if(i(t,e))return c=t}return c=e}if(t=c,a(s,e))return t;var n=r(e);return i!==void 0&&i(t,n)?(s=e,t):(s=e,c=n)}var o=!1,s,c,l=n===void 0?null:n;return[function(){return e(t())},l===null?void 0:function(){return e(l())}]},[t,n,r,i]);var p=o(e,d[0],d[1]);return c(function(){f.hasValue=!0,f.value=p},[p]),u(p),p}})),Wp=i(((e,t)=>{t.exports=Up()})),Gp=a(Wp(),1),{useDebugValue:Kp}=h.default,{useSyncExternalStoreWithSelector:qp}=Gp.default,Jp=e=>e;function Yp(e,t=Jp,n){let r=qp(e.subscribe,e.getState,e.getServerState||e.getInitialState,t,n);return Kp(r),r}var Xp=e=>{let t=typeof e==`function`?Bp(e):e,n=(e,n)=>Yp(t,e,n);return Object.assign(n,t),n},Zp=e=>e?Xp(e):Xp,Qp=Zp((e,t)=>({sidebarCollapsed:!1,setSidebarCollapsed:t=>e({sidebarCollapsed:t}),toggleSidebar:()=>e(e=>({sidebarCollapsed:!e.sidebarCollapsed})),repositories:[],activeRepositoryId:null,setActiveRepositoryId:t=>e({activeRepositoryId:t}),addRepository:t=>e(e=>({repositories:[...e.repositories,t]})),setRepositories:t=>e(e=>({repositories:t,activeRepositoryId:e.activeRepositoryId&&t.some(t=>t.id===e.activeRepositoryId)?e.activeRepositoryId:t[0]?.id||null})),updateRepository:(t,n)=>e(e=>({repositories:e.repositories.map(e=>e.id===t?{...e,...n}:e)})),removeRepository:t=>e(e=>({repositories:e.repositories.filter(e=>e.id!==t),activeRepositoryId:e.activeRepositoryId===t?null:e.activeRepositoryId})),analysisRunning:!1,currentAnalysisId:null,startAnalysis:t=>e({analysisRunning:!0,currentAnalysisId:t}),advanceAnalysisStage:(e,n,r)=>{t().updateRepository(e,{analysisStage:n,analysisProgress:r})},completeAnalysis:(n,r)=>{let i=t(),a=i.repositories.find(e=>e.id===n);a&&(i.updateRepository(n,{status:`completed`,analysisStage:`completed`,analysisProgress:100,analysedAt:new Date().toISOString(),...r}),e({analysisRunning:!1,currentAnalysisId:null,activeRepositoryId:t().repositories.find(e=>e.id===n).id}),i.addNotification({title:`Analysis Complete`,message:`${a.name} has been analysed successfully.`,type:`success`}))},failAnalysis:(n,r)=>{let i=t();i.updateRepository(n,{status:`error`,analysisStage:null,analysisProgress:0,errorMessage:r}),e({analysisRunning:!1,currentAnalysisId:null}),i.addNotification({title:`Analysis Failed`,message:r,type:`error`})},cancelAnalysis:()=>{let n=t();n.currentAnalysisId&&n.removeRepository(n.currentAnalysisId),e({analysisRunning:!1,currentAnalysisId:null})},notifications:[],addNotification:t=>e(e=>({notifications:[{...t,id:crypto.randomUUID(),createdAt:new Date().toISOString(),read:!1},...e.notifications]})),markNotificationRead:t=>e(e=>({notifications:e.notifications.map(e=>e.id===t?{...e,read:!0}:e)})),clearNotifications:()=>e({notifications:[]}),searchQuery:``,setSearchQuery:t=>e({searchQuery:t}),searchOpen:!1,setSearchOpen:t=>e({searchOpen:t})})),$p=[{label:`Dashboard`,icon:kp,path:`/`},{label:`Repositories`,icon:Tp,path:`/repositories`},{label:`Upload Repository`,icon:Lp,path:`/upload`},{label:`Architecture`,icon:Np,path:`/architecture`},{label:`Engineering Review`,icon:Ip,path:`/review`},{label:`Dependency Graph`,icon:Ep,path:`/dependencies`},{label:`AI Workspace`,icon:bp,path:`/ai-workspace`},{label:`Documentation`,icon:wp,path:`/documentation`},{label:`Insights`,icon:Ap,path:`/insights`},{label:`Settings`,icon:Fp,path:`/settings`}];function em(){let t=Zt(),{sidebarCollapsed:n,toggleSidebar:r}=Qp();return(0,z.jsxs)(pp.aside,{initial:!1,animate:{width:n?64:240},transition:{duration:.2,ease:`easeInOut`},className:`fixed left-0 top-0 z-40 h-screen flex flex-col border-r border-sidebar-border bg-sidebar`,children:[(0,z.jsxs)(`div`,{className:`flex h-14 items-center justify-between px-3`,children:[(0,z.jsxs)(Xn,{to:`/`,className:`flex items-center gap-2 overflow-hidden`,children:[(0,z.jsx)(`div`,{className:`flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary text-primary-foreground`,children:(0,z.jsx)(Op,{className:`h-4 w-4`})}),(0,z.jsx)(Ur,{children:!n&&(0,z.jsx)(pp.span,{initial:{opacity:0,width:0},animate:{opacity:1,width:`auto`},exit:{opacity:0,width:0},transition:{duration:.15},className:`text-sm font-semibold text-sidebar-foreground whitespace-nowrap`,children:`PARTHA`})})]}),(0,z.jsx)(`button`,{onClick:r,className:`flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground transition-colors`,children:(0,z.jsx)(Cp,{className:e(`h-4 w-4 transition-transform duration-200`,n&&`rotate-180`)})})]}),(0,z.jsx)(`nav`,{className:`flex-1 space-y-1 px-2 py-2 overflow-y-auto scrollbar-thin`,children:$p.map(r=>{let i=t.pathname===r.path;return(0,z.jsxs)(Xn,{to:r.path,className:e(`flex items-center gap-3 rounded-md px-2.5 py-2 text-sm font-medium transition-colors`,i?`bg-sidebar-accent text-sidebar-accent-foreground`:`text-muted-foreground hover:bg-sidebar-accent/50 hover:text-sidebar-foreground`),children:[(0,z.jsx)(r.icon,{className:`h-4 w-4 shrink-0`}),(0,z.jsx)(Ur,{children:!n&&(0,z.jsx)(pp.span,{initial:{opacity:0},animate:{opacity:1},exit:{opacity:0},transition:{duration:.15},className:`whitespace-nowrap`,children:r.label})})]},r.path)})}),(0,z.jsx)(`div`,{className:`border-t border-sidebar-border p-2`,children:(0,z.jsxs)(`div`,{className:e(`flex items-center gap-3 rounded-md px-2.5 py-2`,n&&`justify-center`),children:[(0,z.jsx)(`div`,{className:`h-7 w-7 shrink-0 rounded-full bg-primary/20 flex items-center justify-center`,children:(0,z.jsx)(`span`,{className:`text-xs font-medium text-primary`,children:`P`})}),(0,z.jsx)(Ur,{children:!n&&(0,z.jsxs)(pp.div,{initial:{opacity:0},animate:{opacity:1},exit:{opacity:0},transition:{duration:.15},className:`overflow-hidden`,children:[(0,z.jsx)(`p`,{className:`text-xs font-medium text-sidebar-foreground truncate`,children:`Developer`}),(0,z.jsx)(`p`,{className:`text-2xs text-muted-foreground truncate`,children:`Free Plan`})]})})]})})]})}var tm={list(e){return c.get(`/repositories`,e)},getById(e,t){return c.get(`/repositories/${e}`,t)},delete(e,t){return c.delete(`/repositories/${e}`,t)},importFromGithub(e,t){return c.post(`/repositories/github`,e,t)}},nm={uploadRepository(e,t,n){return o(`/repositories/upload`,e,t,n)}},rm={getStatus(e,t){return c.get(`/analysis/${e}/status`,t)},start(e,t){return c.post(`/analysis/${e}/start`,void 0,t)}},im={getArchitecture(e,t){return c.get(`/analysis/${e}/architecture`,t)}},am={getReview(e,t){return c.get(`/analysis/${e}/review`,t)}},om={getDependencyGraph(e,t){return c.get(`/analysis/${e}/dependencies`,t)}},sm={async fetchRepositories(){try{return(await tm.list()).data.map(cm)}catch{return[]}},async fetchRepository(e){try{return cm(await tm.getById(e))}catch{return null}},async uploadRepository(e,t,n){return cm(await nm.uploadRepository(e,t,{onUploadProgress:n}))},async importFromGithub(e,t){return cm(await tm.importFromGithub({url:e,branch:t}))},async startAnalysis(e){return rm.start(e)},async fetchAnalysisStatus(e){return rm.getStatus(e)},async fetchArchitecture(e){return im.getArchitecture(e.id)},async fetchReview(e){return am.getReview(e.id)},async fetchDependencyGraph(e){return om.getDependencyGraph(e)},async deleteRepository(e){try{return await tm.delete(e),!0}catch{return!1}}};function cm(e){return{id:e.id,name:e.name,description:e.description||void 0,source:e.source,sourceUrl:e.sourceUrl||void 0,size:e.size,fileCount:e.fileCount,status:e.status,dataSource:e.dataSource,analysisStage:e.analysisStage,analysisProgress:e.analysisProgress,uploadedAt:e.uploadedAt,analysedAt:e.analysedAt||void 0,errorMessage:e.errorMessage||void 0,meta:e.meta,fileTree:e.fileTree}}var lm=(0,h.createContext)(null);function um(){let e=(0,h.useContext)(lm);if(!e)throw Error(`useRepositoryContext must be used within RepositoryProvider`);return e}function dm(){let e=um(),t=Qp(e=>e.removeRepository),n=(0,h.useCallback)(t=>{let n=typeof t==`string`?t:t?.id||null;e.setActiveRepositoryId(n)},[e]),r=(0,h.useCallback)(async e=>{await sm.deleteRepository(e)&&t(e)},[t]);return{...e,selectRepository:n,removeRepository:r,empty:e.repositories.length===0,success:e.repositories.length>0,loading:!1,error:null,retry:()=>void 0,refresh:()=>void 0}}function fm(){let t=$t(),{notifications:n,markNotificationRead:r,searchQuery:i,setSearchQuery:a,searchOpen:o,setSearchOpen:s}=Qp(),{repositories:c,activeRepository:l,selectRepository:u}=dm(),[d,f]=(0,h.useState)(!1),[p,m]=(0,h.useState)(!1),[g,_]=(0,h.useState)(!1),v=(0,h.useRef)(null),y=(0,h.useRef)(null),b=(0,h.useRef)(null),x=(0,h.useRef)(null),S=n.filter(e=>!e.read).length,C=i.trim()?c.flatMap(e=>{let t=i.trim().toLowerCase(),n=e.name.toLowerCase().includes(t)?[{type:`repository`,repo:e,label:e.name,path:``}]:[],r=pm(e.fileTree).filter(e=>e.path.toLowerCase().includes(t)).slice(0,6).map(t=>({type:`file`,repo:e,label:t.name,path:t.path}));return[...n,...r]}).slice(0,8):[];(0,h.useEffect)(()=>{function e(e){v.current&&!v.current.contains(e.target)&&f(!1),y.current&&!y.current.contains(e.target)&&m(!1),b.current&&!b.current.contains(e.target)&&_(!1)}return document.addEventListener(`mousedown`,e),()=>document.removeEventListener(`mousedown`,e)},[]),(0,h.useEffect)(()=>{o&&(x.current?.focus(),s(!1))},[o,s]);let w=e=>e===`completed`?(0,z.jsx)(xp,{className:`h-3 w-3 text-success`}):e===`analysing`?(0,z.jsx)(jp,{className:`h-3 w-3 text-primary animate-spin`}):null;return(0,z.jsxs)(`header`,{className:`sticky top-0 z-30 flex h-14 items-center justify-between border-b border-border bg-background/80 backdrop-blur-sm px-4 gap-4`,children:[(0,z.jsxs)(`div`,{className:`flex items-center gap-3 flex-1`,children:[(0,z.jsxs)(`div`,{ref:v,className:`relative`,children:[(0,z.jsxs)(`button`,{onClick:()=>f(!d),className:`flex items-center gap-2 rounded-md border border-border px-3 py-1.5 text-sm hover:bg-accent transition-colors max-w-[200px]`,children:[(0,z.jsx)(`span`,{className:`text-muted-foreground truncate`,children:l?l.name:`No repository`}),(0,z.jsx)(Sp,{className:`h-3.5 w-3.5 text-muted-foreground shrink-0`})]}),d&&(0,z.jsx)(`div`,{className:`absolute top-full left-0 mt-1 w-64 rounded-lg border border-border bg-popover shadow-lg animate-scale-in z-50`,children:(0,z.jsx)(`div`,{className:`p-2`,children:c.length===0?(0,z.jsx)(`p`,{className:`px-3 py-2 text-sm text-muted-foreground`,children:`No repositories uploaded`}):c.map(t=>(0,z.jsxs)(`button`,{onClick:()=>{u(t),f(!1)},className:e(`w-full flex items-center justify-between rounded-md px-3 py-2 text-sm text-left hover:bg-accent transition-colors`,l?.id===t.id&&`bg-accent`),children:[(0,z.jsx)(`span`,{className:`truncate`,children:t.name}),w(t.status)]},t.id))})})]}),(0,z.jsxs)(`div`,{className:`relative flex-1 max-w-md`,children:[(0,z.jsx)(Pp,{className:`absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground`}),(0,z.jsx)(`input`,{ref:x,type:`text`,placeholder:`Search... (Ctrl+K)`,value:i,onChange:e=>a(e.target.value),className:`w-full rounded-md border border-border bg-background pl-9 pr-3 py-1.5 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring transition-shadow`}),i.trim()&&(0,z.jsx)(`div`,{className:`absolute top-full left-0 right-0 mt-1 rounded-lg border border-border bg-popover shadow-lg animate-scale-in z-50 p-2`,children:C.length===0?(0,z.jsx)(`p`,{className:`px-3 py-2 text-sm text-muted-foreground`,children:`No matches found`}):C.map(e=>(0,z.jsxs)(`button`,{onClick:()=>{u(e.repo),a(``),t(`/repositories/${e.repo.id}`)},className:`w-full rounded-md px-3 py-2 text-left hover:bg-accent transition-colors`,children:[(0,z.jsx)(`p`,{className:`text-sm text-foreground truncate`,children:e.label}),(0,z.jsx)(`p`,{className:`text-2xs text-muted-foreground truncate`,children:e.type===`repository`?`Repository`:`${e.repo.name}${e.path}`})]},`${e.repo.id}-${e.type}-${e.path||e.label}`))})]})]}),(0,z.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,z.jsxs)(`button`,{onClick:()=>t(`/upload`),className:`flex items-center gap-2 rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors`,children:[(0,z.jsx)(Lp,{className:`h-3.5 w-3.5`}),(0,z.jsx)(`span`,{className:`hidden sm:inline`,children:`Upload`})]}),(0,z.jsx)(`button`,{onClick:()=>l?.sourceUrl&&window.open(l.sourceUrl,`_blank`,`noopener,noreferrer`),disabled:!l?.sourceUrl,title:l?.sourceUrl?`Open repository source`:`No GitHub URL available`,className:`flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-40 disabled:cursor-not-allowed transition-colors`,children:(0,z.jsx)(Dp,{className:`h-4 w-4`})}),(0,z.jsxs)(`div`,{ref:y,className:`relative`,children:[(0,z.jsxs)(`button`,{onClick:()=>m(!p),className:`relative flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground transition-colors`,children:[(0,z.jsx)(yp,{className:`h-4 w-4`}),S>0&&(0,z.jsx)(`span`,{className:`absolute top-1 right-1 h-2 w-2 rounded-full bg-primary`})]}),p&&(0,z.jsxs)(`div`,{className:`absolute top-full right-0 mt-1 w-80 rounded-lg border border-border bg-popover shadow-lg animate-scale-in z-50`,children:[(0,z.jsxs)(`div`,{className:`p-3 border-b border-border flex items-center justify-between`,children:[(0,z.jsx)(`h3`,{className:`text-sm font-medium`,children:`Notifications`}),S>0&&(0,z.jsxs)(`span`,{className:`text-2xs text-muted-foreground`,children:[S,` unread`]})]}),(0,z.jsx)(`div`,{className:`p-2 max-h-64 overflow-y-auto scrollbar-thin`,children:n.length===0?(0,z.jsx)(`p`,{className:`px-3 py-4 text-sm text-muted-foreground text-center`,children:`No notifications`}):n.map(t=>(0,z.jsxs)(`button`,{onClick:()=>r(t.id),className:e(`w-full text-left px-3 py-2 rounded-md hover:bg-accent transition-colors`,!t.read&&`bg-accent/50`),children:[(0,z.jsx)(`p`,{className:`text-sm font-medium text-foreground`,children:t.title}),(0,z.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:t.message})]},t.id))})]})]}),(0,z.jsxs)(`div`,{ref:b,className:`relative`,children:[(0,z.jsx)(`button`,{onClick:()=>_(!g),className:`flex h-8 w-8 items-center justify-center rounded-full bg-primary/20 text-primary hover:bg-primary/30 transition-colors`,children:(0,z.jsx)(Rp,{className:`h-4 w-4`})}),g&&(0,z.jsx)(`div`,{className:`absolute top-full right-0 mt-1 w-48 rounded-lg border border-border bg-popover shadow-lg animate-scale-in z-50`,children:(0,z.jsxs)(`div`,{className:`p-1`,children:[(0,z.jsxs)(`button`,{onClick:()=>{t(`/settings`),_(!1)},className:`w-full flex items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-accent transition-colors`,children:[(0,z.jsx)(Fp,{className:`h-4 w-4`}),` Settings`]}),(0,z.jsxs)(`button`,{disabled:!0,className:`w-full flex items-center gap-2 rounded-md px-3 py-2 text-sm text-muted-foreground cursor-not-allowed`,children:[(0,z.jsx)(Mp,{className:`h-4 w-4`}),` Sign Out Coming Soon`]})]})})]})]})]})}function pm(e){let t=[];for(let n of e)n.type===`file`&&t.push({name:n.name,path:n.path}),n.children&&t.push(...pm(n.children));return t}function mm(){let e=Qp(e=>e.notifications),t=(0,h.useRef)(0);(0,h.useEffect)(()=>{if(e.length>t.current){let t=e[0];if(t)switch(t.type){case`success`:pr.success(t.title,{description:t.message});break;case`error`:pr.error(t.title,{description:t.message});break;case`warning`:pr.warning(t.title,{description:t.message});break;default:pr.info(t.title,{description:t.message})}}t.current=e.length},[e])}function hm(){let e=Qp(e=>e.setSearchOpen);(0,h.useEffect)(()=>{function t(t){(t.metaKey||t.ctrlKey)&&t.key===`k`&&(t.preventDefault(),e(!0)),t.key===`Escape`&&e(!1)}return document.addEventListener(`keydown`,t),()=>document.removeEventListener(`keydown`,t)},[e])}function gm(){let{sidebarCollapsed:t}=Qp();return mm(),hm(),(0,z.jsxs)(`div`,{className:`flex h-screen overflow-hidden`,children:[(0,z.jsx)(em,{}),(0,z.jsxs)(`div`,{className:e(`flex flex-1 flex-col transition-all duration-200`,t?`ml-16`:`ml-60`),children:[(0,z.jsx)(fm,{}),(0,z.jsx)(`main`,{className:`flex-1 overflow-y-auto scrollbar-thin p-6`,children:(0,z.jsx)(Tn,{})})]})]})}var _m=`modulepreload`,vm=function(e){return`/`+e},ym={},bm=function(e,t,n){let r=Promise.resolve();if(t&&t.length>0){let e=document.getElementsByTagName(`link`),i=document.querySelector(`meta[property=csp-nonce]`),a=i?.nonce||i?.getAttribute(`nonce`);function o(e){return Promise.all(e.map(e=>Promise.resolve(e).then(e=>({status:`fulfilled`,value:e}),e=>({status:`rejected`,reason:e}))))}function s(e){return import.meta.resolve?import.meta.resolve(e):new URL(e,new URL(`../../../src/node/plugins/importAnalysisBuild.ts`,import.meta.url)).href}r=o(t.map(t=>{if(t=vm(t,n),t=s(t),t in ym)return;ym[t]=!0;let r=t.endsWith(`.css`);for(let n=e.length-1;n>=0;n--){let i=e[n];if(i.href===t&&(!r||i.rel===`stylesheet`))return}let i=document.createElement(`link`);if(i.rel=r?`stylesheet`:_m,r||(i.as=`script`),i.crossOrigin=``,i.href=t,a&&i.setAttribute(`nonce`,a),document.head.appendChild(i),r)return new Promise((e,n)=>{i.addEventListener(`load`,e),i.addEventListener(`error`,()=>n(Error(`Unable to preload CSS for ${t}`)))})}))}function i(e){let t=new Event(`vite:preloadError`,{cancelable:!0});if(t.payload=e,window.dispatchEvent(t),!t.defaultPrevented)throw e}return r.then(t=>{for(let e of t||[])e.status===`rejected`&&i(e.reason);return e().catch(i)})},xm=Fn([{element:(0,z.jsx)(gm,{}),children:[{path:`/`,lazy:async()=>{let{DashboardPage:e}=await bm(async()=>{let{DashboardPage:e}=await import(`./DashboardPage-B7P5IANl.js`);return{DashboardPage:e}},__vite__mapDeps([0,1,2,3,4,5,6,7]));return{Component:e}}},{path:`/repositories`,lazy:async()=>{let{RepositoriesPage:e}=await bm(async()=>{let{RepositoriesPage:e}=await import(`./RepositoriesPage-GMDpbLMw.js`);return{RepositoriesPage:e}},__vite__mapDeps([8,1,4,5,6,7]));return{Component:e}}},{path:`/repositories/:id`,lazy:async()=>{let{RepositoryDetailPage:e}=await bm(async()=>{let{RepositoryDetailPage:e}=await import(`./RepositoryDetailPage-DG0b2Pf1.js`);return{RepositoryDetailPage:e}},__vite__mapDeps([9,1,10,11,3,12,13,14,15,16,4,5,6,7]));return{Component:e}}},{path:`/upload`,lazy:async()=>{let{UploadPage:e}=await bm(async()=>{let{UploadPage:e}=await import(`./UploadPage-04HmGrk-.js`);return{UploadPage:e}},__vite__mapDeps([17,1,18,19,13,14,16,4,6]));return{Component:e}}},{path:`/analysis/:id`,lazy:async()=>{let{AnalysisPipelinePage:e}=await bm(async()=>{let{AnalysisPipelinePage:e}=await import(`./AnalysisPipelinePage-Zvy8DKxE.js`);return{AnalysisPipelinePage:e}},__vite__mapDeps([20,1,10,21,4,6]));return{Component:e}}},{path:`/architecture`,lazy:async()=>{let{ArchitecturePage:e}=await bm(async()=>{let{ArchitecturePage:e}=await import(`./ArchitecturePage-7B6CwUGU.js`);return{ArchitecturePage:e}},__vite__mapDeps([22,1,2,18,23,11,21,24,12,14,16,5,6,25]));return{Component:e}}},{path:`/dependencies`,lazy:async()=>{let{DependenciesPage:e}=await bm(async()=>{let{DependenciesPage:e}=await import(`./DependenciesPage-L38XGsJu.js`);return{DependenciesPage:e}},__vite__mapDeps([26,1,24,15,4,5,6,27]));return{Component:e}}},{path:`/review`,lazy:async()=>{let{EngineeringReviewPage:e}=await bm(async()=>{let{EngineeringReviewPage:e}=await import(`./EngineeringReviewPage-BpcXtZzj.js`);return{EngineeringReviewPage:e}},__vite__mapDeps([28,1,18,23,19,3,24,12,16,4,5,6]));return{Component:e}}},{path:`/ai-workspace`,lazy:async()=>{let{AIWorkspacePage:e}=await bm(async()=>{let{AIWorkspacePage:e}=await import(`./AIWorkspacePage-Ddm0QeRu.js`);return{AIWorkspacePage:e}},__vite__mapDeps([29,1,19,4,5,6,30,27]));return{Component:e}}},{path:`/documentation`,lazy:async()=>{let{DocumentationPage:e}=await bm(async()=>{let{DocumentationPage:e}=await import(`./DocumentationPage-CP6VAto-.js`);return{DocumentationPage:e}},__vite__mapDeps([31,1,24,4,5,6,27]));return{Component:e}}},{path:`/insights`,lazy:async()=>{let{InsightsPage:e}=await bm(async()=>{let{InsightsPage:e}=await import(`./InsightsPage-BlJ2erOS.js`);return{InsightsPage:e}},__vite__mapDeps([32,1,4,5,6,27]));return{Component:e}}},{path:`/settings`,lazy:async()=>{let{SettingsPage:e}=await bm(async()=>{let{SettingsPage:e}=await import(`./SettingsPage-vKuDSF0h.js`);return{SettingsPage:e}},__vite__mapDeps([33,1,4,30]));return{Component:e}}}]}]);function Sm({children:e}){let t=Qp(e=>e.repositories),n=Qp(e=>e.activeRepositoryId),r=Qp(e=>e.setActiveRepositoryId),i=Qp(e=>e.setRepositories);(0,h.useEffect)(()=>{let e=!1;async function t(){let t=await sm.fetchRepositories();e||i(t)}return t(),()=>{e=!0}},[i]);let a=(0,h.useMemo)(()=>{let e=t.find(e=>e.id===n)||null;return{repositories:t,activeRepository:e,activeRepositoryId:n,completedRepositories:t.filter(e=>e.status===`completed`),hasRepositories:t.length>0,setActiveRepositoryId:r}},[n,t,r]);return(0,z.jsx)(lm.Provider,{value:a,children:e})}function Cm(){return(0,z.jsxs)(Sm,{children:[(0,z.jsx)(Gn,{router:xm}),(0,z.jsx)(kr,{position:`bottom-right`,toastOptions:{style:{background:`hsl(var(--card))`,border:`1px solid hsl(var(--border))`,color:`hsl(var(--foreground))`}}})]})}_.createRoot(document.getElementById(`root`)).render((0,z.jsx)(h.StrictMode,{children:(0,z.jsx)(Cm,{})}));export{pp as C,rn as D,$t as E,p as O,vp as S,wn as T,Tp as _,Wp as a,xp as b,Ip as c,Np as d,jp as f,Ep as g,Dp as h,Zp as i,Fp as l,kp as m,sm as n,Bp as o,Ap as p,Qp as r,Lp as s,dm as t,Pp as u,wp as v,Ur as w,bp as x,Sp as y}; \ No newline at end of file diff --git a/dist/assets/package-Dhq59A-j.js b/dist/assets/package-Dhq59A-j.js deleted file mode 100644 index 98973042..00000000 --- a/dist/assets/package-Dhq59A-j.js +++ /dev/null @@ -1 +0,0 @@ -import{S as e}from"./index-QB2QUwKm.js";var t=e(`Package`,[[`path`,{d:`m7.5 4.27 9 5.15`,key:`1c824w`}],[`path`,{d:`M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z`,key:`hh9hay`}],[`path`,{d:`m3.3 7 8.7 5 8.7-5`,key:`g66t2b`}],[`path`,{d:`M12 22V12`,key:`d0xqtd`}]]);export{t}; \ No newline at end of file diff --git a/dist/assets/panel-left-open-DLIMLp_X.js b/dist/assets/panel-left-open-DLIMLp_X.js deleted file mode 100644 index a71eb7ce..00000000 --- a/dist/assets/panel-left-open-DLIMLp_X.js +++ /dev/null @@ -1 +0,0 @@ -import{S as e}from"./index-QB2QUwKm.js";var t=e(`ChevronRight`,[[`path`,{d:`m9 18 6-6-6-6`,key:`mthhwq`}]]),n=e(`CodeXml`,[[`path`,{d:`m18 16 4-4-4-4`,key:`1inbqp`}],[`path`,{d:`m6 8-4 4 4 4`,key:`15zrgr`}],[`path`,{d:`m14.5 4-5 16`,key:`e7oirm`}]]),r=e(`Copy`,[[`rect`,{width:`14`,height:`14`,x:`8`,y:`8`,rx:`2`,ry:`2`,key:`17jyea`}],[`path`,{d:`M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2`,key:`zix9uf`}]]),i=e(`Database`,[[`ellipse`,{cx:`12`,cy:`5`,rx:`9`,ry:`3`,key:`msslwz`}],[`path`,{d:`M3 5V19A9 3 0 0 0 21 19V5`,key:`1wlel7`}],[`path`,{d:`M3 12A9 3 0 0 0 21 12`,key:`mv7ke4`}]]),a=e(`Globe`,[[`circle`,{cx:`12`,cy:`12`,r:`10`,key:`1mglay`}],[`path`,{d:`M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20`,key:`13o1zl`}],[`path`,{d:`M2 12h20`,key:`9i4pu4`}]]),o=e(`Layers`,[[`path`,{d:`m12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83Z`,key:`8b97xw`}],[`path`,{d:`m22 17.65-9.17 4.16a2 2 0 0 1-1.66 0L2 17.65`,key:`dd6zsq`}],[`path`,{d:`m22 12.65-9.17 4.16a2 2 0 0 1-1.66 0L2 12.65`,key:`ep9fru`}]]),s=e(`PanelLeftClose`,[[`rect`,{width:`18`,height:`18`,x:`3`,y:`3`,rx:`2`,key:`afitv7`}],[`path`,{d:`M9 3v18`,key:`fh3hqa`}],[`path`,{d:`m16 15-3-3 3-3`,key:`14y99z`}]]),c=e(`PanelLeftOpen`,[[`rect`,{width:`18`,height:`18`,x:`3`,y:`3`,rx:`2`,key:`afitv7`}],[`path`,{d:`M9 3v18`,key:`fh3hqa`}],[`path`,{d:`m14 9 3 3-3 3`,key:`8010ee`}]]);export{i as a,t as c,a as i,s as n,r as o,o as r,n as s,c as t}; \ No newline at end of file diff --git a/dist/assets/status-5ylMtKLG.js b/dist/assets/status-5ylMtKLG.js deleted file mode 100644 index 363f10c4..00000000 --- a/dist/assets/status-5ylMtKLG.js +++ /dev/null @@ -1 +0,0 @@ -var e={uploading:`info`,analysing:`warning`,completed:`success`,error:`error`};export{e as t}; \ No newline at end of file diff --git a/dist/assets/useRepositoryFeatureStatus-BAj4lrBf.js b/dist/assets/useRepositoryFeatureStatus-BAj4lrBf.js deleted file mode 100644 index f5e81dc5..00000000 --- a/dist/assets/useRepositoryFeatureStatus-BAj4lrBf.js +++ /dev/null @@ -1 +0,0 @@ -import{t as e}from"./index-QB2QUwKm.js";function t(){let{activeRepository:t,completedRepositories:n}=e(),r=n.length===0||!t||t.status!==`completed`?`empty`:`success`,i=r===`empty`?n.length===0?`no-completed-repositories`:`no-active-repository`:null;return{activeRepository:t,completedRepositories:n,status:r,loading:!1,error:null,empty:r===`empty`,success:r===`success`,source:t?.dataSource||null,emptyReason:i,retry:()=>void 0,refresh:()=>void 0}}export{t}; \ No newline at end of file diff --git a/dist/assets/x-Bf4UPE8b.js b/dist/assets/x-Bf4UPE8b.js deleted file mode 100644 index 3d5e0c00..00000000 --- a/dist/assets/x-Bf4UPE8b.js +++ /dev/null @@ -1 +0,0 @@ -import{S as e}from"./index-QB2QUwKm.js";var t=e(`X`,[[`path`,{d:`M18 6 6 18`,key:`1bl5f8`}],[`path`,{d:`m6 6 12 12`,key:`d8bk6v`}]]);export{t}; \ No newline at end of file diff --git a/dist/assets/zap-8B5FG3kN.js b/dist/assets/zap-8B5FG3kN.js deleted file mode 100644 index 3df2586d..00000000 --- a/dist/assets/zap-8B5FG3kN.js +++ /dev/null @@ -1 +0,0 @@ -import{S as e}from"./index-QB2QUwKm.js";var t=e(`Box`,[[`path`,{d:`M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z`,key:`hh9hay`}],[`path`,{d:`m3.3 7 8.7 5 8.7-5`,key:`g66t2b`}],[`path`,{d:`M12 22V12`,key:`d0xqtd`}]]),n=e(`Tag`,[[`path`,{d:`M12.586 2.586A2 2 0 0 0 11.172 2H4a2 2 0 0 0-2 2v7.172a2 2 0 0 0 .586 1.414l8.704 8.704a2.426 2.426 0 0 0 3.42 0l6.58-6.58a2.426 2.426 0 0 0 0-3.42z`,key:`vktsd0`}],[`circle`,{cx:`7.5`,cy:`7.5`,r:`.5`,fill:`currentColor`,key:`kqv944`}]]),r=e(`TriangleAlert`,[[`path`,{d:`m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3`,key:`wmoenq`}],[`path`,{d:`M12 9v4`,key:`juzpu7`}],[`path`,{d:`M12 17h.01`,key:`p32p05`}]]),i=e(`Zap`,[[`path`,{d:`M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z`,key:`1xq2db`}]]);export{t as i,r as n,n as r,i as t}; \ No newline at end of file diff --git a/dist/index.html b/dist/index.html deleted file mode 100644 index c44b66a5..00000000 --- a/dist/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - - PARTHA - Understand Any Codebase in Minutes - - - - - -
    - - diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index b7dca0aa..00000000 --- a/docker-compose.yml +++ /dev/null @@ -1,60 +0,0 @@ -services: - api: - build: - context: ./apps/backend - dockerfile: Dockerfile - ports: - - "8000:8000" - environment: - APP_ENV: ${APP_ENV:-development} - LOG_LEVEL: ${LOG_LEVEL:-INFO} - LOG_FORMAT: ${LOG_FORMAT:-text} - DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-partha}:${POSTGRES_PASSWORD:-partha}@postgres:5432/${POSTGRES_DB:-partha} - REDIS_URL: redis://redis:6379/0 - STORAGE_PATH: /data/partha - CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:5173,http://127.0.0.1:5173} - AUTO_CREATE_TABLES: ${AUTO_CREATE_TABLES:-true} - volumes: - - partha_storage:/data/partha - depends_on: - postgres: - condition: service_healthy - redis: - condition: service_started - healthcheck: - test: - [ - "CMD", - "python", - "-c", - "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/ready', timeout=5).read()", - ] - interval: 10s - timeout: 5s - retries: 6 - start_period: 10s - - postgres: - image: postgres:16-alpine - environment: - POSTGRES_USER: ${POSTGRES_USER:-partha} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-partha} - POSTGRES_DB: ${POSTGRES_DB:-partha} - ports: - - "5432:5432" - volumes: - - postgres_data:/var/lib/postgresql/data - healthcheck: - test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] - interval: 5s - timeout: 5s - retries: 10 - - redis: - image: redis:7-alpine - ports: - - "6379:6379" - -volumes: - postgres_data: - partha_storage: diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md new file mode 100644 index 00000000..44e8006b --- /dev/null +++ b/docs/DEVELOPMENT.md @@ -0,0 +1,164 @@ +# Local development and troubleshooting + +A single walkthrough for getting PARTHA running locally, running its test +suites, and resolving the failures a new contributor is most likely to hit. +It consolidates and cross-links the [root README](../README.md#run-partha-locally), +[CONTRIBUTING.md](../CONTRIBUTING.md#1-setup), and the +[backend](../apps/backend/README.md) and [frontend](../apps/frontend/README.md) +guides rather than duplicating them — follow those links for anything not +covered here. + +## Prerequisites + +| Tool | Version | Needed for | +| --- | --- | --- | +| Python | 3.12 or 3.13 | Backend | +| Node.js | 22 | Frontend and workflow scripts | +| Git | Recent version | Checkout and public GitHub import | + +There is no Docker Compose file for local development. A root [`Dockerfile`](../Dockerfile) +exists for single-service hosting (building the frontend and serving it from +the same FastAPI process), but day-to-day development runs the backend and +frontend as two separate local processes, described below. + +## 1. Clone and start the backend + +```bash +git clone https://github.com/Second-Origin/PARTHA.git +cd PARTHA + +cd apps/backend +python3.13 -m venv .venv +source .venv/bin/activate +pip install -e . +cd ../.. + +npm run dev:backend +``` + +The API starts at `http://localhost:8000`. Confirm it is up with `curl http://localhost:8000/ready` +(expects `{"status":"ready", ...}`) or open `http://localhost:8000/docs` for the OpenAPI UI. + +No `.env` file is required — every setting has a working default. Local +development defaults to SQLite at `apps/backend/.local/partha.db` and local +filesystem storage at `apps/backend/.local/storage`, so nothing else needs to +be installed or running. Copy `apps/backend/.env.example` to `.env` only if +you need to change one specific value. + +## 2. Start the frontend + +In a second terminal, from the repository root: + +```bash +npm ci --prefix apps/frontend +npm run dev:frontend +``` + +Open `http://localhost:5173`. There is no seeded account or sample +repository — register a new local account through the UI, then add a +repository (upload an archive or import a public GitHub repository over +HTTPS) and start analysis. + +`VITE_API_URL` sets the backend origin the frontend calls; leave it unset to +use the default (`http://localhost:8000`). It is inlined at build time, so +changing it requires restarting the dev server. + +## 3. Running the test suites and checks + +```bash +# Backend (from apps/backend, with its venv active) +python -m pytest + +# Backend static analysis (needs apps/backend/requirements-dev.txt installed first) +ruff check app scripts tests +ruff format --check app scripts tests +mypy app scripts + +# Frontend, from the repository root +npm --prefix apps/frontend run test # vitest +npm run lint:frontend # eslint +npm run build:frontend # tsc -b && vite build -- type errors surface here, not in lint + +# API contract: regenerate the frontend's DTOs from the live FastAPI schema +npm run generate:api-contract +npm --prefix apps/frontend run generate:api-contract -- --check # fails if the checked-in file is stale + +# Disposable fixtures + Playwright browser journeys +npm run test:e2e +``` + +Backend tests default to a per-test SQLite database and an in-memory rate +limiter, so nothing external is required to run the full suite. Two specific +tests opt into real services and skip cleanly without them: + +- the PostgreSQL migration round-trip and refresh-token concurrency test skip + with `set PARTHA_TEST_PG_URL to run the Postgres concurrency test` unless + `PARTHA_TEST_PG_URL` is set to a real Postgres connection string; +- the Redis-backed rate-limiter tests skip with + `set PARTHA_TEST_REDIS_URL to run the Redis backend test` unless + `PARTHA_TEST_REDIS_URL` is set. + +CI provides both services and runs everything; neither is required for local +development. + +## Troubleshooting + +**Backend won't start: `ERROR: [Errno 48] error while attempting to bind on address ('127.0.0.1', 8000): address already in use`** +Something else (often a previous `dev:backend` you forgot to stop) is already +listening on 8000. Stop it, or start on a different port: +`python -m uvicorn app.main:app --reload --reload-dir app --port 8001` (and +point the frontend at it with `VITE_API_URL=http://localhost:8001`). + +**Frontend silently starts on a different port than 5173** +Vite does not fail on a port conflict — it logs `Port 5173 is in use, trying +another one...` and starts on the next free port (typically 5174) instead. +Check the terminal output for the actual `Local:` URL it printed rather than +assuming 5173. + +**You are logged out on every page reload; `POST /auth/refresh` returns 401** +The refresh cookie is `SameSite=Lax`, so the browser only sends it back on a +request to the *same site* the page is on. If the page origin and `VITE_API_URL` +disagree on host — most commonly one is `localhost` and the other is +`127.0.0.1` — the cookie is withheld from `/auth/refresh` and the session never +re-establishes. Serve the frontend and point `VITE_API_URL` at the **same +host** (both `localhost` or both `127.0.0.1`). The documented defaults already +match (`localhost:5173` + `http://localhost:8000`); this only bites if you +change one of them. The same-site rule applies to real deployments too — see +[System Overview § Authentication and session flow](architecture/SYSTEM_OVERVIEW.md#authentication-and-session-flow). + +**Backend refuses to start with a message naming a table that "already exists"** +This is local database drift: `AUTO_CREATE_TABLES` (on by default in +`development`/`test`) built a table directly from the models without ever +recording it as an applied Alembic migration, so a later real migration for +that same table fails when it tries to create it again. The startup error +names the conflicting table(s) and the exact recovery — see +[backend README § Local database schema drift](../apps/backend/README.md#local-database-schema-drift-developmenttest-only) +for the full explanation and the `alembic stamp` / `alembic upgrade head` +commands to run. + +**`ruff: command not found` / `mypy: command not found` when running static analysis** +The runtime install (`pip install -e .`) deliberately excludes development +tooling. Install it first: `pip install -r apps/backend/requirements-dev.txt` +(see [backend README § Static analysis](../apps/backend/README.md#static-analysis)). + +**`npm --prefix apps/frontend run generate:api-contract -- --check` fails with `Generated API contract is stale at character N. Run \`npm run generate:api-contract\`.`** +A backend schema change (a new/changed Pydantic model or route) was made +without regenerating the frontend's DTOs. Run `npm run generate:api-contract` +from the repository root, review the diff to `generated.ts`, and commit it +alongside the backend change that caused it. + +**A test needing PostgreSQL or Redis is skipped instead of failing** +That's expected locally — see the skip reasons in §3 above. It is not a sign +of a broken local setup; CI runs those tests against real services. + +## Reporting a reproducible issue + +Search open issues first to avoid a duplicate. For a bug, use the **Bug +Report** template in [`.github/ISSUE_TEMPLATE/`](../.github/ISSUE_TEMPLATE/) +and include exact reproduction steps (route, endpoint, input repository, +commands run), expected versus actual behaviour, and any relevant log output +— see [CONTRIBUTING.md § Choosing a template](../CONTRIBUTING.md#choosing-a-template). +**Never file a security vulnerability as a public issue** — report it +privately through [SECURITY.md](../SECURITY.md) instead. For a question that +isn't yet a confirmed bug, ask on [Discord](https://discord.gg/qvk9DcxDA) +first. diff --git a/docs/README.md b/docs/README.md index e4ce7c8d..bc64ca0c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,58 +1,55 @@ # PARTHA Documentation -This directory contains durable product, architecture, operations, audit, and brand documentation for PARTHA. +Maintained guides describe the system as it currently exists. RFCs describe accepted engineering +design contracts and must label what is accepted, implemented, and deferred. Historical QA records +are point-in-time evidence, not current-state documentation. -Start with the root `README.md` for public orientation. Use this index when you need deeper implementation or maintainer context. +**Current behaviour belongs in documentation. Future work belongs in GitHub issues.** If you find a claim the code does not support, that is a bug — please open an issue. -## Documentation Map +## Index -| Area | Document | Purpose | +| Document | Reader | Purpose | | --- | --- | --- | -| Product | `product/PUBLIC_FACE_AUDIT.md` | Public positioning, documentation audit, and roadmap for the public-face redesign. | -| Brand | `brand/VISUAL_IDENTITY.md` | Logo, colors, typography, diagram style, and visual language. | -| Architecture | `architecture/REPOSITORY_INTELLIGENCE_ENGINE.md` | Repository Intelligence Engine boundaries, lifecycle, outputs, and consumers. | -| Architecture | `architecture/AI_ARCHITECTURE.md` | AI workspace architecture, provider abstraction, prompt/context flow, and non-goals. | -| Operations | `operations/production-deployment.md` | Production deployment baseline, environment, health checks, rollback, and operational limits. | -| Operations | `operations/release-management.md` | Versioning, release validation workflow, and hotfix flow. | -| Operations | `operations/dependency-management.md` | Frontend/backend dependency maintenance and security update process. | -| Operations | `operations/observability.md` | Request IDs, structured logs, redaction, metrics, and readiness checks. | -| Audit | `audit/CORE_1_INGESTION_PIPELINE_AUDIT.md` | Ingestion stabilization audit evidence. | -| Audit | `audit/CORE_2_REPOSITORY_INTELLIGENCE_AUDIT.md` | Repository Intelligence audit and refactor summary. | - -## Recommended Reading Paths - -### New Contributor - -1. `../README.md` -2. `../CONTRIBUTING.md` -3. `architecture/REPOSITORY_INTELLIGENCE_ENGINE.md` -4. `../apps/backend/README.md` or `../apps/frontend/README.md` - -### Backend Contributor - -1. `architecture/REPOSITORY_INTELLIGENCE_ENGINE.md` -2. `architecture/AI_ARCHITECTURE.md` -3. `operations/observability.md` -4. `operations/dependency-management.md` - -### Maintainer / Release Reviewer - -1. `operations/release-management.md` -2. `operations/production-deployment.md` -3. `operations/observability.md` -4. `product/PUBLIC_FACE_AUDIT.md` - -### Public Documentation / Brand Work - -1. `product/PUBLIC_FACE_AUDIT.md` -2. `brand/VISUAL_IDENTITY.md` -3. `../README.md` - -## Documentation Rules - -- Keep the root README product-oriented and implementation-honest. -- Keep durable architecture detail under `docs/architecture/`. -- Keep operational procedures under `docs/operations/`. -- Keep audit evidence under `docs/audit/`. -- Do not add empty placeholder docs. -- If a feature is not implemented, describe it as roadmap or planned work. +| [README](../README.md) | Anyone evaluating or running PARTHA | What PARTHA is, what currently works, how to run it locally, and its limitations. | +| [CONTRIBUTING](../CONTRIBUTING.md) | Contributors | The contribution rules: fork-first workflow, claiming an issue, branch naming, rebasing, pull requests, Definition of Ready and Done. Read before opening a PR. | +| [Local development and troubleshooting](DEVELOPMENT.md) | New contributors | A single walkthrough for starting the backend and frontend, running every test/lint/build command, the local database and API-contract failures you're most likely to hit and how to fix them, and how to report a reproducible issue. | +| [SECURITY](../SECURITY.md) | Anyone reporting a vulnerability | How to disclose privately. Never open a public issue for a vulnerability. | +| [AI provider egress policy](security/AI_PROVIDER_EGRESS.md) | Operators and backend contributors | Deployment-owned provider destination policy, DNS pinning, redirect handling, safe defaults, and required production network controls. | +| [Connecting an AI provider](operations/AI_PROVIDER_SETUP.md) | Anyone enabling the optional AI workspace | The end-to-end setup path (Settings and the `ai/*` API), per-provider requirements, the egress-policy prerequisite for a local or custom Ollama endpoint, Ollama's slow-first-request and one-at-a-time behaviour, and a troubleshooting table. | +| [Database migration rehearsal and recovery](operations/DATABASE_MIGRATION_REHEARSAL.md) | Operators and backend contributors | Disposable Alembic rehearsal command, supported baseline evidence, production preflight, and truthful restore-based recovery decisions. | +| [WCAG 2.2 AA accessibility baseline](accessibility/WCAG_2_2_AA_BASELINE.md) | Frontend contributors and accessibility reviewers | Reproducible automated coverage for the Phase 0 journeys, the outstanding human verification checklist, confirmed findings, and linked follow-up issues. | +| [CODE_OF_CONDUCT](../CODE_OF_CONDUCT.md) | Everyone | Expected conduct and how to report a violation. | +| [System Overview](architecture/SYSTEM_OVERVIEW.md) | Contributors and maintainers | Current components, ingestion flow, persistence, consumers, trust boundaries, and architectural limitations. | +| [Repository Intelligence](architecture/REPOSITORY_INTELLIGENCE.md) | Anyone changing analysis behaviour | What is extracted, what is deterministic versus heuristic, how facts are persisted, who consumes them, what consumers must not do, and where evidence and provenance stop. **Read this before touching analysis.** | +| [Repository Intelligence v1 RFC](architecture/REPOSITORY_INTELLIGENCE_V1_RFC.md) | Contributors on the intelligence track | **Accepted** architectural contract (RFC-0001, tracking [#86](https://github.com/Second-Origin/PARTHA/issues/86)) for the snapshot/evidence schema: deterministic entity keys, separate inferred assertions, complete derivation chains, producer identity and producer-version tracking, provenance, immutability, diagnostics, versioning, and total canonical graph hashing. **Status: Accepted** — independently approved by [@SHAURYAKSHARMA24](https://github.com/SHAURYAKSHARMA24) on [Issue #86](https://github.com/Second-Origin/PARTHA/issues/86#issuecomment-4990687780) and [PR #101](https://github.com/Second-Origin/PARTHA/pull/101#pullrequestreview-4712687647) on 2026-07-16. The durable snapshot pipeline and product-consumer migration are implemented; §17 tracks the remaining contract gaps. | +| [Repository Intelligence relationship resolution](architecture/REPOSITORY_INTELLIGENCE_RESOLUTION.md) | Contributors changing extraction or resolution | The deterministic resolver (Issue [#91](https://github.com/Second-Origin/PARTHA/issues/91)): how stored observations become `resolved` edges, the one/zero/many candidate outcomes, and the `RI-RES-UNRESOLVED` / `RI-RES-AMBIGUOUS` diagnostics emitted instead of a guess. | +| [Repository Lineage RFC](architecture/REPOSITORY_LINEAGE_RFC.md) | Contributors on the intelligence track | **Accepted design** (RFC-0002, tracking [#298](https://github.com/Second-Origin/PARTHA/issues/298)) for owner-scoped repository lineage identity, unlineaged standalone imports, 1-based never-reused sequence allocation, deletion behavior, and database-enforced membership integrity. The implementation-critical PR #328 amendment was explicitly approved by the owner on 2026-08-19, authorizing writing and testing #299. The #322 rehearsal and recovery process remains required against the eventual migration before its implementation PR merges. Revision identity remains governed by RFC-0001 §3. | +| [Repository Lineage migration plan](architecture/REPOSITORY_LINEAGE_MIGRATION_PLAN.md) | Reviewers of #299 | Implementation-grade current-state, Alembic/backfill, ownership, deletion, concurrency, validation, and rollback plan for #299. RFC-0002 owns the architecture decisions; this document defines how to migrate and test them and contains no runtime change. | +| [Repository Intelligence golden benchmark](../apps/backend/tests/benchmark/README.md) | Contributors on the intelligence track | The versioned golden fixture corpus, independently authored expected facts, explicit mapping to the production support matrices, real-extractor precision/recall and citation validation, repeated-extraction canonical-hash determinism checks, and CI reports for Issue [#94](https://github.com/Second-Origin/PARTHA/issues/94). | +| [Backend README](../apps/backend/README.md) | Backend contributors | Running the backend, endpoints, configuration, tests. | +| [Frontend README](../apps/frontend/README.md) | Frontend contributors | Running the frontend, structure, commands, tests. | +| [Scripts README](../scripts/README.md) | All contributors | What each helper script does. | + +## Reading paths + +**New contributor** — [README](../README.md) → [CONTRIBUTING](../CONTRIBUTING.md) → [Local development and troubleshooting](DEVELOPMENT.md) → [System Overview](architecture/SYSTEM_OVERVIEW.md) → the README for your area. + +**Changing analysis, parsing, or AI grounding** — [Repository Intelligence](architecture/REPOSITORY_INTELLIGENCE.md), first and in full. + +## Documentation rules + +- Describe what the code does today. +- Never present heuristic or generated output as a guaranteed fact. +- Represent evidence only as precisely as the implementation supports. +- State limitations plainly. An honest gap is more useful than an optimistic claim. +- No placeholder documents. +- Documentation changes in the same pull request as the behaviour it describes. + +## Point-in-time records + +These are **not** maintained descriptions of current behaviour. Each captures +what was checked on a given date and is left unedited so it stays usable as +evidence. Where the product has since changed, the record says so at the top +rather than being rewritten. + +- Internal point-in-time QA notes are preserved in the repository but are not maintained contributor documentation. diff --git a/docs/accessibility/WCAG_2_2_AA_BASELINE.md b/docs/accessibility/WCAG_2_2_AA_BASELINE.md new file mode 100644 index 00000000..204724cb --- /dev/null +++ b/docs/accessibility/WCAG_2_2_AA_BASELINE.md @@ -0,0 +1,170 @@ +# WCAG 2.2 AA accessibility baseline + +This report establishes the reproducible Phase 0 accessibility baseline requested by +[#118](https://github.com/Second-Origin/PARTHA/issues/118). It deliberately separates automated +browser evidence from human and assistive-technology evidence. A green axe run is not a claim +that PARTHA conforms to WCAG 2.2 AA. + +## Audit identity + +| Field | Value | +| --- | --- | +| Original audit date | 2026-07-29 | +| Original source revision | `5bb63ca4e74882b0a08b5e8c761e1997590a5755` plus the issue #118 audit changes in this report's pull request | +| Operating system | Microsoft Windows NT 10.0.26200.0 | +| Latest automated revalidation | 2026-08-18 on the current source revision: 6/6 focused accessibility states and 22/22 full browser-acceptance journeys passed | +| Current source revision | `e4609f3db92de268f3c15f0dadb511d49995dd42` | +| Automated browser | Playwright Chrome for Testing 151.0.7922.34, headless Chromium project | +| Automated viewport | 1440 x 900 CSS pixels | +| Automated zoom | 100% browser zoom | +| Colour scheme | Repository default dark theme | +| Accessibility engine | axe-core 4.12.1 | +| Rule scope | `wcag2a`, `wcag2aa`, `wcag21aa`, and `wcag22aa` tags | + +## Automated route and state coverage + +The checked-in Playwright suite uses the same disposable account, SQLite database, storage +directory, and seven seeded repositories as the browser acceptance gate. It waits for +finite page animations to settle before running axe, so contrast measurements are not taken from +transient opacity frames. + +| Journey | Route and selected state | +| --- | --- | +| Login | `/login`, initial sign-in form | +| Application shell/sidebar | authenticated dashboard, expanded desktop sidebar and top bar | +| Repository list | `/repositories`, seeded success list | +| Repository import | `/upload`, GitHub URL mode with an empty form | +| Architecture graph | `/architecture`, sealed `small` fixture with the graph rendered | +| Node/inspector | `/architecture`, first node selected and inspector dialog open | + +Known violations are not disabled. Each exact rule/target/count is asserted against a stable test +identifier and linked issue. A new rule, a new target, or a changed count fails with the route, +state, rule, impact, offending markup, target selector, axe help URL, and failure summary. When a +follow-up fixes a violation, its exact expectation must be removed in the fixing pull request. + +## Rerun instructions + +From the repository root, install the locked frontend and backend development dependencies: + +```text +npm ci --prefix apps/frontend +python -m venv apps/backend/.venv + +# Windows +apps\backend\.venv\Scripts\python.exe -m pip install -r apps\backend\requirements-dev.txt +apps\backend\.venv\Scripts\python.exe -m pip install -e apps\backend --no-deps + +# POSIX +apps/backend/.venv/bin/python -m pip install -r apps/backend/requirements-dev.txt +apps/backend/.venv/bin/python -m pip install -e apps/backend --no-deps +``` + +Install Chromium once if it is not already present, then run the focused or complete browser gate: + +```text +npm --prefix apps/frontend exec -- playwright install chromium +npm run test:accessibility +npm run test:e2e +``` + +The runner chooses free loopback ports, uses the platform virtual environment when present, creates +fixture archives with Python's standard library, and removes its temporary database, repositories, +and fixture manifest after the run. Playwright filters can be forwarded for diagnosis, for example: + +```text +node scripts/run-e2e-acceptance.mjs e2e/accessibility.spec.ts --grep "architecture graph" +``` + +CI runs `npm --prefix apps/frontend run test`, including the jsdom route smoke checks, and then +`node scripts/run-e2e-acceptance.mjs`, which includes this real-browser baseline. + +## Manual and assistive-technology baseline + +No interactive browser or screen reader was available for this audit. The entries below are +therefore **not tested manually**. Automated Playwright, DOM, accessibility-tree, and axe results +are not substituted for human or screen-reader evidence. + +| Required check | Result | Exact human verification checklist | +| --- | --- | --- | +| Keyboard-only navigation | **Not tested manually** | With a fresh session, use only Tab, Shift+Tab, Enter, Space, arrows, and Escape through login, sidebar, repository list/import, graph, and inspector. Confirm every action is reachable and operable and no keyboard trap occurs. | +| Visible focus | **Not tested manually** | At every focus stop above, confirm a persistent, clearly visible indicator against adjacent colours, including graph nodes, menu triggers, row actions, import controls, and inspector sections. | +| Logical tab order | **Not tested manually** | Record the complete focus sequence for each route at desktop and narrow viewport. Confirm it follows reading order, does not enter hidden navigation, and returns sensibly after drawers/dialogs close. | +| 200% zoom and reflow | **Not tested manually** | At 1280 x 720 CSS pixels, set browser zoom to 200%. Confirm content reflows without two-dimensional page scrolling, clipping, overlap, or lost controls; pan inside the graph must not conceal an equivalent route to its information. | +| Contrast | **Not tested manually** | Use a calibrated contrast analyser on text, focus indicators, icons conveying state, controls, and graph/status colours in every selected state and supported theme. Record foreground/background values and ratios. axe automated findings are listed below but are not a complete manual contrast pass. | +| Reduced motion | **Not tested manually** | Enable the OS/browser `prefers-reduced-motion: reduce` setting before loading the app. Exercise navigation, menus, uploads, graph layout, and inspector transitions. Confirm non-essential motion is removed or reduced and no information depends on animation. | +| Screen-reader smoke test | **Not tested manually** | Record screen-reader name/version, browser/version, and OS. Verify page title/heading/landmarks; labelled login fields and errors; sidebar current page; repository table and row actions; upload mode and form; graph alternative; node inspector name, focus containment, sections, relationships, and close/return focus. | + +The existing seeded architecture visual suite contains automated keyboard and narrow-viewport +assertions. Those checks are useful regression evidence, but they are not recorded as manual +results here. + +## Manual validation close-out (2026-08-18) + +This is a dated manual close-out confirmed by the maintainer on 2026-08-19. It does not replace +the historical **not tested manually** entries above and does not establish full WCAG 2.2 AA +conformance. + +Environment: local seeded fixture app at `http://127.0.0.1:18081`, macOS 26.5.2, Google Chrome +152.0.7977.42. The browser was used as a normal desktop window; native Chrome zoom was reset to +100% after the 200% check. No application code was changed for this validation. + +| Check | Route/state exercised | Result | Evidence and follow-up | +| --- | --- | --- | --- | +| Keyboard-only navigation | Login/auth screens; shell/sidebar and mobile drawer; repository list/upload; architecture graph and node inspector | **Pass — maintainer-confirmed** | On 2026-08-19 the maintainer confirmed complete Tab/Shift+Tab, Enter/Space, Escape, and arrow-key coverage across the required states, with no keyboard trap and disabled controls excluded from focus. | +| Focus visibility and restoration | Login, shell, drawer, overlays, dialogs, route changes, graph, and node inspector | **Pass — maintainer-confirmed** | On 2026-08-19 the maintainer confirmed visible focus indicators at each stop and correct focus containment/restoration after drawers, dialogs, overlays, inspector close, and route changes. | +| 200% zoom and reflow | Dashboard, `/repositories`, loaded `/architecture` graph, node inspector, and equivalent graph information route | **Pass — maintainer-confirmed** | On 2026-08-19 the maintainer confirmed native Chrome 200% reflow without unnecessary two-dimensional scrolling, clipping, overlap, or lost functionality; the graph alternative remained available. | +| Contrast | Text, focus indicators, icons, controls, graph/status colours, and selected states | **Pass — maintainer-confirmed** | On 2026-08-19 the maintainer confirmed the manual contrast review across the validated routes and selected states. Automated axe results remain supplemental and are not treated as a complete conformance claim. | +| Reduced motion | macOS Accessibility → Motion → Reduce motion; navigation, menus, upload, drawers/dialogs, graph, inspector, loading, and success | **Pass — maintainer-confirmed** | On 2026-08-19 the maintainer confirmed the required reduced-motion exercise completed with no information dependent on non-essential animation. The OS setting was restored afterward. | +| Screen-reader smoke test | Login, shell/sidebar, repository list, upload, graph alternative, and inspector | **Pass — maintainer-confirmed** | On 2026-08-19 the maintainer confirmed that macOS VoiceOver was enabled and exercised successfully in the real browser, with the tested experience working as expected and no defect reported. This result is based on direct maintainer confirmation; automated accessibility-tree output remains supplemental. | + +No separate product-defect issue was filed from this pass: no product defect was confirmed. + +## Confirmed violations and follow-up issues + +| Route/state | Automated finding | WCAG 2.2 | Severity and impact | Follow-up | +| --- | --- | --- | --- | --- | +| ~~`/login`, initial form~~ | ~~Registration link relies on colour alone; axe reports `link-in-text-block` and 1.41:1 against surrounding copy.~~ | ~~1.4.1 Use of Color~~ | ~~Serious; the registration path can be missed by low-vision and colour-vision-deficient users.~~ | ~~[#240](https://github.com/Second-Origin/PARTHA/issues/240)~~ | +| ~~Authenticated shell~~ | ~~Notification and account icon buttons have no accessible names (`button-name`).~~ | ~~4.1.2 Name, Role, Value~~ | ~~Critical/high impact; screen-reader and voice-control users cannot identify persistent controls.~~ | ~~[#236](https://github.com/Second-Origin/PARTHA/issues/236)~~ | +| ~~`/repositories`, success list~~ | ~~Every open/delete icon action lacks a repository-specific accessible name (`button-name`).~~ | ~~4.1.2 Name, Role, Value~~ | ~~Critical/high impact; actions, including deletion, cannot be distinguished non-visually.~~ | ~~[#235](https://github.com/Second-Origin/PARTHA/issues/235)~~ | +| ~~Authenticated expanded sidebar~~ | ~~`More` section label is approximately 4.23:1 at 10px (`color-contrast`).~~ | ~~1.4.3 Contrast (Minimum)~~ | ~~Serious; the navigation grouping can be difficult to perceive.~~ | ~~[#238](https://github.com/Second-Origin/PARTHA/issues/238)~~ | +| ~~`/upload`, GitHub URL mode~~ | ~~Title, helper copy, field label, and URL placeholder can fall below 4.5:1 (`color-contrast`); the rebased Chromium run measured the title at 2.82:1 and placeholder at 2.87:1. axe 4.12 can omit individual targets across environments despite shared computed colors. The automated baseline therefore permits each exact known target up to its recorded count; a new target or increased count still fails.~~ | ~~1.4.3 Contrast (Minimum)~~ | ~~Serious; low-vision users can miss the import purpose, field purpose, public-URL constraint, or example format.~~ | ~~[#237](https://github.com/Second-Origin/PARTHA/issues/237)~~ | + +**Resolved by #286** — repository open/delete actions now carry repository-specific accessible names (`Open ` / `Delete `), and their icons are marked decorative. + +**Resolved by the #289 sidebar regrouping** — the single `More` section label was replaced by the `Analysis` and `Assist` labels, rendered at full `text-muted-foreground` rather than `text-muted-foreground/70`, which clears 4.5:1 at that size. The element the #238 baseline was keyed to (`secondary-navigation-label`) no longer exists, so its automated allowance has been removed rather than left to silently permit a violation on an element that is gone. + +**Resolved by the #118/#236/#237/#238/#240 batch (2026-08-18)** — + +- **#240**: the login `Create one` link and its reciprocal register-page `Sign in` link now carry a persistent `underline` at rest instead of `hover:underline` only, so the link no longer depends on colour alone (1.4.1). +- **#236**: the authenticated header's notification and account controls now carry `aria-label` (`Notifications` / `Notifications, N unread` / `Account menu`), and their glyphs are marked `aria-hidden`/`focusable="false"` (4.1.2), matching the #286 convention. +- **#237**: re-investigated rather than restyled. The title/helper/label/placeholder colors already clear 4.5:1 at rest — `text-foreground` and `text-muted-foreground` against `bg-card` compute to roughly 15:1 and 5.6:1 respectively in this theme. The axe failures were an artifact of `expectWcagBaseline`'s animation-settle wait: the GitHub-import panel enters via `AnimatePresence mode="wait"`, and `document.getAnimations()` can be transiently empty in the gap between the outgoing panel's exit finishing and the incoming panel's enter starting, so the old wait resolved before the enter animation began and axe sampled the panel still at its initial (near-zero-opacity) state. `waitForAnimationsSettled` in `e2e/accessibility.spec.ts` now re-checks after a fixed delay to close that window; with the fix, all four `github-import-*` targets measure 0 violations and their allowances have been removed. No application color changed for #237. +- **#238** was already resolved by the #289 sidebar regrouping (above); this batch only re-confirmed it. + +This clears the four Phase 0 follow-up issues #118 opened (#236, #237, #238, #240) at the automated-baseline level described in this report. Issue [#239](https://github.com/Second-Origin/PARTHA/issues/239) is completed and its non-visual architecture equivalent is implemented. The dated manual close-out above records the maintainer-confirmed human checks; "WCAG 2.2 AA baseline established" still means that this report has no outstanding known violation in the tested scope, not a claim of full WCAG 2.2 AA conformance. + +No confirmed automated violation is left only in this report. + +## Architecture graph non-visual equivalent + +The visual graph has partial accessibility affordances: individual React Flow nodes are +keyboard-focusable and have names containing classification, layer, description, file count, and +relationship trust state. Selecting a node opens a named modal inspector with responsibilities, +files, dependencies, and dependents. + +The semantic list/table equivalent is implemented and [#239](https://github.com/Second-Origin/PARTHA/issues/239) +is completed. The manual screen-reader experience is recorded as maintainer-confirmed in the +dated close-out above. + +## Known limitations + +- Historical manual-baseline rows above remain preserved as historical evidence; the dated + close-out above records the maintainer-confirmed checks for this validation cycle. +- Automated coverage is Chromium-only, desktop-only, dark-theme-only, and 100% zoom. +- axe cannot determine overall WCAG conformance, usability, reading order, quality of accessible + names, screen-reader announcements, or whether the graph's non-visual experience is efficient. +- Selected states prioritize the first-use login, persistent shell, populated repository list, + empty GitHub import form, successful small graph, and open inspector. Loading/error states and + other product routes are outside this Phase 0 baseline. +- The original audit was on 2026-07-29; the latest automated revalidation is recorded above. + Follow-up fixes must update both their issue status and the exact known-finding expectations in + the automated baseline. diff --git a/docs/architecture/AI_ARCHITECTURE.md b/docs/architecture/AI_ARCHITECTURE.md deleted file mode 100644 index e69fe635..00000000 --- a/docs/architecture/AI_ARCHITECTURE.md +++ /dev/null @@ -1,158 +0,0 @@ -# AI Architecture - -PARTHA's AI subsystem is designed around one rule: - -> AI providers consume Repository Intelligence. They never parse repositories directly. - -The current implementation supports configured providers, provider connection testing, repository-grounded query prompts, citations from repository context, and a compatibility streaming endpoint. - -## Goals - -- Keep Repository Intelligence as the single source of repository understanding. -- Keep providers behind a common abstraction. -- Keep orchestration provider-agnostic. -- Preserve the existing `/ai/*` API contract and behaviour. - -## Component Responsibilities - -| Component | Responsibility | Must not do | -| --- | --- | --- | -| `AiService` | Route-facing compatibility facade. | Build prompts, read repository intelligence, or call providers directly. | -| `AiOrchestrator` | Coordinate query and provider-test lifecycles. | Contain provider-specific HTTP logic. | -| `AiProviderConfigStore` | Preserve current file-backed provider configuration behaviour. | Redesign secret storage or persistence. | -| `RepositoryContextBuilder` | Transform `RepositoryIntelligence` into provider-safe `RepositoryContext`. | Parse repositories or read dependency manifests. | -| `PromptBuilder` | Transform `RepositoryContext` and user question into `PromptBundle`. | Format provider-specific payloads. | -| `ProviderRegistry` | Register dedicated provider implementations by provider id. | Instantiate providers from request data. | -| `ProviderFactory` | Resolve a provider implementation from validated configuration. | Know provider HTTP details. | -| Dedicated providers | Preserve provider-specific HTTP behaviour behind `AiProvider`. | Access Repository Intelligence or change prompt construction. | -| `LegacyProvider` | Preserve the previous provider implementation as a compatibility reference. | Be registered for runtime provider resolution. | - -## Dependency Graph - -```mermaid -flowchart TD - Routes[AI Routes] - Service[AiService facade] - Orchestrator[AiOrchestrator] - Config[AiProviderConfigStore] - Context[RepositoryContextBuilder] - Intelligence[RepositoryIntelligenceEngine] - Prompt[PromptBuilder] - Factory[ProviderFactory] - Registry[ProviderRegistry] - Providers[OpenAI / Anthropic / Gemini / OpenRouter / Ollama] - - Routes --> Service - Service --> Orchestrator - Orchestrator --> Config - Orchestrator --> Context - Context --> Intelligence - Orchestrator --> Prompt - Orchestrator --> Factory - Factory --> Registry - Registry --> Providers -``` - -## Request Lifecycle - -```mermaid -sequenceDiagram - participant Route as AI Route - participant Service as AiService - participant Orchestrator as AiOrchestrator - participant Context as RepositoryContextBuilder - participant Prompt as PromptBuilder - participant Provider as AiProvider - - Route->>Service: AiQueryRequest - Service->>Orchestrator: query(request) - Orchestrator->>Context: build(record, selected_file) - Context-->>Orchestrator: RepositoryContext - Orchestrator->>Prompt: build(context, question) - Prompt-->>Orchestrator: PromptBundle - Orchestrator->>Provider: complete(config, prompt) - Provider-->>Orchestrator: AiProviderResponse - Orchestrator-->>Route: AiQueryResponse -``` - -Steps: - -1. API route receives an existing `AiQueryRequest`. -2. `AiService.query()` delegates to `AiOrchestrator.query()`. -3. The orchestrator loads the repository record. -4. The orchestrator loads the saved provider configuration. -5. `RepositoryContextBuilder` builds a structured `RepositoryContext` from Repository Intelligence. -6. `PromptBuilder` builds a structured `PromptBundle`. -7. `ProviderFactory` resolves the configured provider. -8. The provider returns a normalized `AiProviderResponse`. -9. The orchestrator returns the existing `AiQueryResponse` shape. - -## Repository Context Boundary - -Providers consume `RepositoryContext` through `PromptBundle`. - -Providers must not: - -- parse repositories; -- read repository files; -- call `RepositoryIntelligenceEngine` directly; -- rebuild architecture, dependency, documentation, or review facts. - -## Context and Prompt Flow - -```mermaid -flowchart LR - RI[Repository Intelligence] - RC[RepositoryContext
    structured facts] - PB[PromptBundle
    system + user prompt] - Provider[Provider-specific HTTP payload] - Response[Normalized AI response] - - RI --> RC - RC --> PB - PB --> Provider - Provider --> Response -``` - -`PromptBuilder` decides how structured repository context becomes prompt text. Providers only translate the provider-neutral prompt bundle into provider-specific HTTP requests. - -## Provider Implementations - -Dedicated provider implementations own provider-specific request construction, -authentication headers, response parsing, and legacy-compatible error -normalization: - -```text -ProviderRegistry - openai -> OpenAIProvider - anthropic -> AnthropicProvider - gemini -> GeminiProvider - openrouter -> OpenRouterProvider - ollama -> OllamaProvider -``` - -No future provider should require changes to `AiOrchestrator`. - -`LegacyProvider` is intentionally kept in the package as a compatibility -reference, but it is not registered by the default dependency graph. - -## Out of Scope - -The current AI architecture intentionally does not implement: - -- provider improvements; -- streaming redesign; -- conversation persistence; -- citation rendering changes; -- secret storage redesign; -- rate limiting; -- health checks; -- frontend changes; -- database migrations. - -## Current Limits - -- Streaming currently adapts a completed response into server-sent events rather than using provider-native streaming. -- Conversation persistence is not implemented. -- API keys are file-backed local configuration, not a production secret-management system. -- Repository context is grounded in current Repository Intelligence depth; richer graph extraction will improve AI grounding later. diff --git a/docs/architecture/REPOSITORY_INTELLIGENCE.md b/docs/architecture/REPOSITORY_INTELLIGENCE.md new file mode 100644 index 00000000..a9d81a00 --- /dev/null +++ b/docs/architecture/REPOSITORY_INTELLIGENCE.md @@ -0,0 +1,331 @@ +# Repository Intelligence + +Repository Intelligence is PARTHA's single repository-understanding boundary. It exists so that a repository is parsed **once** and every product surface reads the same facts, instead of each feature growing its own parser and quietly disagreeing with the others. + +This document describes what the engine actually does today. Read it before changing anything under `apps/backend/app/intelligence/`, and before adding any feature that needs to know something about a repository. + +--- + +## The rule + +> **Consumers must not independently parse repositories or construct a second source of repository truth.** + +The AI subsystem is a consumer like any other, and gets no exemption: + +> **AI is a consumer of Repository Intelligence and must not independently parse or reinterpret repositories.** + +If your feature needs a repository fact that does not exist yet, the answer is always: **add reusable extraction to `app/intelligence/`, then consume it.** It is never: read the files yourself. + +--- + +## What Repository Intelligence currently means + +Production analysis has one authoritative output. A durable job runs the +evidence-backed Python, TypeScript, dependency-manifest, lockfile, and IaC +extractors, resolves stored observations, classifies explicitly heuristic roles, +and seals a normalized `ri.v1` snapshot. It no longer builds or writes the legacy +mutable `RepositoryIntelligence` JSON model. + +The `ri.v1` persistence boundary includes first-class repository revision +columns, normalized snapshot/fact/provenance tables, deterministic canonical +hashing, and a lifecycle store that validates and seals immutable snapshots. +Evidence-backed Python and TypeScript extractors, the dependency-manifest, +lockfile, and IaC extractors, their support matrices, the repository-level +source-policy pipeline, deterministic +[relationship resolution](REPOSITORY_INTELLIGENCE_RESOLUTION.md) over stored +observations, and a heuristic `role-classifier` producer (file- and +symbol-level `classified_as` assertions, always `inferred`) all run in the +durable product job. The versioned, owner-scoped `/intelligence/v1/snapshots` +API reads sealed normalized snapshots only and explicitly rejects unsupported +schema versions; it never falls back to legacy metadata or a repository +working tree. The Architecture Graph and the evidence-backed authentication +explanation (`GET /analysis/{repositoryId}/architecture/authentication`, #95) +both consume that query boundary **exclusively** — modules, primary language, +entry points, frameworks, and relationships are all derived from the sealed +snapshot, with no legacy-metadata fallback for either consumer. + +```mermaid +flowchart LR + Root["Repository on disk
    extracted archive or clone"] + Parser["RepositoryParser
    file tree + metadata"] + Revision[("repositories.revision_*
    immutable source identity")] + Snapshot[("ri_* tables
    sole product read model")] + Consumers["Consumers"] + + Root --> Parser + Root --> Revision + Revision --> Job["Durable analysis job"] + Root --> Job + Job --> Snapshot --> Consumers + Store -->|"from_record()"| Consumers +``` + +--- + +## Where parsing happens + +Repository source enters the intelligence pipeline in two bounded phases: + +1. **`RepositoryParser`** walks the extracted tree during import and persists a path inventory plus basic import metadata. +2. **`AnalysisWorker`** reads the persisted inventory's source paths under the shared size/path policy and supplies bytes to `ExtractionPipeline`. The extractor set is registered once in `app/extraction/__init__.py::production_extractors()`, which the worker's pipeline, the predeclared producer-version set in `AnalysisJobService`, and the golden benchmark all read — a second hand-kept list is how a producer ends up emitting facts a snapshot never declared. No extractor walks the tree itself. + +There is one further direct read that is **not** parsing: `RepositoryService.read_file` serves the explorer's file preview. It is a path-checked read for display only and feeds no analysis. + +Nothing else may open a repository file. The evidence-backed extractors accept +stored bytes from their caller; they do not walk or open a repository themselves. +`ExtractionPipeline` applies normalized-path and source-size policy before +dispatching those bytes through each extractor's real `supports()` method. + +### Syntax-aware extractors are a separate, real boundary + +`PythonExtractor` uses Python's AST and `TypeScriptExtractor` uses tree-sitter. +`DependencyManifestExtractor` and `LockfileExtractor` share one structure-aware +JSON/TOML scanner (`app/extraction/structured.py`) so two producers can never +disagree about where a value sits in the same file, and `IacExtractor` parses +Compose through PyYAML's *composer* rather than `safe_load` because the node +graph's source marks are what make an exact declaration line possible at all. +Every one of them emits normalized nodes, observations, diagnostics, and line +evidence through the `ExtractionResult` contract. Their declared support and blind spots live in +the typed capability registry in `app/extraction/support_matrix.py`; the legacy +`SUPPORT_MATRIX` import is only a derived compatibility view. Durable analysis +stores their output in a sealed normalized snapshot. Historical legacy JSON is +not produced or consumed. + +The registry is the authoritative source for construct status, limitations, +stable ids, benchmark mappings, the supported dependency-manifest, lockfile, and +IaC filenames each extractor's `supports()` reads, and the public capability +assessments rendered in the README. Run +`python scripts/check-capabilities.py` to validate registry structure, +registry-to-benchmark parity, deterministic rendering, and the committed +README block. The command checks only; it never rewrites documentation. + +--- + +## What is currently extracted + +| Field | Contents | How it is derived | +| --- | --- | --- | +| `file` nodes | Observed normalized paths, supported language, content hash. | Repository inventory producer. | +| `symbol` and `module` nodes | Supported Python and TypeScript/JavaScript syntax facts. | AST/tree-sitter extractors with stored evidence spans. | +| `classified_as` assertions | File and symbol roles. | Explicit path/name heuristics, stored as inferred with heuristic confidence. | +| `dependency` nodes | Logical dependency carrying two separate collections: `declarations` (every declared version/specifier, type, ecosystem, workspace/manifest path, span, extractor) and `resolutions` (every lockfile-pinned exact version with its entry, scope, lockfile path, and span). | Supported `package.json`, `requirements.txt`, and `pyproject.toml` manifests, plus supported `package-lock.json` and `poetry.lock` lockfiles, at accepted root or nested workspace paths. | +| `service` nodes | An outbound HTTP destination identified by its absolute origin (`svc:://[:]`). | Syntax-proven `requests`/`httpx`/`fetch`/`axios` call sites with a literal absolute URL. | +| `iac_resource` nodes | A declared infrastructure resource with its type, literal name, manifest path, exact declaration line, and literal image where present. | Docker Compose `services`, `volumes`, and `networks` sections. | +| observations and resolved edges | Imports, calls, routes, dependency declarations, lockfile resolutions, outbound service interactions, IaC resource declarations, and supported relationships. | Syntax/manifest observations resolved only against stored snapshot facts. | +| diagnostics and evidence | Unsupported/malformed/unresolved states plus exact stored spans where available. | Producers and resolver; missing facts are never manufactured. | + +### Deterministic vs. heuristic + +This distinction matters, and consumers must respect it. + +**Deterministic** — the same repository always yields the same answer, and the answer is a fact about the bytes on disk: + +- file paths, names, extensions, sizes, and the file tree; +- supported Python and TypeScript/JavaScript modules, symbols, imports, calls, + route declarations, and implementation relationships, with source spans; +- dependency names and version specifiers **as declared in** the three supported manifests, including accepted nested workspaces, kept distinct from the exact versions **as resolved in** the two supported lockfiles; +- outbound service interactions where the source proves both a literal HTTP method and an absolute literal URL; +- declared Docker Compose services, volumes, and networks with their exact declaration lines; +- resolved graph edges and unresolved/ambiguous diagnostics produced from stored + observations under the published resolution rules; +- primary language and recognized framework labels derived from observed file + languages and declared direct dependencies. + +**Heuristic** — an inference that can be wrong, and is wrong on projects that do not follow common conventions: + +- **file and symbol role** (`service`, `route`, `model`, `repository`, …) — + inferred from explicit path/name rules and stored as `classified_as` + assertions with `truth_class="inferred"`; +- **module grouping and architectural layer** — presentation projections derived + from those inferred roles and path segments; +- **entry points and architecture pattern labels** — projections over inferred + roles and a bounded framework mapping, not universal semantic conclusions. + +**Never present a heuristic output as a deterministic fact** — not in the UI, not in generated documentation, not in a review finding, and not in an AI answer. + +--- + +## How it is persisted today + +Import stores revision identity, parser metadata, and the file tree. After the +client submits analysis, the durable worker builds and stores the normalized +snapshot: + +```text +repositories.revision_kind/value/ref -- first-class immutable revision identity +repositories.file_tree -- parsed tree, JSON +ri_snapshots + ri_* fact tables -- normalized ri.v1 persistence boundary +``` + +The repository API returns `revision: {kind,value,ref}` and retains `commitSha` only as a compatibility alias of `revision.value`. New imports do not stash `commitSha` inside mutable metadata. A new Git commit or changed upload hash creates a new repository record; the same source at the same immutable revision remains a duplicate. + +Historical `repo_metadata["intelligence"]` values may remain in existing rows, +but executable product code ignores them. There is no destructive rewrite and +no filesystem or legacy-engine fallback. + +`SnapshotStore` fixes the complete semantic identity before a build, enforces +same-snapshot foreign keys and provenance, validates derivation chains, computes +the canonical graph hash, and seals the snapshot. Completed snapshots reject +mutation. The query API exposes sealed snapshot metadata, symbols, stored +resolved relationships, inferred assertions, file facts, and evidence spans. +Architecture, the authentication explanation, Engineering Review, Insights, and +Dependency Graph (#158) build entirely from owner-scoped persisted-fact +queries. Documentation and free-form AI context share a bounded immutable +projection over the owner-scoped sealed snapshot for the repository's current +revision. Missing or stale snapshots return the standard 404. + +The authenticated `GET /intelligence/v1/snapshots/{snapshot_id}/impact` query +answers “what depends on this node?” and “what does this node depend on?” from +one named sealed snapshot. It follows only stored, resolved `imports` and +`depends_on` edges: outgoing edges are dependencies and incoming edges are +dependents. Every reached node carries the stored edge, evidence spans, and +immediate derivation that prove its traversal hop. The read is deterministic, +cycle-safe, and bounded to a requested depth from 1 through 10 and at most 100 +reached nodes in each direction. `limitReached: true` means that the result was +cut off at that node limit and must not be treated as exhaustive. The query +never reads a working tree, legacy metadata, or unresolved observations. + +The architecture read is bounded to what the response actually renders (#133): +the relationship predicates Architecture draws, the resolution diagnostics it +displays, `classified_as` assertions, and the file, dependency and repository +nodes it inventories — plus any symbol node that is an endpoint of a rendered +relationship. Symbol nodes that no rendered edge references are not loaded, and +observations are no longer hydrated at all. Covered module paths come from a +single distinct-path read over non-inventory node or observation evidence +instead of walking every evidence row already in memory. Evidence lookups by ID +run in bounded batches. The architecture response therefore stays proportional +to the relationships and diagnostics it exposes; this is a targeted read, not +API pagination. + +--- + +## The knowledge graph + +Node kinds currently persisted are `repository`, `module`, `file`, `symbol`, +`dependency`, `service`, and `iac_resource`. + +`service` and `iac_resource` (#209) are registered stable-key forms in +`canonical.normalize_stable_key`, not free-form future kinds: a `service` key +must be `svc:://[:]` and an `iac_resource` key must be +`iac:::/`. Registering them is what stops a relative +path or a bare host entering a snapshot as though it were a proven destination. +Existing consumers filter by the kinds they render, so the new kinds are +inventoried and counted but are not injected into the Architecture graph, +Dependency Graph, or role classification. + +The resolver can emit `contains`, `defines`, `imports`, `calls`, `implements`, +`routes_to`, `depends_on`, `injects`, `calls_service`, and `declares`. A +consumer must request only the predicates it needs through the snapshot query +layer; the Architecture response, for example, intentionally renders a bounded +subset. + +Edges are backed by snapshot evidence records carrying normalized paths and +inclusive line spans where the producer can provide them. Unresolved or +ambiguous observations remain diagnostics instead of being converted into +speculative edges. + +--- + +## Consumers + +| Consumer | Module | Reads | +| --- | --- | --- | +| Architecture | `app/analysis/architecture.py` | exclusively the sealed snapshot query layer — nodes, resolved edges, `classified_as` assertions, diagnostics, and evidence. An `RI-RES-UNRESOLVED` whose target is a declared dependency or the language platform is not treated as a module relationship gap. No legacy `repo_metadata['intelligence']` read. | +| Authentication explanation (#95) | `app/analysis/authentication.py` | exclusively the sealed snapshot query layer — routes, `routes_to`/`injects`/`calls` edges, `classified_as` assertions, diagnostics. No legacy read. | +| Engineering review | `app/review/` | exclusively one sealed snapshot — diagnostics promoted only when an exact same-snapshot fact and evidence span exist; manifest identity; no legacy read and no scores. | +| Repository insights | `app/insights/` | exclusively one sealed snapshot — defined node, relationship, evidence, diagnostic, language, coverage, and extractor counts, with unresolved relationships split into genuine in-repo gaps and expected external references; no legacy read. | +| Dependency graph (#158) | `app/graph/` | exclusively one sealed snapshot — `dependency` nodes and resolved `depends_on` edges, with declarations merged across manifests (#156) and lockfile resolutions merged onto the same identity (#209); no legacy read. | +| Documentation | `app/services/documentation_service.py` | current-revision sealed projection: observed paths/languages, heuristic roles/modules, routes, dependencies/declarations, diagnostics, and snapshot identity | +| AI | `app/ai/repository_context.py` | the same sealed projection; structural facts only, with no source-file contents or fabricated citations | +| Reports and exports | `app/reports/` | snapshot-backed analysis and Documentation output | + +### What consumers are forbidden from doing + +- Walking the repository filesystem. +- Reading or re-reading dependency manifests. +- Re-implementing language, framework, or config detection. +- Caching their own parallel copy of repository facts. +- Letting an AI provider read repository files or call the engine directly. +- Presenting a heuristic output as a deterministic fact. + +A consumer's job is to transform an owner-scoped sealed-snapshot query or its +bounded projection into a response shape. That is all. + +--- + +## Evidence and provenance + +Two terms with distinct meanings. PARTHA uses them precisely: + +- **Evidence** — the source artifact that supports a repository fact: a file, a declaration, an import, a route, or a configuration entry. +- **Provenance** — the information identifying *where a fact came from*: the revision, file, symbol, line span, and extraction method. + +### What exists today + +Snapshot facts carry exact revision, +extractor, fact, path, and inclusive line-span identity. Architecture renders +those relationships. Authentication and Engineering Review citations open +through the owner-scoped evidence endpoint, which verifies the exact fact/span +and current source bytes against the sealed file fact. Review diagnostics +without that support are counted as omitted and never presented as findings. +Insights counts stored rows and never turns absence into a positive claim. + +Every product path now consumes the sealed model. GitHub imports store a +40-character commit plus resolved ref; uploads store a `sha256:` archive +identity. Consumers require that exact current revision and never select an +arbitrary older "latest" snapshot. + +The honest summary: **durable analysis populates an immutable snapshot with exact +revision identity, spans, producer versions, and derivations. Architecture, +authentication, Review, Insights, Documentation, exports, and free-form AI +context use it.** If no matching job has completed, surfaces return an honest +404/unavailable state rather than falling back to legacy facts. + +This is why AI answers carry no citations. The AI context builder deliberately emits an empty citation list and sends the provider **no source content and no line numbers** — fabricating `1:1` placeholder citations would misrepresent a structural answer as line-level evidence. See [`app/ai/repository_context.py`](../../apps/backend/app/ai/repository_context.py). + +Normalized snapshot facts, authentication explanations, and Review findings +have revision-, fact-, extractor-, and line-addressed evidence. Insights metrics +carry snapshot identity and exact definitions. Free-form AI remains uncited +because provider prose cannot be deterministically mapped to stored facts. + +--- + +## Current limitations + +- **Symbols:** syntax-derived for supported Python and TS/JS constructs, with line spans but limited signatures/nesting and deliberately conservative cross-file resolution. +- **Line spans:** emitted by the Python and TypeScript extractors, stored by durable analysis, and returned unchanged from sealed snapshots. +- **Graph production and consumption:** durable jobs populate normalized immutable graph tables through syntax-aware producers. Every product surface consumes sealed facts exclusively. Documentation and free-form AI use a bounded structural projection and do not receive source contents. +- **Relationships:** resolution is deliberately conservative. An import edge + resolves to a local module or declared dependency when supported; otherwise + the observation remains unresolved and no external placeholder is invented. + `calls`, `implements`, `routes_to`, and + `injects` are limited to the syntax and binding rules in + [the resolution contract](REPOSITORY_INTELLIGENCE_RESOLUTION.md). A FastAPI + `Depends(name)` argument resolves through same-file definitions or explicit + import bindings only, never a repository-wide same-name guess. +- **Role classification (`classified_as` assertions, #95):** a small, explicit rule set — filename/path substring matching for files, and class-name suffix matching (`Service`, `Repository`, `Controller`, `Model`, `Middleware`, `Dto`) for symbols, plus a name-pattern heuristic (`auth`, `current_user`, `token`, `verify`, …) applied only to functions that are the object of a resolved `injects` edge. Always `truth_class="inferred"`, never presented as a guaranteed fact. It does not understand base classes, decorators beyond `Depends()`, or any framework's actual dependency-injection semantics — a differently named auth guard, or one injected some other way, is simply not classified, not misclassified. +- **Authentication subgraph selection (#95):** the classifier and the resolved graph together only produce *candidate* facts; `AuthenticationExplanationService` additionally requires graph connectivity before any of them is claimed as authentication. A route is included only when its resolved `routes_to` handler has a resolved `injects` edge to a symbol explicitly classified `auth_dependency`; a service or model is included only when it lies on a resolved `calls` path from that guard (a breadth-first walk that keeps only edges on a path to a `service`/`model`-classified symbol, discarding everything else the guard happens to call). This is why an unrelated `/health` route, a generic `Depends(get_database)`, or a same-suffix `PaymentService`/`AuditModel` that the guard never calls are never claimed as authentication even though the classifier still labels them `service`/`model` for Architecture's module grouping — a name match alone is never sufficient. The response also returns `chains`: one ordered route -> handler -> guard -> (service/model) path per qualifying route, so a consumer does not have to reconstruct the flow from the flat `relationships` list. +- **Evidence navigation (#95, #154):** authentication and Engineering Review citations link to the existing repository Explorer with `snapshotId`, `factId`, path, and line span. The Explorer calls the owner-scoped `GET /analysis/{repositoryId}/evidence` endpoint, which returns source only when the exact fact/span exists and the current source bytes match the SHA-256 sealed on the snapshot's file fact; otherwise it displays an explicit unavailable state. The same Monaco-based `CodePreview` is reused rather than adding a second viewer. +- **Revision identity:** first-class and immutable per imported repository revision. Snapshot history can be retained, but diff/query APIs and product re-analysis orchestration are not implemented. +- **Dependencies:** three manifest formats plus two lockfile formats, no transitive resolution, and no vulnerability or outdated-version scanning. The dependency API reports both assessments as explicit `not_computed` statuses; it emits no clean result or count without a scanner. +- **Lockfiles (#209):** exactly two formats are read, and both are named rather than sniffed. `package-lock.json` is read only at `lockfileVersion` 2 or 3, through its `packages` table; only entries under a `node_modules/` path with a concrete `version` string count, and workspace `"link": true` entries are skipped. `poetry.lock` is read only at `[metadata] lock-version` major 1 or 2, taking each `[[package]]` table's `name` and `version`. `lockfileVersion` 1 is disclosed as `RI-EXT-UNSUPPORTED` rather than parsed from its legacy `dependencies` tree, and `yarn.lock`, `pnpm-lock.yaml`, `npm-shrinkwrap.json`, `Pipfile.lock`, `uv.lock`, and `pdm.lock` are not opened at all. A resolution is recorded on the same logical dependency identity as its declaration (PyPI names folded per PEP 503) but is **never** promoted to a `depends_on` edge: a lockfile proves a version was installed, not that the repository depends on the package directly. A nested npm tree resolving one package twice is modelled as two resolutions of one node, not two dependencies. Poetry lock-version 2 removed the per-package `category` field, so the production/development split is reported as unknown rather than guessed. Lockfiles are subject to the same 512 KiB source budget as any other file, so a large `package-lock.json` produces `RI-LIMIT-SKIP` and contributes nothing. +- **Service interactions (#209):** a destination fact requires three things visible in the source at once — an import proving the client, a literal HTTP method, and a literal absolute `http`/`https` URL. Python covers attribute-form calls on a module-level `requests`/`httpx` binding and on a local name assigned directly from `requests.Session()` / `httpx.Client()` / `httpx.AsyncClient()`; a bare `from requests import get` followed by `get(url)` is disclosed as unsupported specifically so it is not counted twice alongside the generic `call` observation it already produces. TypeScript covers the `fetch` global (GET by specification when no init is passed, or a literal `method` in an inline init object) and attribute calls on a default import from `axios`. Computed URLs, f-strings and template substitutions, relative paths, non-HTTP schemes, computed methods, spread or variable `fetch` init objects, `axios(config)`, `axios.request`, and shadowed client names all produce `RI-EXT-UNSUPPORTED` and no destination fact. Identity is the **origin only**: the request path travels on the call's own observation, and query strings and userinfo credentials are dropped rather than stored, because a literal URL can carry a token and stored facts must not embed secrets. Base URLs, client configuration, and any destination assembled at runtime are outside this contract. +- **IaC (#209):** only Docker Compose, and only its four canonical filenames (`compose.yaml`, `compose.yml`, `docker-compose.yaml`, `docker-compose.yml`) and its `services`, `volumes`, and `networks` sections. Detecting Kubernetes by scanning every `*.yaml` for an `apiVersion` key would turn an arbitrary document into a resource claim, so Terraform/OpenTofu HCL, Kubernetes, Helm, CloudFormation, Pulumi, Ansible, and Compose's `configs`/`secrets` sections are not read at all. Provenance is the resource's declaration line, not its whole block. A value that interpolates the environment (`image: ${TAG}`) is a template, not an observed image: the property is omitted and the blind spot is disclosed at that resource's span. Deployment topology, profiles, and runtime behaviour are not modelled. +- **Dependency inventory:** direct declarations from accepted `package.json`, `pyproject.toml`, and `requirements.txt` paths, plus resolved pins from accepted `package-lock.json` and `poetry.lock` paths, are reported. The parser inventory excludes `.git`, dependency/install directories, build output, virtual environments, caches, vendor paths, and generated paths. Each candidate is size-checked and read with the existing 512 KiB source budget before being processed individually; oversized manifests and lockfiles produce `RI-LIMIT-SKIP` rather than being retained in memory. Multiple workspace declarations remain attached to one logical dependency, including conflicts rather than an arbitrarily selected version. A malformed supported manifest or lockfile produces a safe `RI-SRC-MALFORMED` diagnostic while valid ones continue to contribute facts. No transitive resolution, vulnerability scanning, or outdated-version scanning is implemented. `app/extraction/dependencies.py` merges dependency nodes that share a stable key into one node carrying separate `declarations` and `resolutions` set-arrays before persistence, specifically so a package declared in more than one manifest — an ordinary monorepo shape — or pinned by a lockfile does not fail sealing for the whole repository. The merged node is emitted once per contributing producer so manifest evidence stays attributed to `dependency-manifest` and lockfile evidence to `dependency-lockfile`; a stable key claimed by any producer outside that pair is left as a genuine conflict for `add_node` to reject. The durable worker and the golden benchmark both apply that same reducer, so the benchmark measures the merged shape the product actually persists. +- **Languages:** the capability registry declares meaningful extraction for Python and TypeScript/JavaScript constructs. Other languages get file-tree and metadata treatment only. +- **File size cap:** files over 512 KB are read as empty during extraction, so their contents contribute nothing. +- **Build cost:** the whole repository is re-analysed from scratch in a background job; incremental analysis is not implemented. + +--- + +## Contributing to the engine + +1. Add observed syntax facts to the shared extraction contract and the relevant + extractor; represent heuristic conclusions as explicit inferred assertions. +2. Update the authoritative capability registry and focused extractor tests. +3. Add or update an independently authored benchmark fixture and golden manifest. +4. Consume the normalized fact only through the Repository Intelligence query + boundary once that boundary supports it. +5. If the fact is heuristic, say so — in the model, in the API, and in the UI. + +Adding a parser inside a consumer to avoid step 1 is the single change most likely to be rejected in review. diff --git a/docs/architecture/REPOSITORY_INTELLIGENCE_ENGINE.md b/docs/architecture/REPOSITORY_INTELLIGENCE_ENGINE.md deleted file mode 100644 index 8406b843..00000000 --- a/docs/architecture/REPOSITORY_INTELLIGENCE_ENGINE.md +++ /dev/null @@ -1,148 +0,0 @@ -# Repository Intelligence Engine - -The Repository Intelligence Engine is PARTHA's central backend subsystem for repository understanding. - -It exists so every downstream feature consumes the same repository facts instead of independently parsing files. - -## Architecture - -```mermaid -flowchart TD - Repository[Repository
    uploaded archive or GitHub clone] - Parser[Repository Parser
    file tree + metadata] - Engine[Repository Intelligence Engine] - Intelligence[RepositoryIntelligence
    discovery + files + modules + symbols + dependencies + graph] - Persistence[(RepositoryRecord.repo_metadata intelligence)] - Consumers[Feature Consumers] - - Repository --> Parser - Parser --> Engine - Engine --> Intelligence - Intelligence --> Persistence - Intelligence --> Consumers -``` - -## Boundaries - -| Layer | Responsibility | Should not do | -| --- | --- | --- | -| Repository Parser | Walk filesystem, build file tree, produce basic metadata. | Generate feature-specific architecture/review/docs output. | -| Repository Intelligence Engine | Extract reusable repository facts, source intelligence, dependencies, modules, and graph relationships. | Render UI-specific responses. | -| Knowledge Graph | Store serializable nodes and relationships. | Re-read repository files. | -| Feature Consumers | Transform intelligence into API response shapes. | Traverse repositories or duplicate parsing heuristics. | - -## Data Lifecycle - -```mermaid -sequenceDiagram - participant Import as Repository Import - participant Parser as Repository Parser - participant Engine as Intelligence Engine - participant DB as Repository Record - participant Feature as Feature Consumer - - Import->>Parser: Parse repository tree and metadata - Parser-->>Import: FileTreeNode[] + RepositoryMeta - Import->>Engine: Build reusable repository intelligence - Engine-->>Import: RepositoryIntelligence - Import->>DB: Persist metadata, tree, and serialized intelligence - Feature->>Engine: from_record(record) - Engine-->>Feature: Existing persisted intelligence or rebuilt fallback -``` - -## Engine Output - -`RepositoryIntelligence` contains: - -| Field | Purpose | -| --- | --- | -| `metadata` | Parser-provided repository metadata. | -| `discovery` | Languages, frameworks, package managers, config, env, Docker, CI, build systems, databases, cloud providers, statistics. | -| `files` | Source file intelligence including role, imports, exports, symbols, routes, technologies. | -| `modules` | Grouped repository modules with role, layer, files, symbols, dependencies. | -| `symbols` | Functions, classes, interfaces, types, enums, constants, and routes. | -| `dependencies` | Dependency manifest entries across supported ecosystems. | -| `graph` | Serializable knowledge graph nodes and relationships. | - -## Knowledge Graph - -The graph currently supports these node types: - -- `repository` -- `module` -- `file` -- `symbol` -- `dependency` - -The graph currently supports these relationship types: - -- `contains` -- `imports` -- `depends_on` -- `exports` -- `references` -- `calls` -- `extends` -- `implements` - -Not every relationship type is deeply extracted yet. The model includes them so language-specific parsers can add them without changing downstream consumers. - -## Consumer Flow - -```mermaid -flowchart LR - Intelligence[Repository Intelligence] - Architecture[Architecture Intelligence] - Dependencies[Dependency Intelligence] - Review[Engineering Review] - Docs[Documentation Intelligence] - AI[AI Workspace Context] - Reports[Reports and Exports] - - Intelligence --> Architecture - Intelligence --> Dependencies - Intelligence --> Review - Intelligence --> Docs - Intelligence --> AI - Architecture --> Reports - Dependencies --> Reports - Review --> Reports - Docs --> Reports -``` - -## Persistence - -Repository intelligence is serialized into: - -```text -RepositoryRecord.repo_metadata["intelligence"] -``` - -This avoids a database migration while establishing a durable persisted artifact. A future migration may promote this to a dedicated JSON column or graph-backed store. - -## Consumers - -| Consumer | Uses | -| --- | --- | -| Architecture | Modules, layers, discovery, graph relationships. | -| Dependency Graph | Dependencies and `depends_on` relationships. | -| Engineering Review | Discovery, file roles, statistics, environment/CI facts. | -| Documentation | Discovery, file list, API routes, deployment/environment facts. | -| AI Workspace | Modules, dependencies, discovery, files, selected-file context. | -| Repository Explorer | Existing parsed tree today; richer file intelligence later. | -| Insights | Future consumer. | - -## Contributor Rules - -- Do not add feature-specific repository traversal. -- Do not re-read dependency manifests in consumers. -- Do not duplicate language/framework/config detection outside the engine. -- Add reusable extraction to `app/intelligence` first. -- Consumers should transform `RepositoryIntelligence` into response schemas only. - -## Current Limits - -- Relationship extraction is intentionally lightweight. -- Graph data is serialized inside repository metadata rather than promoted to dedicated graph tables. -- Vulnerability, outdated dependency, ownership, and deep change-impact analysis are not implemented yet. -- Some language-specific extraction still relies on heuristics and parser fallbacks. diff --git a/docs/architecture/REPOSITORY_INTELLIGENCE_RESOLUTION.md b/docs/architecture/REPOSITORY_INTELLIGENCE_RESOLUTION.md new file mode 100644 index 00000000..3a08e824 --- /dev/null +++ b/docs/architecture/REPOSITORY_INTELLIGENCE_RESOLUTION.md @@ -0,0 +1,230 @@ +# Repository Intelligence relationship resolution + +This document defines the deterministic resolver introduced for Issue #91. It +applies only to facts already stored in a **building** `ri.v1` snapshot. A +resolver never reads the repository working tree, reparses a source file, or +creates an `observed` node or edge. + +## Inputs and outcomes + +Extractors and manifest readers store observations with exact source evidence. +The resolver sorts those observations by `observation_id`, builds candidate +indexes from the snapshot's nodes, and applies the rules below. Every attempted +relationship has one of three outcomes: + +| Candidate count | Output | +| --- | --- | +| one | One `resolved` edge with resolver evidence at the observation span and a `derived_from` observation reference. | +| zero | `RI-RES-UNRESOLVED` warning with the observation id. No edge. | +| more than one | `RI-RES-AMBIGUOUS` warning with sorted candidate stable keys. No edge. | + +Warnings remain in a completed snapshot. They are part of the canonical graph +hash and make missing knowledge visible; they are never replaced by a guessed +edge. + +### How read consumers present unresolved warnings + +The sealed warnings are never edited, but the read models classify them before +display so a healthy analysis does not read as broken. An `RI-RES-UNRESOLVED` +whose target is the standard library / language platform (a builtin call, a +`pathlib` import) or a package the repository declares as a dependency +(`jsonify` from Flask, `useState` from React) is an *expected* reference into +code outside the analysed repository, not a coverage gap: + +- **Repository Insights** counts those as + `diagnostics.relationships.external-references`; the headline + `diagnostics.relationships.unresolved` is then only the genuine in-repo gaps + (a relative import that matched no file, a bare name with no binding and no + same-file definition). The raw `RI-RES-UNRESOLVED` total stays visible under + "Diagnostics by code", and the two buckets always sum to it. +- **Architecture** does not mark a module `unresolved` for those references and + omits them from its diagnostics list. `RI-RES-AMBIGUOUS` and in-repo + `RI-RES-UNRESOLVED` gaps still flag the module. + +The classifier is `app/insights/relationship_diagnostics.py`, shared by both +consumers; it reuses the review layer's `import_dispositions` judgment (#412) +and reads sealed rows only. + +## Stored observation contract + +| Observation | Extracted input | Resolved predicate | +| --- | --- | --- | +| `definition` | Symbol definition | `contains`, `defines` | +| `import` | Module specifier | `imports` | +| `import_binding` | `specifier|imported|local` exact binding representation | supports `imports`, `calls`, `implements`, `routes_to` lookup | +| `call` | Direct named call | `calls` | +| `call_shadowed` | The paired call-site name is lexically local to its function/class scope | forces the paired `call` to an unresolved diagnostic | +| `implements` | TypeScript `implements` clause | `implements` | +| `route` + `route_handler` | Route declaration and one handler reference | `routes_to` | +| `dependency` | Direct manifest declaration on a dependency node | `depends_on` | +| `injects` | A `Depends(name)` argument (#95) | `injects` | +| `http_call` | A proven outbound HTTP call site, `METHOD\|origin\|path` (#209) | `calls_service` | +| `iac_resource` | A declared infrastructure resource on an `iac_resource` node (#209) | `declares` | +| `resolution` | A lockfile pin on a dependency node (#209) | *(none — deliberately not a relationship input)* | + +`import_binding` uses an unambiguous delimiter format because `ri.v1` +observations intentionally have only `referent_text`; it is still direct +extractor output, not resolver-generated source interpretation. + +Python `import_binding` is emitted only for a direct module-level import. A +function- or block-local import still produces an `import` observation, but it +cannot be exposed as a file-wide name binding. Calls through such local names +carry `call_shadowed` and fail closed until scope-qualified import bindings are +part of the stored contract. + +## Algorithms + +### Structural edges + +For each `definition` observation, split the symbol stable key at `::`. A +nested qualified name resolves to its immediate enclosing symbol only if that +symbol exists in the snapshot. A top-level name resolves to `file:` only +if that file node exists. Emit both `contains` and `defines` edges from that +single parent to the definition symbol. + +### Imports and dependencies + +For a relative TypeScript/Python specifier, generate the complete candidate set +without precedence: explicit extensions plus `.ts`, `.tsx`, `.py`, TypeScript +`index.*`, and Python `__init__.py` forms. Relative Python dotted imports also +consider each stored module prefix, which preserves the existing extractor +representation for `from .pkg import symbol`. + +An absolute Python `from a.b import c` is stored as the import referent `a.b.c`, +whose module is `a.b`. Because `c` may be either a submodule (`a/b/c.py`) or a +member of the module `a/b.py`, the resolver derives module-file candidates from +both the full referent and the stored `import_binding` module specifier `a.b`, +using the binding rather than reparsing source. Match candidates only against +stored file nodes; when any local file node matches, an external dependency +with the same package root is never substituted. + +For a bare specifier with no matching local file node, derive its npm package +root (including scoped packages) or PEP 503-normalized PyPI root and match only +an already-stored dependency node. No `external:*` placeholder is created. A +`dependency` observation resolves `repo:root -> dependency` as `depends_on`. +Genuinely ambiguous module layouts stay ambiguous; unresolved ones stay +diagnostics. + +### References and implementations + +A relationship is emitted only when a stored syntax fact uniquely proves it. +Evidence is considered in a fixed order: a direct stable-key referent, then a +same-file top-level symbol, then the explicit `import_binding` records for the +source file. Before lookup, a paired `call_shadowed` observation forces the +call to remain unresolved; a parameter, function-local import, or local +declaration can therefore never borrow a same-named global/imported symbol. +There is **no** repository-wide same-name fallback. Without a +same-file definition or an import binding, a lone symbol elsewhere that happens +to share the name is not proof and stays unresolved. When a binding exists but +its module or exported symbol cannot be resolved, the reference stays +unresolved rather than borrowing an unrelated same-named symbol; when a binding +resolves to more than one target, it stays ambiguous. A default import resolves +only to a symbol carrying an explicit stored `default_export` property; the +resolver never substitutes the only symbol in a file. A call inside a symbol +uses that symbol as its source; a top-level call retains its observed +file/module source, and recursive calls may produce an intentional self-edge. +This applies identically to `calls`, `implements`, `routes_to`, and `injects`. +TypeScript extraction emits `implements` for direct concrete or abstract class +clauses, and generic targets use their AST base reference while retaining +evidence over the complete clause. `extends` is intentionally not repurposed +as an `implements` fact. + +`injects` (#95) resolves a Python `Depends(name)` argument the same way a +`call` does: the extractor records only the bare referenced name and its +source span; the resolver attributes it to whichever symbol's stored span +contains it (the function whose default argument reads `Depends(name)`), then +looks up `name` through the identical same-file-definition / +`import_binding`-only rule above. A dependency imported from another module, +or one this fixture never defines (e.g. a bare `oauth2_scheme` reference with +no local definition), stays an honest `RI-RES-UNRESOLVED` diagnostic rather +than a guessed edge. + +### Service interactions (#209) + +An `http_call` referent is the extractor's three-part +`METHOD|origin|path` record — the same delimited representation `import_binding` +uses, and for the same reason: it is direct extractor output, not resolver +interpretation. The resolver splits it and looks up exactly one deterministic +key, `svc:`. There is no search and no nearest-match: the extractor that +proved the literal URL also emitted the `service` node for that origin, so +either the key is present or the observation stays `RI-RES-UNRESOLVED`. A +referent that does not carry all three parts is never repaired. + +The call is attributed to whichever symbol's stored span contains it, falling +back to the observed file/module — identical to how a `calls` reference picks +its source, so both predicates agree about who made a call. + +Only the origin is the service's identity. The method and path vary per call +site and live on the observation, so `GET https://api.example.com/v1/users` and +`POST https://api.example.com/v1/orders` are two calls to **one** service rather +than two services. That identity is language-neutral, so a Python and a +TypeScript call site to the same origin converge on one node. + +### Lockfile resolutions (#209) + +A `resolution` observation records that a lockfile pinned a dependency to an +exact version. It is deliberately **not** a relationship input. A lockfile entry +proves that a version was installed; it does not prove the repository depends on +that package directly, and most entries in a real lockfile are transitive. Only +a manifest-backed `dependency` observation produces `depends_on`, so no +transitive resolution is ever implied by a `depends_on` edge. + +The resolution is retained as an observation on the dependency node rather than +downgraded to a diagnostic: nothing about it is unresolved, it simply is not a +relationship claim. The merged dependency node keeps `declarations` and +`resolutions` as separate collections, and an empty `resolutions` list is the +honest statement that no supported lockfile pinned that dependency. + +### Infrastructure resources (#209) + +An `iac_resource` observation whose subject is an `iac_resource` node in the +snapshot resolves `repo:root -[declares]-> `, exactly like a manifest +`dependency` observation resolves `depends_on`. An observation whose subject is +absent or is some other node kind stays unresolved rather than attaching an +arbitrary node to the repository. + +### Routes + +Extractors create an observed anonymous route symbol for each literal route +declaration. A `route_handler` observation must name exactly one direct handler +reference for the route. Python decorators bind the route to their decorated +function. TypeScript records static `Component`, `component`, and JSX `element` +references. Lazy, computed, or otherwise unsupported route handlers remain +unresolved rather than being inferred from a path. A dynamic JSX/object route +path produces `RI-EXT-UNSUPPORTED`; it is never materialized as a literal route +property or used as the subject of a resolved route relationship. + +## Truth classes and provenance + +The resolver emits only `resolved` edges through `SnapshotStore.add_edge`. Each +edge has `relationship-resolver@1.1.0` as its immediate producer, carries the +same repository-relative span as its source observation under that resolver +producer identity, and has a tagged observation derivation. The producer must +be present in the snapshot's planned `producer_version_set`, which sealing +validates. Extractors remain the only producers of `observed` facts. + +## Verification + +The focused resolver tests cover successful structural/import/call/route/ +dependency/implements edges, unresolved imports, actual TypeScript alias +resolution, and FastAPI decorator routes. They also prove the no-fallback rule: +a call, an imported route handler, and an implemented interface each stay +unresolved when only an unrelated repository-wide same-name symbol exists or +when an import binding is broken, while a binding that resolves to several +targets stays ambiguous. A dedicated case shows an absolute `from a.b import c` +resolving its import edge to the module file `a/b.py` even when a same-named +dependency is present. Adversarial cases cover local parameter shadowing in +both languages, module-local Python import bindings, default-imported React +route handlers, dynamic JSX route paths, top-level and recursive calls, and +abstract generic `implements` clauses. All warning paths are sealed +successfully to prove that honest partial knowledge remains usable. + +`tests/intelligence/test_service_and_iac_resolution.py` covers the #209 kinds: +a proven call resolving to its origin's service node and being attributed to the +containing symbol, a top-level call falling back to its module, an origin with no +service node and a malformed referent both staying unresolved, an `iac_resource` +observation attaching to `repo:root`, and — the negative that matters most — a +lockfile `resolution` producing neither an edge nor a diagnostic while a manifest +declaration for the same dependency still produces `depends_on`. Edge truth class, +producer identity, evidence span, and observation derivation are asserted +directly rather than inferred from a count. diff --git a/docs/architecture/REPOSITORY_INTELLIGENCE_V1_RFC.md b/docs/architecture/REPOSITORY_INTELLIGENCE_V1_RFC.md new file mode 100644 index 00000000..db9987a9 --- /dev/null +++ b/docs/architecture/REPOSITORY_INTELLIGENCE_V1_RFC.md @@ -0,0 +1,1806 @@ +# RFC-0001 — Repository Intelligence v1 Schema and Evidence Contract + +| Field | Value | +| --- | --- | +| **RFC number** | RFC-0001 | +| **Title** | Repository Intelligence v1 Schema and Evidence Contract | +| **Tracking issue** | [Second-Origin/PARTHA#86](https://github.com/Second-Origin/PARTHA/issues/86) | +| **Accepted schema version** | `ri.v1` | +| **Author** | @parthrohit22 | +| **Ratifier** | [@SHAURYAKSHARMA24](https://github.com/SHAURYAKSHARMA24), an independent project maintainer other than the author | +| **Approval evidence** | [Issue #86 approval comment](https://github.com/Second-Origin/PARTHA/issues/86#issuecomment-4990687780) and [PR #101 approval review](https://github.com/Second-Origin/PARTHA/pull/101#pullrequestreview-4712687647) | +| **Approval / ratification date** | 2026-07-16 | +| **Created** | 2026-07-15 | +| **Last updated** | 2026-07-26 | +| **Status** | **Accepted** | +| **Supersedes** | — | +| **Superseded by** | — | + +> **This RFC records the accepted architectural contract; it is not application code.** Acceptance +> does not by itself implement snapshots, persistence, extractors, resolvers, queries, jobs, +> migrations, or consumer migration. Downstream implementation was tracked in issues +> [#87–#95](https://github.com/Second-Origin/PARTHA/issues/87) and subsequent migration work; see +> [§16, Dependency gate](#16-dependency-gate) and [§17, implementation status](#17-current-behavior-vs-accepted-contract-vs-implementation-status). + +--- + +## 1. Status and approval + +### 1.1 Status + +This RFC is **Accepted**. Independent project maintainer +[@SHAURYAKSHARMA24](https://github.com/SHAURYAKSHARMA24) recorded approval in the +[Issue #86 approval comment](https://github.com/Second-Origin/PARTHA/issues/86#issuecomment-4990687780) +and the [PR #101 approval review](https://github.com/Second-Origin/PARTHA/pull/101#pullrequestreview-4712687647) +on 2026-07-16. This final pre-merge update records that decision in the RFC; merging PR #101 makes +the accepted record authoritative on `dev` and closes #86. + +### 1.2 Approval / ratification rule + +- **Ratification requires an independent project maintainer other than the author.** The author + (@parthrohit22) cannot ratify their own RFC, and per [CONTRIBUTING §6](../../CONTRIBUTING.md) must + not self-merge. A reviewer's `write` access alone does not make them a maintainer; ratification + authority must be an actual project maintainer. +- **Approval does not automatically edit this document.** After approval is recorded, a **final + pre-merge update MUST**: + 1. set `Status: Accepted`; + 2. record the ratifier (the approving maintainer) and the approval date; + 3. change the pull request description from `Related to #86` to `Closes #86`. + This update fulfills all three requirements using the recorded + [Issue #86 approval](https://github.com/Second-Origin/PARTHA/issues/86#issuecomment-4990687780) + and [PR #101 approval](https://github.com/Second-Origin/PARTHA/pull/101#pullrequestreview-4712687647). + Merge of this updated document makes the accepted contract authoritative; the status change is + made by hand in this pre-merge update, not inferred from the merge event. +- The author **MUST NOT** self-declare this RFC approved and **MUST NOT** set `Status: Accepted` + without a recorded independent-maintainer approval. +- Amendments after acceptance follow the schema-versioning rules in [§9](#9-schema-versioning): a + backward-compatible clarification amends this document in place; a breaking change is a new RFC + that proposes `ri.v2` for ratification. + +### 1.3 What approval unblocks + +The recorded approval satisfies the governance dependency gate in [§16](#16-dependency-gate). +After PR #101 merges, downstream issues may proceed against the authoritative accepted contract, +subject to their own dependencies and review requirements. Acceptance does not mark any downstream +capability as implemented or as current product behavior. + +### 1.4 Ratification follow-up + +The original proposal was merged in [PR #99](https://github.com/Second-Origin/PARTHA/pull/99) +without the independent-maintainer approval and final status update required by §1.2. Tracking +issue [#86](https://github.com/Second-Origin/PARTHA/issues/86) was reopened on 2026-07-16 so that +the missing ratification step can be completed explicitly rather than inferred from the earlier +merge. + +Independent project maintainer @SHAURYAKSHARMA24 subsequently recorded approval in both +[Issue #86](https://github.com/Second-Origin/PARTHA/issues/86#issuecomment-4990687780) and +[PR #101](https://github.com/Second-Origin/PARTHA/pull/101#pullrequestreview-4712687647). This final +pre-merge update sets the status to **Accepted**, records the ratifier and date, and uses the +`Closes #86` form in PR #101. It resolves the governance gap left by PR #99. A maintainer other +than the author must merge PR #101 for this accepted record to become authoritative and for #86 +to close. + +--- + +## 2. Normative terminology + +### 2.1 Requirement levels + +The key words **MUST**, **MUST NOT**, **REQUIRED**, **SHALL**, **SHALL NOT**, **SHOULD**, +**SHOULD NOT**, **MAY**, and **OPTIONAL** in this document are to be interpreted as follows, +consistent with RFC 2119 / RFC 8174 and with the existing convention in +[CONTRIBUTING §Preamble](../../CONTRIBUTING.md): + +- **MUST** / **REQUIRED** / **SHALL** — an absolute requirement. A conforming implementation that + violates a MUST is non-conforming and the corresponding pull request must be rejected. +- **MUST NOT** / **SHALL NOT** — an absolute prohibition. +- **SHOULD** / **RECOMMENDED** — a strong expectation. Deviation requires a documented reason on + the implementing issue and reviewer agreement. +- **MAY** / **OPTIONAL** — genuinely discretionary; either choice conforms. + +### 2.2 Defined terms + +These terms have exactly the meaning below throughout the intelligence track. They refine the +informal usage in [REPOSITORY_INTELLIGENCE.md](REPOSITORY_INTELLIGENCE.md); where the two differ, +this RFC governs for `ri.v1` artifacts. + +| Term | Definition | +| --- | --- | +| **Snapshot** | An immutable, sealed set of facts about **one repository at one exact revision**, produced under one schema version and one predeclared producer-version set. The unit of reproducibility. | +| **Fact** | A single stored claim in a snapshot. Every fact is a **node**, **edge**, or **assertion**, carries a **truth class** ([§7](#7-truth-classes)), and — when its truth class requires it — carries provenance or derivation references ([§6](#6-provenance-contract)). | +| **Node** | A fact asserting the existence of an entity: a repository, module, file, symbol, or dependency. Identified by a **stable key** ([§4](#4-node-identity-and-stable-keys)). | +| **Edge** | A fact asserting a directed relationship between two nodes: `subject —predicate→ object` ([§5](#5-edgefact-identity)). | +| **Assertion** | A separate fact that assigns a derived property/value to an existing node without duplicating or mutating that entity node. `ri.v1` assertions are inferred and have deterministic identities ([§5.6](#56-inferred-property-assertions)). | +| **Observation** | A stored, evidence-bearing extractor record for one exact syntactic occurrence. It is provenance input to resolution/inference, not a graph node or edge ([§6.4](#64-observations-and-the-derived_from-reference-model)). | +| **Evidence** | A single provenance record ([§6](#6-provenance-contract)) that ties a fact to an exact source location (path + line span) in the snapshot's stored revision, plus the extractor/resolver that produced it. | +| **Provenance** | The complete set of evidence attached to a fact — *where the fact came from and how it was derived*. A fact may carry one or more evidence records. | +| **Diagnostic** | A structured record of something the pipeline could not turn into a fact, or could turn into a fact only with a caveat: an unsupported construct, an ambiguous resolution, a parse failure, an invalid span, a collision, a skipped input. Diagnostics are first-class snapshot output ([§8](#8-diagnostics-model)). | +| **Extractor** | A component that reads source at a byte/AST level and emits **observed** facts with exact spans. Named and versioned (e.g. `typescript-ast@1.0.0`). | +| **Resolver** | A component that consumes stored extractor output and emits **resolved** facts via a documented, deterministic algorithm ([§7](#7-truth-classes)). Named and versioned. It does not read the working tree. | +| **Producer** | Any versioned component enabled in the planned snapshot pipeline that can emit snapshot nodes, edges, assertions, observations, or diagnostics: extractors, resolvers, classifiers, and inference producers. Narrative generators are consumers and are not snapshot producers. | +| **Schema version** | The version of *this contract* — node/edge shape, stable-key format, provenance record, truth classes, canonicalization. Value for this RFC: `ri.v1` ([§9](#9-schema-versioning)). | +| **Producer version** | The independently incremented version of a producer implementation. Distinct from schema version ([§9.4](#94-schema-version-vs-producer-version)). | + +--- + +## 3. Revision and snapshot identity + +### 3.1 Problem this settles + +Before #87, revision identity was coarse and mutable: `RepositoryService._metadata_with_intelligence` +stashed a `commitSha` inside the mutable `repo_metadata` JSON blob. A value inside a mutable blob is +not an identity — it cannot be indexed, uniquely constrained, made immutable, or joined against a +snapshot table. #87 now makes the identity in this section first-class; #88 keys normalized snapshots +to it. The legacy description remains relevant to rows created before the migration. + +### 3.2 Revision identity + +A **revision** is the exact version of source a snapshot describes. There are exactly two kinds in +`ri.v1`: + +```jsonc +{ "kind": "git", "value": "<40-lowercase-hex-commit-sha>", "ref": "refs/heads/main" } +{ "kind": "upload", "value": "sha256:<64-lowercase-hex>", "ref": null } +``` + +- **Git identity (`kind: "git"`).** `value` MUST be the immutable **40-character lowercase + hexadecimal commit SHA** (SHA-1 object name), as already read by + [`GitHubClient.read_head_commit`](../../apps/backend/app/github/client.py#L45) via + `git rev-parse HEAD`. `ref` MUST additionally record the **resolved ref** the import targeted + (e.g. `refs/heads/main`), derived from the requested branch. When SHA-256 object-format + repositories are supported, a 64-character SHA is permitted and the hash algorithm is recorded; + `ri.v1` targets the SHA-1 default that `git` produces today. +- **Upload identity (`kind: "upload"`).** `value` MUST be the stable **`sha256:` content hash** of + the uploaded archive, exactly as already computed by + [`_content_hash_for_upload`](../../apps/backend/app/services/repository_service.py#L284). Uploads + have no git history; the content hash is the revision. +- **Moving names are metadata, never identity.** A branch name (`main`), a tag, or `HEAD` is a + *moving pointer*. It MAY be stored as descriptive metadata (`ref`, `branch`) but MUST NOT be used + as, or as part of, revision identity. Two snapshots of `main` taken a week apart are different + revisions. + +`value` is REQUIRED and immutable once written. It MUST be an indexed column (#87), not a JSON-blob +field. + +### 3.3 Snapshot identity + +A snapshot's identity is the composite: + +```text +(repository_id, revision.value, schema_version, producer_version_set, config_hash) +``` + +- `repository_id` — the owning repository (`repo_…`), scoping the snapshot to one owner's repository. +- `revision.value` — the exact revision ([§3.2](#32-revision-identity)). +- `schema_version` — e.g. `ri.v1` ([§9](#9-schema-versioning)). +- `producer_version_set` — the lexicographically sorted, deduplicated set of every **enabled** + extractor, resolver, classifier, and inference producer in the planned pipeline, each encoded as + `producer@version` (for example + `["architecture-classifier@1.0.0", "import-resolver@1.0.0", "python-ast@1.0.0", "reference-resolver@1.0.0", "repository-inventory@1.0.0", "route-resolver@1.0.0", "typescript-ast@1.0.0"]`). + It describes the pipeline that is about to run, not only components that later happen to emit + output. +- `config_hash` — the deterministic hash of the output-affecting analysis configuration, computed + exactly as defined in [§12.7](#127-config_hash). Configuration that cannot change graph output + MUST NOT be included. + +These five components are the **semantic identity components** of a snapshot. A new snapshot is +required if and only if at least one of them changes. + +**The complete semantic identity MUST be computable before an analysis job is enqueued.** Pipeline +planning resolves every enabled producer and version, canonicalizes `producer_version_set`, computes +`config_hash`, and performs sealed-snapshot lookup before extraction begins. Any producer +implementation-version change therefore changes identity and cannot accidentally reuse a snapshot +built by different code. An implementation MAY record a lexicographically sorted, deduplicated +`actual_producers` field after execution for operational auditing, but that field MUST NOT replace +the planned `producer_version_set`, alter semantic identity, or be used for pre-enqueue lookup. + +Each snapshot ALSO carries an opaque surrogate `snapshot_id` (`snap_…`) as its primary key. The +surrogate is for referencing; the composite above is the **semantic** identity and MUST be enforced +by a uniqueness constraint that permits **at most one sealed snapshot** per composite identity (#88). + +### 3.4 Reanalysis, reuse, and idempotency + +These rules are normative and are stated identically in [§11.3](#113-immutability), +[§11.4](#114-failed-extraction-cancellation-retry-93-interaction), and +[§11.5](#115-concurrency-and-idempotency); they must not be contradicted anywhere in this document: + +- **Identical composite identity MUST reuse the existing sealed snapshot.** If a **sealed** snapshot + already exists for the exact composite identity in [§3.3](#33-snapshot-identity), an analysis + request with that identity **MUST** return the existing sealed snapshot. It **MUST NOT** build a + second one. (Because the canonical graph hash — [§12](#12-canonical-graph-hash) — is deterministic + for identical inputs, a rebuild could only reproduce the same bytes; reuse is therefore required, + not merely permitted.) +- **A new snapshot is required only when a semantic identity component changes.** If *any* component + of the composite differs — a new revision, a changed planned producer version, a schema bump, or + a `config_hash` change — the result is a **distinct** snapshot. Otherwise no new snapshot is + produced. +- **Concurrent identical requests MUST coordinate so that at most one build seals.** When two + requests race for the same composite identity, implementations MUST ensure at most one `building` + attempt reaches `completed`; after one seals, the others reuse it. The uniqueness constraint on + sealed snapshots ([§3.3](#33-snapshot-identity)) is the backstop. +- **Retries may create separate `building` or `failed` attempts, but only one snapshot may become + sealed** for a given composite identity ([§11.4](#114-failed-extraction-cancellation-retry-93-interaction)). +- **Completed snapshots remain immutable and are never rewritten** ([§11.3](#113-immutability)). + Corrections come from a *new* snapshot produced by changed inputs, never from editing a sealed one. + +### 3.5 Migration of the existing `commitSha` (handled by #87) + +The existing `repo_metadata["commitSha"]` value is migrated forward, not dropped: + +- #87 adds first-class, indexed, immutable revision columns to the repository record + ([`repository.py`](../../apps/backend/app/models/repository.py)): the git commit SHA **and** the + resolved ref for git imports, and the `sha256:` content hash for uploads. +- The Alembic migration backfills those columns from the existing `repo_metadata["commitSha"]` + values (a git SHA, or a `sha256:` upload hash), per [CONTRIBUTING §10](../../CONTRIBUTING.md). +- Once the column is authoritative, the `commitSha` stash in `_metadata_with_intelligence` is + removed (#87 acceptance criterion). The migration MUST downgrade cleanly. +- This is a *transformation* of an existing value into a typed column, not a re-derivation — see + [§10](#10-migration-policy) for the distinction between transformation and re-extraction, which + governs the graph facts themselves. + +--- + +## 4. Node identity and stable keys + +### 4.1 Principle and cross-revision guarantees + +Every node has a **deterministic stable key**: a pure function of the repository-relative source +location and the node's semantic name, containing no random component, no timestamp, and no +autoincrement id. Stable keys are UTF-8 strings; the general grammar is `:`. + +Node stable keys are unique **within a snapshot**. Persistence MUST enforce +`UNIQUE(snapshot_id, stable_key)`, so each entity has exactly one node record in a snapshot. Entity +existence nodes are `observed`; a resolver or classifier MUST NOT create a second node with the same +key or change the existing node's truth class. Derived properties belong in assertions +([§5.6](#56-inferred-property-assertions)). + +`ri.v1` makes exactly two levels of guarantee, and they must not be conflated: + +- **Within a revision (strong, MUST).** For a **fixed stored revision and schema version**, the same + entity MUST always receive the same stable key. Stable keys are fully deterministic per revision — + this is what #88's storage, #92's queries, and #94's determinism check rely on. +- **Across revisions (qualified).** A stable key identifies "the same entity" across two revisions + **only to the extent its inputs are unchanged.** Concretely: + - **Ordinary, non-colliding named symbols are comparable across revisions.** A symbol whose + `::` is unique in its file carries **no** discriminator, so its key + depends only on its file path and qualified name. As long as neither changes, the key is identical + across revisions and a consumer MAY treat two snapshots' facts with that key as the same entity. + A file rename or a rename of any enclosing scope changes the key — that is a *different identity*, + correctly, because the source location changed. + - **Duplicate-symbol and anonymous-symbol ordinals are revision-local.** The `#` overload + discriminator ([§4.3](#43-canonical-stable-key-formats)) and the `(anonymous:#)` + segment are assigned by **source order within the revision**. Inserting or deleting an earlier + occurrence renumbers the later ones, so these keys **MUST NOT** be assumed to identify the same + entity across revisions. They are stable *within* a revision and are for intra-snapshot reference + and hashing only; cross-revision matching of duplicate/anonymous symbols is explicitly **not + guaranteed** in `ri.v1`. A consumer that needs to track such a symbol across revisions MUST fall + back to evidence (path + span) comparison, not the ordinal key. + - **`repository`, `module`, `file`, and `dependency` keys are cross-revision stable** as long as + their path/name inputs are unchanged (they carry no ordinal). The repository-root key is the + constant `repo:root`, scoped by its snapshot rather than by a database identifier. + +This is the honest limit of `ri.v1`: source-order ordinals buy determinism cheaply but cannot promise +cross-revision identity for the entities that need them. See +[§15.1](#151-operational-costs-and-limitations-stated-honestly) for the consequence and the deferred +alternative (a content-based semantic discriminator) considered for a future schema version. + +### 4.2 Path normalization (applies to every path in a stable key or evidence record) + +A **repository-relative POSIX path** is produced by this exact procedure; it is normative for +stable keys ([§4](#4-node-identity-and-stable-keys)) and for evidence paths ([§6](#6-provenance-contract)): + +1. Interpret the path relative to the repository root (the analyzed root, after + [`_resolve_repository_root`](../../apps/backend/app/services/repository_service.py#L259)). +2. Convert all backslashes to forward slashes (`/`). POSIX separator only. +3. Split on `/`, resolve `.` segments (drop them) and `..` segments (pop the previous segment) + **lexically, without touching the filesystem**. +4. **Reject** any path that, after lexical resolution, escapes the repository root (a leading `..` + that cannot be popped). Such a path MUST NOT produce a node; it produces an + `RI-SEC-PATH-ESCAPE` diagnostic ([§8](#8-diagnostics-model)) and is dropped. +5. **Reject** absolute paths and paths that traverse a **symlink that escapes the repository**; + emit `RI-SEC-PATH-ESCAPE`. Symlinks that stay within the repository are followed to their + normalized in-repo target. +6. Strip any leading `/`. The result has no leading slash, no `.`/`..` segments, and uses `/`. +7. **No leading `./`.** `.` alone (the repository root itself) is represented as the empty string + for the repository node's path and as the literal path for the module rooted there. +8. **Case sensitivity.** Paths are compared **case-sensitively** and byte-exactly as normalized in + step 9. This matches git's default index behavior; a case-only rename is a distinct path. On a + case-insensitive host filesystem, two paths differing only in case that collide MUST emit + `RI-KEY-COLLISION` rather than silently merging. +9. **Unicode.** Path strings are normalized to **Unicode NFC** before use in a key. Two paths that + are canonically equivalent under NFC are the same path; if the stored revision contains both an + NFC and a non-NFC spelling of the same name, that is a collision and emits `RI-KEY-COLLISION`. + +### 4.3 Canonical stable-key formats + +| Node kind | Prefix | Body | Example | +| --- | --- | --- | --- | +| **repository** | `repo` | the constant repository-root token `root` | `repo:root` | +| **module** | `mod` | normalized repo-relative POSIX **directory** path (empty string = repository root) | `mod:src/auth` | +| **file** | `file` | normalized repo-relative POSIX **file** path | `file:src/auth/service.ts` | +| **symbol** | *(none)* | `::[#]` | `src/auth/service.ts::AuthService.login` | +| **dependency** | `dep` | `:` | `dep:npm:react`, `dep:pypi:fastapi` | + +Notes and rules: + +- **Repository root.** Every snapshot has exactly one repository entity node, `repo:root`. The + opaque `repository_id` remains part of snapshot ownership and semantic identity ([§3.3](#33-snapshot-identity)) + but MUST NOT appear in any node stable key. This prevents a generated database UUID from entering + canonical graph content. Re-imports of the same source can therefore produce comparable graph + content even when their repository records differ. +- The **symbol** key deliberately uses the `::` form shown in the issue's + target contract, with **no `sym:` prefix**, so it reads naturally in evidence and query output. + Consumers detect a symbol key by the presence of `::`. +- **Qualified name.** The dotted path of enclosing named scopes from file top-level to the symbol, + joined by `.`. TypeScript: `Namespace.Class.method`. Python: `module-is-the-file`, so + `OuterClass.inner_method`, `outer_func.nested_func`. The qualified name is **language-native + dotted notation**, not a file path. +- **Nested symbols** append each enclosing scope: `service.ts::AuthService.Session.refresh`. +- **Overloads / duplicate names.** When two symbols in one file share an identical + `::` (TypeScript function overloads, two `def foo` at the same scope, a + re-`class` after conditional definition), each after the first receives a **`#` discriminator** + assigned by **ascending source start position** (`#2`, `#3`, …); the first occurrence has **no** + discriminator. This is deterministic given a fixed revision. The collision itself is also recorded + as an `RI-KEY-DUP-SYMBOL` diagnostic (informational) so consumers can surface it. + **Cross-revision caveat:** the `#` ordinal is **revision-local** — inserting or deleting an + earlier occurrence renumbers later ones — so a `#` key MUST NOT be assumed to identify the same + entity across revisions ([§4.1](#41-principle-and-cross-revision-guarantees)). +- **Anonymous / generated symbols.** A symbol with no source name that must be represented (an + exported default arrow function, an anonymous class expression) gets a **synthetic qualified + segment** of the form `(anonymous:#)` where `` is its 1-based position + among anonymous symbols **within the same enclosing scope**, by source order. Example: + `routes.ts::(anonymous:arrow#1)`. Synthetic segments are the only place parentheses appear in a + qualified name, which keeps them unambiguous against real identifiers. Like the `#` overload + ordinal, the anonymous `#` is **revision-local** and MUST NOT be used for cross-revision + identity ([§4.1](#41-principle-and-cross-revision-guarantees)). +- **Language namespace.** Language is **not** part of the stable key: the file extension already + disambiguates same-named symbols across languages, and a symbol key is always scoped to exactly + one file. Language is stored as a **node property**. For **dependencies**, the `ecosystem` + segment (`npm`, `pypi`) *is* the namespace and is REQUIRED, because the same package name can + exist in multiple ecosystems. +- **External dependencies.** A dependency the repository declares but does not contain is a + `dep::` node with truth class `observed` (the declaration is observed in a + manifest). Its `name` is the manifest-declared package name, lowercased only where the ecosystem + is case-insensitive (npm: preserve; PyPI: normalize per PEP 503 — lowercase, runs of `._-` → `-`). +- **Collision detection.** If two *distinct* entities would receive the **same** stable key and the + `#` discriminator rule does not apply (i.e. they are genuinely different kinds, or a path + collision from [§4.2](#42-path-normalization-applies-to-every-path-in-a-stable-key-or-evidence-record)), + the pipeline MUST emit an `RI-KEY-COLLISION` diagnostic and MUST NOT silently overwrite one with + the other. Collision handling is a snapshot-completing diagnostic (visible gap), not a fatal + error, unless it prevents a coherent graph ([§8.4](#84-which-diagnostics-fail-a-snapshot)). + +### 4.4 Valid and invalid examples + +#### TypeScript — valid + +| Source (`src/auth/service.ts`) | Stable key | +| --- | --- | +| `export class AuthService { login() {} }` | `src/auth/service.ts::AuthService.login` | +| `export function issueToken() {}` (in `src/auth/tokens.ts`) | `src/auth/tokens.ts::issueToken` | +| second `export function fmt(x): string;` overload | `src/auth/util.ts::fmt#2` | +| `export default () => {}` | `src/auth/handler.ts::(anonymous:arrow#1)` | +| `import react` (declared in `package.json`) | `dep:npm:react` | + +#### TypeScript — invalid (rejected, produces a diagnostic, no node) + +| Attempted | Why rejected | +| --- | --- | +| `file:../secrets/.env` | escapes repo root → `RI-SEC-PATH-ESCAPE` | +| `file:/etc/passwd` | absolute path → `RI-SEC-PATH-ESCAPE` | +| `file:src\auth\service.ts` (unnormalized backslashes) | must be normalized to `src/auth/service.ts` before use; raw form is invalid | +| `src/auth/service.ts::login` for a method of `AuthService` | missing enclosing scope; qualified name MUST be `AuthService.login` | + +#### Python — valid + +| Source (`app/api/routes/auth.py`) | Stable key | +| --- | --- | +| `class AuthController:` → `def login(self):` | `app/api/routes/auth.py::AuthController.login` | +| top-level `def get_current_user():` | `app/api/routes/auth.py::get_current_user` | +| second `def handler` at module scope | `app/api/routes/auth.py::handler#2` | +| `import fastapi` (declared in `pyproject.toml`) | `dep:pypi:fastapi` | +| nested `def _inner()` inside `def outer()` | `app/api/routes/auth.py::outer._inner` | + +#### Python — invalid + +| Attempted | Why rejected | +| --- | --- | +| `dep:pypi:Flask` | PyPI names are PEP 503-normalized → `dep:pypi:flask` | +| `file:app/../../etc/hosts` | escapes repo root → `RI-SEC-PATH-ESCAPE` | +| symbol key using `/` instead of `.` in qualified name (`auth.py::AuthController/login`) | qualified name uses language-native `.` | + +--- + +## 5. Edge/fact identity + +### 5.1 Canonical predicates + +These are the **initial registered `ri.v1` predicate set** (lowercase `snake_case`). The set is +**not permanently closed**: adding a new predicate with new semantics is a **compatible `ri.v1` +addition** ([§9.1](#91-compatible-additions)), and conforming readers MUST ignore or safely preserve +predicates they do not recognize ([§9.1](#91-compatible-additions)). **Removing, renaming, or +changing the meaning of** an existing predicate is a breaking change that requires `ri.v2` +([§9.2](#92-breaking-changes-require-riv2)). + +| Predicate | Meaning | Typical subject → object | Required by | +| --- | --- | --- | --- | +| `contains` | structural containment | repository→module, module→file, file→symbol | #88, #91 | +| `defines` | a file/scope defines a symbol | file→symbol, symbol→symbol (nested) | #89, #90, #91 | +| `imports` | an import statement references a module/dependency | file→file, file→dependency | #89, #90, #91 | +| `calls` | a call site invokes a callable | symbol→symbol | #91 | +| `routes_to` | an HTTP route declaration maps to a handler | symbol(route)→symbol(handler) | #90, #91, #95 | +| `depends_on` | repository depends on an external dependency | repository→dependency | #91 | +| `implements` | a type implements/realizes an interface/protocol | symbol→symbol | #91 | + +> Note: today's `RelationshipType` union, consumed by `KnowledgeGraphRelationship` +> ([`models.py:25`](../../apps/backend/app/intelligence/models.py#L25)), also lists `extends`, +> `references`, and `exports`, of which only four are ever emitted +> ([REPOSITORY_INTELLIGENCE.md](REPOSITORY_INTELLIGENCE.md)). `ri.v1` starts from the registered set +> above; `extends` folds into `implements`-adjacent modeling for v1 and `exports`/`references` are +> represented via `defines` + node properties. Reviving them later is a compatible `ri.v1` addition +> when a resolver actually produces them, not an `ri.v2` change. + +### 5.2 Subject and object identity + +The `subject` and `object` of every edge are **node stable keys** ([§4](#4-node-identity-and-stable-keys)), +each carried with its `kind`: + +```json +{ + "subject": { "kind": "symbol", "stable_key": "src/auth/service.ts::AuthService.login" }, + "predicate": "calls", + "object": { "kind": "symbol", "stable_key": "src/auth/tokens.ts::issueToken" } +} +``` + +An edge MUST NOT reference a node that does not exist in the same snapshot. This includes external +dependencies: a `file → dependency` or `repo:root → dependency` edge requires the corresponding +`dep::` node. An unresolved target is not a node and therefore is not stored as an +edge at all ([§5.5](#55-unresolved-relationships)). + +### 5.3 Edge identity and IDs + +- **Relationship identity is the triple** `(subject.stable_key, predicate, object.stable_key)`. + This triple is the canonical stable key of the edge and is stable across snapshots. +- **The edge row id is snapshot-scoped.** The stored `edge_id` is + `edge:sha256(subject.stable_key || "\x1f" || predicate || "\x1f" || object.stable_key)` + (`\x1f` = ASCII unit separator, which cannot occur in a stable key). The digest is deterministic, + so the *same triple always yields the same `edge_id`*, but the stored edge is a row **within one + snapshot** — edge identity does not span snapshots any more than node facts do. +- **Duplicate triples collapse to one edge.** Within a snapshot there is **at most one** edge per + `(subject, predicate, object)` triple. + +### 5.4 Multiple occurrences and multiple evidence + +This is a decision, not an option: + +- **Multiple source occurrences of the same relationship are one edge with multiple evidence + records.** If `AuthService.login` calls `issueToken` on line 41 and again on line 90, that is + **one** `calls` edge carrying **two** evidence records ([§6](#6-provenance-contract)). Occurrences + are *not* modeled as distinct edges. This keeps the graph free of parallel edges, makes + canonicalization ([§12](#12-canonical-graph-hash)) straightforward, and matches how consumers + reason ("does A call B?" is a yes/no with citations). +- **Evidence records are ordered canonically** within the edge (by + the total evidence ordering in [§12.3](#123-total-ordering-and-deduplication)). +- Truth class is a property of the **edge**, not of individual evidence records. All evidence on a + single edge supports the same asserted relationship at the same truth class. + +### 5.5 Unresolved relationships + +An observed occurrence whose target the resolver cannot determine **does not become a guessed +edge**. It becomes a **diagnostic** (`RI-RES-UNRESOLVED` or `RI-RES-AMBIGUOUS`, [§8](#8-diagnostics-model)) +that names the subject stable key and the unresolved reference text. This is the hard rule from #91: +*a guessed edge is never presented as observed* — and, in `ri.v1`, a guessed edge is never stored +at all. + +### 5.6 Inferred property assertions + +An inferred property is a separate graph-native **assertion**, never a replacement entity node. +This preserves the invariant that there is exactly one observed node per stable key while allowing +classifiers to make auditable claims about that entity. + +`ri.v1` initially registers one assertion predicate: + +| Predicate | Meaning | Subject | Value | +| --- | --- | --- | --- | +| `classified_as` | assigns an inferred architecture/layer classification | repository, module, file, or symbol node | canonical JSON object containing `classification` and optional deterministic qualifiers such as `confidence` | + +The assertion record has this shape: + +```json +{ + "kind": "assertion", + "assertion_id": "assertion:sha256:c081743f9923c3f5036ebda30de9be443deb58002b6bfa1102f388e64a024d57", + "subject": { "kind": "module", "stable_key": "mod:app/services" }, + "predicate": "classified_as", + "value": { "classification": "business-logic-layer", "confidence": "heuristic" }, + "truth_class": "inferred", + "producer": "architecture-classifier", + "producer_version": "1.0.0", + "derived_from": [ + { "kind": "node", "stable_key": "mod:app/services" }, + { "kind": "edge", "edge_id": "edge:sha256:e20b7e1135e0535ffb7c19cb2066a0645d9e980d2071816ddfc967431b774807" } + ], + "schema_version": "ri.v1" +} +``` + +Rules: + +- `subject` MUST resolve to the one existing entity node in the same snapshot. An assertion MUST + NOT create, overwrite, or change the truth class/properties of that node. +- `truth_class` MUST be `inferred`. The immediate classifier/inference producer and + `producer_version` are REQUIRED, and `producer@producer_version` MUST appear in the snapshot's + precomputed `producer_version_set` ([§3.3](#33-snapshot-identity)). +- `derived_from` MUST contain the assertion's immediate inputs using the tagged references in + [§6.4](#64-observations-and-the-derived_from-reference-model). Recursive traversal exposes the + exact source evidence supporting the classification; assertions do not copy or invent source + spans. +- Build the assertion identity document from `schema_version`, `subject`, `predicate`, the complete + canonical `value`, `truth_class`, `producer`, `producer_version`, and the sorted/deduplicated + `derived_from` list. Normalize it under [§12.4](#124-normalization), serialize it with JCS + ([§12.1](#121-serialization-format)), then compute + `assertion_id = "assertion:sha256:" + lowercase_hex(sha256(canonical_identity_document))`. +- Persistence MUST enforce `UNIQUE(snapshot_id, assertion_id)`. Byte-identical duplicate assertions + collapse; a change to value, immediate producer/version, or derivation produces a different + assertion identity. +- Assertion predicates form their own registered namespace. Adding a predicate with new semantics + is compatible within `ri.v1`; removing, renaming, or changing an existing predicate's meaning is + breaking and requires `ri.v2` ([§9](#9-schema-versioning)). +- Query responses (#92) return assertions separately from nodes and edges, with their producer, + truth class, value, immediate derivation references, and recursively resolvable evidence. + +--- + +## 6. Provenance contract + +### 6.1 The evidence record + +The **minimum required** evidence record — the fields every stored piece of evidence MUST carry: + +```json +{ + "path": "src/auth/service.ts", + "start_line": 41, + "end_line": 58, + "extractor": "typescript-ast", + "extractor_version": "1.0.0" +} +``` + +| Field | Type | Rule | +| --- | --- | --- | +| `path` | string | Repository-relative POSIX path, normalized per [§4.2](#42-path-normalization-applies-to-every-path-in-a-stable-key-or-evidence-record). REQUIRED. | +| `start_line` | integer | **One-based** line number of the first line of the span. REQUIRED. | +| `end_line` | integer | **One-based**, **inclusive** line number of the last line of the span. REQUIRED. | +| `extractor` | string | The extractor **or resolver** identifier that produced this evidence (`typescript-ast`, `python-ast`, `import-resolver`). REQUIRED. | +| `extractor_version` | string | Semantic version of that extractor/resolver. REQUIRED. | + +### 6.2 Line and span rules (decided, not optional) + +- **Lines are one-based.** The first line of a file is line 1. +- **`end_line` is inclusive.** A span covering only line 41 is `start_line: 41, end_line: 41`. +- **Text decoding and logical line count.** `ri.v1` line evidence is defined over a strict UTF-8 + decode of the stored file bytes. For decoded text, `logical_line_count = 1 + count(U+000A)`. + Therefore an empty zero-byte text file has exactly one logical empty line, and text ending in `\n` + has a final empty logical line. `\r\n` contributes one line because only its `\n` is counted; a + lone `\r` does not increment the count. #89, #90, and #94 MUST use this exact convention. +- **Validation.** An evidence record is **valid** iff `1 ≤ start_line ≤ end_line ≤ + logical_line_count` for the file at the stored revision. A record that is reversed + (`end_line < start_line`), zero or negative, or out of range is **invalid**. +- **An invalid span MUST NOT be stored as `observed` evidence.** The offending fact is dropped and + an `RI-SPAN-INVALID` diagnostic is emitted naming the intended subject/object and the bad span. A + fact without at least one valid evidence record MUST NOT be stored with truth class `observed` + ([§7](#7-truth-classes)). +- **Whole-file facts.** A fact that is genuinely about the whole file (e.g. a `file` node, or a + module-level property) is represented with `start_line: 1` and `end_line: logical_line_count`, + plus `"granularity": "file"` on the evidence record. `granularity` defaults to `"span"` when + omitted. This keeps every evidence record span-validatable while distinguishing "the whole file" + from "lines 1–N happen to be the span." +- **Empty-file test vector.** A zero-byte `empty.txt` decodes to `""`, has + `logical_line_count: 1`, and its whole-file evidence is exactly: + + ```json + { + "path": "empty.txt", + "start_line": 1, + "end_line": 1, + "granularity": "file", + "extractor": "repository-inventory", + "extractor_version": "1.0.0" + } + ``` + + `repository-inventory@1.0.0` must consequently be present in the planned + `producer_version_set` for a pipeline that emits file nodes. +- **Binary and undecodable files.** A non-empty file containing a NUL byte is binary and emits + an `info` `RI-SRC-BINARY`; a non-binary byte sequence that fails strict UTF-8 decoding emits an + `error` `RI-SRC-MALFORMED`. In both cases no line-addressed node, assertion, observation, or + relationship fact is emitted for that file. The diagnostic remains in the snapshot so absence is + visible, and the snapshot may still complete. A zero-byte file is text, not binary. +- **Columns are deferred to v2.** `ri.v1` evidence is **line-granular only**. Optional + `start_column`/`end_column` fields are **reserved** (a reader MUST ignore them if present) and + are **not** produced by any `ri.v1` extractor. Adding column support is a compatible addition + ([§9.1](#91-compatible-additions)) — it does not require `ri.v2` because it only adds optional + fields — but the canonical hash treats their absence as canonical for `ri.v1` snapshots. + +### 6.3 Multiple evidence, resolved/inferred evidence, and the revision tie + +- **A fact MAY carry multiple evidence records** ([§5.4](#54-multiple-occurrences-and-multiple-evidence)). + At least one is REQUIRED for an `observed` fact. +- **Resolved and inferred facts preserve their immediate derivation via `derived_from`.** A + `resolved` edge normally references its source observations. An inferred assertion references the + observations or canonical node/edge/assertion facts it consumed directly. Consumers recursively + traverse these tagged references to the ultimate observations and evidence + ([§6.4](#64-observations-and-the-derived_from-reference-model)); a producer MUST NOT flatten away + an intermediate resolved fact or invent a span it did not read. +- **Evidence is tied to the exact stored revision.** Every `path`/span in an evidence record is + resolved against the **snapshot's stored revision** ([§3](#3-revision-and-snapshot-identity)), not + the current working tree. Because snapshots are immutable and revision-addressed, an evidence + record remains valid for the life of the snapshot: line 41 of `service.ts` means line 41 of + `service.ts` **at that commit/content hash**, forever. #94 validates that 100% of emitted evidence + resolves to a real span in the stored revision. + +### 6.4 Observations and the `derived_from` reference model + +A `resolved` or `inferred` fact must be able to name the observed inputs it was computed from — but +an observed **occurrence** of an import, call, or route is not a node, and (before resolution) is +not an edge, because an unresolved occurrence never becomes an edge +([§5.5](#55-unresolved-relationships)). To reference such an input deterministically, `ri.v1` +defines the **observation**: the smallest stored, deterministic unit of observed syntax. + +**What an observation is.** An extractor (#89/#90) emits, as part of its stored output, one +observation per observed syntactic occurrence it finds — each definition, import statement, call +site, and route declaration. An observation is an evidence-bearing provenance record, not a graph +node or edge; the corresponding node/edge fact carries its own truth class. A resolver (#91) +consumes observations, never the working tree. An observation that never resolves still exists (and +is what a `RI-RES-UNRESOLVED`/`RI-RES-AMBIGUOUS` diagnostic points at); it simply produces no edge. + +**Exact JSON shape.** + +```json +{ + "observation_id": "obs:sha256:d76b7bdbae85fc2016c49cf893a99ac8156cc98c3357582ab092836b94f0b424", + "observed_kind": "import", + "subject": { "kind": "file", "stable_key": "file:src/auth/service.ts" }, + "referent_text": "./tokens", + "ordinal": 1, + "evidence": { + "path": "src/auth/service.ts", "start_line": 3, "end_line": 3, + "extractor": "typescript-ast", "extractor_version": "1.0.0" + } +} +``` + +| Field | Rule | +| --- | --- | +| `observation_id` | REQUIRED. Deterministic id (below). | +| `observed_kind` | REQUIRED. One of `definition`, `import`, `call`, `route`, `implements`, `contains`, `decorator`. Extensible as a compatible addition. | +| `subject` | REQUIRED. The stable key + kind of the node the occurrence is lexically inside (the enclosing file or symbol). This node MUST exist in the snapshot. | +| `referent_text` | OPTIONAL. The raw, unresolved reference as written in source (the import specifier `"./tokens"`, the callee text `issueToken`, the route path `"/login"`). Present for occurrences that a resolver will attempt to resolve; absent for pure `definition` observations. | +| `ordinal` | REQUIRED. One-based source order among observations whose other identity fields are identical. This disambiguates multiple identical occurrences on one line while columns are deferred. | +| `evidence` | REQUIRED. Exactly one evidence record ([§6.1](#61-the-evidence-record)) with a valid span ([§6.2](#62-line-and-span-rules-decided-not-optional)) against the stored revision. | + +**Identity rule.** Build this observation identity document using the snapshot's immutable revision +and the observation fields: + +```json +{ + "evidence": { + "extractor": "typescript-ast", + "extractor_version": "1.0.0", + "path": "src/auth/service.ts", + "start_line": 3, + "end_line": 3 + }, + "observed_kind": "import", + "ordinal": 1, + "referent_text": "./tokens", + "revision": { "kind": "git", "value": "0123456789abcdef0123456789abcdef01234567" }, + "schema_version": "ri.v1", + "subject": { "kind": "file", "stable_key": "file:src/auth/service.ts" } +} +``` + +Normalize its strings and path under [§12.4](#124-normalization), serialize it with the JCS rules in +[§12.1](#121-serialization-format), and compute: + +```text +observation_id = "obs:sha256:" + lowercase_hex(sha256(canonical_identity_document)) +``` + +For an observation without `referent_text`, the identity document contains `"referent_text": null`; +omission is not an alternative encoding. `ordinal` handles the columns-deferred case +([§6.2](#62-line-and-span-rules-decided-not-optional)): two calls to `issueToken` on the same line +with otherwise identical identity fields get ordinals `1` and `2` in source order. Including the +immutable revision makes the ID revision-tied; including the extractor identifier and version avoids +collisions when different extractors report the same span. The subject stable key is safe here +because it is deterministic within the stored revision ([§4.1](#41-principle-and-cross-revision-guarantees)). + +**The `derived_from` tagged reference.** A derived fact records its **immediate** inputs with one or +more of these exact shapes: + +```json +[ + { + "kind": "observation", + "observation_id": "obs:sha256:d76b7bdbae85fc2016c49cf893a99ac8156cc98c3357582ab092836b94f0b424" + }, + { "kind": "node", "stable_key": "mod:app/services" }, + { "kind": "edge", "edge_id": "edge:sha256:e20b7e1135e0535ffb7c19cb2066a0645d9e980d2071816ddfc967431b774807" }, + { + "kind": "assertion", + "assertion_id": "assertion:sha256:c081743f9923c3f5036ebda30de9be443deb58002b6bfa1102f388e64a024d57" + } +] +``` + +Each reference MUST resolve in the same snapshot: observation refs by +`observation_id`, node refs by the snapshot-scoped stable key, edge refs by deterministic `edge_id`, +and assertion refs by deterministic `assertion_id`. Free text, placeholders, database row ids, and +cross-snapshot references are forbidden. + +- A `resolved` edge MUST cite the source observation(s) it resolved. Its own required `producer` and + `producer_version` identify the immediate resolver. +- An inferred assertion MUST cite the actual node, edge, assertion, or observation facts consumed + directly by its classifier. If it consumes a resolved edge, it cites that edge rather than + flattening the edge to the edge's observations. Recursive traversal then preserves the complete + chain to the ultimate source evidence. +- `derived_from` is a set: pre-seal normalization removes byte-identical references and sorts the + rest by the total ordering in [§12.3](#123-total-ordering-and-deduplication). +- Derivation references form a directed graph from each derived fact to its immediate inputs. The + graph MUST be acyclic. Pre-seal validation resolves every reference and performs cycle detection; + a missing target or cycle is a `fatal` internal-consistency error and the snapshot MUST NOT seal. + +**How this behaves across resolution outcomes:** + +- **Resolves.** The resolver emits a `resolved` edge whose `derived_from` names the import/call/route + observation(s); the edge's own evidence points at the same occurrence span, and the edge records + its resolver `producer` and `producer_version`. The observation remains stored. +- **Inference.** The classifier emits a separate inferred assertion ([§5.6](#56-inferred-property-assertions)) + whose immediate references may include resolved edges. It never creates a second entity node or + erases the intermediate edge from the audit chain. +- **Unresolved / ambiguous.** No edge is produced. A `RI-RES-UNRESOLVED` / `RI-RES-AMBIGUOUS` + diagnostic ([§8](#8-diagnostics-model)) carries the `observation_id` in its `details` + (`{"observation_id": "obs:sha256:…"}`) and names the subject stable key. The observation remains + stored so a consumer can still cite the unresolved occurrence — but it is never presented as an + edge. +- **Hashing.** Observations participate in the canonical hash as their own ordered array + ([§12.2](#122-what-is-hashed), [§12.3](#123-total-ordering-and-deduplication)); every tagged + `derived_from` list is sorted, deduplicated, serialized, and hashed with its containing fact. + Because all targets have deterministic identities, identical input and pipeline plans produce + identical derivation bytes. + +--- + +## 7. Truth classes + +### 7.1 Definitions and emission rules + +| Truth class | Definition | Emission rule | +| --- | --- | --- | +| **observed** | A direct syntax/entity-existence fact extracted from an **exact source span**. | MAY be emitted **only** by an **extractor** ([§2.2](#22-defined-terms)), and **only** with ≥1 valid evidence record whose span comes from that stored source. Entity nodes are observed and unique by stable key; no extractor may emit `observed` without valid evidence ([§6.2](#62-line-and-span-rules-decided-not-optional)). | +| **resolved** | A relationship produced by a **documented, deterministic resolution algorithm** over stored observed inputs. | MAY be emitted **only** as an **edge** by a resolver. The edge records `producer`, `producer_version`, and source-observation `derived_from` references. The algorithm MUST be documented (per #91) and deterministic: same inputs → same output. | +| **inferred** | A heuristic conclusion supported by evidence but **not guaranteed by syntax** (e.g. "this module is the authentication layer"). | MAY be emitted **only** as a separate **assertion** by a classifier/inference producer ([§5.6](#56-inferred-property-assertions)). It records its immediate producer/version and immediate tagged derivation references, and MUST be labeled inferred everywhere it surfaces. It MUST NOT duplicate or mutate an entity node. | +| **generated** | Human-facing **narrative** (prose explanation, AI answer, generated docs). | **Never stored as a fact in the snapshot graph** and never displayed as a deterministic repository fact ([§7.4](#74-generated-narrative)). | + +### 7.2 Emission matrix (producer × truth class) + +`✔` = permitted; blank = **forbidden**. + +| Producer | observed | resolved | inferred | generated | +| --- | :---: | :---: | :---: | :---: | +| **Extractor** (`typescript-ast`, `python-ast`) | ✔ | | | | +| **Resolver** (`import-resolver`, `route-resolver`, `reference-resolver`) | | ✔ | | | +| **Classifier / inference** (architecture/layer classification) | | | ✔ (assertion only) | | +| **Narrative generator** (AI answer, doc prose) | | | | ✔ (never in graph) | +| **Historical legacy regex engine** | | | | see [§10.3](#103-legacy-regex-intelligence) — **not** `observed` | + +The forbidden cells are load-bearing: an extractor MUST NOT emit `resolved` or `inferred`; a +resolver MUST NOT emit `observed` (it did not read a source span — it read stored facts); no +component may store `generated` narrative as a graph fact. A classifier MUST NOT emit an inferred +entity node; it emits a property assertion about the existing observed node. + +Every `resolved` edge and `inferred` assertion MUST carry separate `producer` and +`producer_version` fields naming its immediate producer. The combined +`producer@producer_version` identifier MUST exist in the snapshot's precomputed +`producer_version_set`; otherwise pre-seal validation fails. This makes every derived fact +attributable without waiting until execution to discover which code participated. + +### 7.3 Upgrades, retention, labeling + +- **Truth class is fixed at emission and MUST NOT be upgraded.** A fact cannot be "promoted" from + `resolved` to `observed`, or from `inferred` to `resolved`. A stronger claim requires a stronger + *producer* re-deriving the fact from scratch in a **new snapshot**; it is never an in-place edit + (snapshots are immutable — [§11](#11-snapshot-lifecycle-and-immutability)). +- **Supporting evidence and derivation are retained.** Downgrading is also not an in-place + operation; every fact keeps its evidence and immediate `derived_from` references for the life of + the snapshot, so consumers can recursively reconstruct the complete chain. +- **APIs and UIs MUST label `inferred` output.** The query API (#92) MUST expose `truth_class` on + every fact, and any consumer (#95) MUST visibly distinguish `inferred` conclusions from + `observed`/`resolved` ones. This is the machine-checkable successor to the + [REPOSITORY_INTELLIGENCE.md](REPOSITORY_INTELLIGENCE.md) rule "never present a heuristic output as + a deterministic fact." +- **An unresolved relationship produces a diagnostic, not a guessed edge** ([§5.5](#55-unresolved-relationships)). + +### 7.4 Generated narrative + +`generated` content — an AI answer, a prose architecture explanation — is **never stored in the +snapshot graph and never displayed as a deterministic repository fact.** It MAY be presented as +narrative *alongside* facts, but each concrete claim it makes MUST resolve to an underlying +`observed`/`resolved` fact with evidence (this is exactly the #95 proof workflow). This preserves +the existing invariant that the AI is a *consumer*, never a producer, of repository truth +([REPOSITORY_INTELLIGENCE.md](REPOSITORY_INTELLIGENCE.md), [CONTRIBUTING §11](../../CONTRIBUTING.md)). + +--- + +## 8. Diagnostics model + +### 8.1 The diagnostic record + +Diagnostics are first-class, structured snapshot output. Every diagnostic record has: + +| Field | Type | Rule | +| --- | --- | --- | +| `code` | string | Stable diagnostic code, e.g. `RI-EXT-UNSUPPORTED` ([§8.2](#82-diagnostic-codes-and-categories)). REQUIRED, stable across versions. | +| `category` | enum | One of [§8.2](#82-diagnostic-codes-and-categories). REQUIRED. | +| `severity` | enum | `fatal` \| `error` \| `warning` \| `info` ([§8.3](#83-severities)). REQUIRED. | +| `message` | string | Human-readable, deterministic (no timestamps, no absolute paths, no addresses). REQUIRED. | +| `path` | string \| null | Repository-relative POSIX path when applicable. | +| `span` | `{start_line,end_line}` \| null | One-based inclusive span when a location is known. | +| `producer` | string | Combined producer and version, e.g. `typescript-ast@1.0.0`. REQUIRED, and MUST occur in the snapshot's planned `producer_version_set`. | +| `subject` | stable key \| null | Related subject stable key when applicable. | +| `object` | stable key \| null | Related object stable key when applicable. | +| `details` | object | Deterministic structured details (e.g. `{ "candidates": ["a.ts::x","b.ts::x"] }`). Keys sorted; no volatile values. | + +### 8.2 Diagnostic codes and categories + +Codes are stable strings. The `ri.v1` baseline set (extensible as a compatible addition): + +| Category | Code | Raised when | +| --- | --- | --- | +| unsupported construct | `RI-EXT-UNSUPPORTED` | A construct outside the extractor's published support matrix (#89/#90). | +| ambiguous resolution | `RI-RES-AMBIGUOUS` | A resolver finds more than one candidate target and cannot deterministically choose. | +| unresolved reference | `RI-RES-UNRESOLVED` | A resolver finds no target for an observed reference ([§5.5](#55-unresolved-relationships)). | +| extraction failure | `RI-EXT-FAILURE` | An extractor failed on an input it should have handled. | +| invalid span | `RI-SPAN-INVALID` | A produced span violates [§6.2](#62-line-and-span-rules-decided-not-optional). | +| stable-key collision | `RI-KEY-COLLISION` | Two distinct entities map to one stable key ([§4.3](#43-canonical-stable-key-formats)). | +| duplicate symbol | `RI-KEY-DUP-SYMBOL` | Overload/duplicate name resolved via `#` discriminator (informational). | +| binary source | `RI-SRC-BINARY` | A non-empty file contains a NUL byte and is excluded from line-addressed extraction ([§6.2](#62-line-and-span-rules-decided-not-optional)). | +| malformed source | `RI-SRC-MALFORMED` | Source could not be parsed (syntax error, invalid encoding). | +| resource-limit skip | `RI-LIMIT-SKIP` | An input skipped by a resource budget (file too large, count cap — cf. the 512 KiB default in [`pipeline.py`](../../apps/backend/app/extraction/pipeline.py#L31) and #93 budgets). | +| path escape | `RI-SEC-PATH-ESCAPE` | A path escapes the repository root ([§4.2](#42-path-normalization-applies-to-every-path-in-a-stable-key-or-evidence-record)). | +| internal failure | `RI-INT-FAILURE` | An unexpected internal extractor/resolver failure. | + +### 8.3 Severities + +- **`fatal`** — the snapshot cannot be coherently built. Fails the snapshot ([§8.4](#84-which-diagnostics-fail-a-snapshot)). +- **`error`** — a specific fact could not be produced (a collision, an invalid span, an extraction + failure on one file). The snapshot completes **with a visible gap**. +- **`warning`** — a fact was produced but with a caveat, or a relationship was left unresolved. +- **`info`** — informational (e.g. `RI-KEY-DUP-SYMBOL`, an unsupported construct that is expected). + +### 8.4 Which diagnostics fail a snapshot + +- A snapshot seals as **`completed`** iff it has **no `fatal` diagnostic**. `error`/`warning`/`info` + diagnostics are recorded and the snapshot completes with the corresponding facts absent (visible + gaps), because a partial-but-honest graph is more useful than no graph. +- A snapshot transitions to **`failed`** iff any `fatal` diagnostic is present — for example + `RI-INT-FAILURE` at the pipeline level, or a condition that would make the stored graph + internally inconsistent (a dangling edge that cannot be dropped safely). +- `RI-EXT-UNSUPPORTED`, `RI-RES-UNRESOLVED`, `RI-RES-AMBIGUOUS`, `RI-SRC-BINARY`, `RI-LIMIT-SKIP`, + `RI-KEY-DUP-SYMBOL` are **never fatal** — they are the expected, honest output of a system that + refuses to guess. `RI-SPAN-INVALID` and `RI-KEY-COLLISION` are `error` (drop the fact, complete + the snapshot) unless they cascade into an incoherent graph, in which case they escalate to + `fatal`. + +--- + +## 9. Schema versioning + +This RFC records the accepted `schema_version: "ri.v1"` as the value carried on every snapshot and +every fact-bearing API response. `ri.v1` is the value all conforming `ri.v1` artifacts carry. + +### 9.1 Compatible additions + +The following MAY be added within `ri.v1` without a version bump, because they cannot break a +conforming reader that ignores unknown optional fields: + +- New **optional** fields on nodes, edges, assertions, evidence, or diagnostics. +- New **node kinds**, **predicates** (adding a predicate with new semantics — [§5.1](#51-canonical-predicates)), + **assertion predicates**, **observation kinds**, or **diagnostic codes**. +- New producers (extractors, resolvers, classifiers, or inference producers) when they use existing + fact semantics; they carry their own version and enter `producer_version_set`, not the schema + version ([§9.4](#94-schema-version-vs-producer-version)). +- Populating a `reserved` field (e.g. columns — [§6.2](#62-line-and-span-rules-decided-not-optional)) with the documented semantics. + +Readers MUST **ignore or safely preserve** unknown fields, unknown node kinds, **unknown +predicates**, unknown assertion predicates, unknown observation kinds, and unknown diagnostic codes +rather than failing. + +### 9.2 Breaking changes (require `ri.v2`) + +- Changing the **stable-key format** of any node kind ([§4](#4-node-identity-and-stable-keys)), + including introducing a content-based symbol discriminator ([§15.1](#151-operational-costs-and-limitations-stated-honestly)). +- Changing the assertion identity document or tagged derivation-reference shapes + ([§5.6](#56-inferred-property-assertions), [§6.4](#64-observations-and-the-derived_from-reference-model)). +- Changing **provenance semantics** (one-based → zero-based, inclusive → exclusive, path + normalization rules). +- Changing **truth-class semantics** or the emission matrix in a way that reclassifies existing + facts. +- **Removing, renaming, or changing the meaning of an existing relationship or assertion + predicate** ([§5.1](#51-canonical-predicates), [§5.6](#56-inferred-property-assertions)); + removing or renaming a required field; changing a field's type or meaning. +- Changing **canonical-hash inputs or ordering** ([§12](#12-canonical-graph-hash)) such that an + unchanged revision hashes differently. + +A breaking change is introduced as a **new RFC** that proposes `ri.v2` for ratification; this +document is not edited to describe `ri.v2` behavior. + +### 9.3 When `ri.v2` is required + +Precisely when a change falls under [§9.2](#92-breaking-changes-require-riv2). If a proposed change +would make an existing sealed `ri.v1` snapshot mis-read or re-hash under the new rules, it is +breaking and requires `ri.v2`. + +### 9.4 Schema version vs producer version + +- **Schema version** (`ri.v1`) versions the *contract*. +- **Producer version** versions one implementation. Bumping `typescript-ast`, `import-resolver`, or + `architecture-classifier` from `1.0.0` to `1.1.0` does **not** change the schema version; it + changes the precomputed `producer_version_set` ([§3.3](#33-snapshot-identity)) and therefore + produces a new snapshot for the same revision. The two axes are independent and both feed the + canonical hash ([§12](#12-canonical-graph-hash)). + +### 9.5 API response versioning (#92) and negotiation + +- Every fact-bearing API response (#92) MUST include the `schema_version` it conforms to. +- Response **envelope** schemas are additionally versioned by the API (e.g. a `v1` route prefix or + media-type parameter); a breaking envelope change is a new API version, decoupled from `ri.v*` so + the wire shape can evolve independently of the fact schema. +- A reader that receives an **unsupported** `schema_version` MUST reject it explicitly (a clear + error naming the versions it supports), MUST NOT silently coerce it, and MAY negotiate by + requesting a supported version if the API offers one. + +### 9.6 Historical snapshots retain their version + +**Immutable historical snapshots retain their original `schema_version` forever.** A sealed `ri.v1` +snapshot is never rewritten to `ri.v2`; it remains a valid `ri.v1` artifact and is read under +`ri.v1` rules. New analysis after a schema bump produces `ri.v2` snapshots alongside the retained +`ri.v1` ones. + +--- + +## 10. Migration policy + +### 10.1 Database migrations, backfills, rollback + +- Every schema change ships an **Alembic migration** that **downgrades cleanly**, per + [CONTRIBUTING §10](../../CONTRIBUTING.md) (the migration test enforces up/down). +- **Backfills belong in the migration**, not in application startup ([CONTRIBUTING §10](../../CONTRIBUTING.md)). + The #87 revision-column backfill and any snapshot-table introduction (#88) follow this rule. +- Migrations are **transactional** with uniqueness constraints and foreign keys on snapshot/node/ + edge/provenance tables (#88). +- **Rollback/downgrade** must leave the database in the pre-migration shape without data loss for + columns that existed before. Introducing immutable snapshot tables is additive; downgrade drops + the new tables (accepting that snapshots created under the new schema are lost on downgrade — an + explicitly documented cost, not a silent one). + +### 10.2 Migration between schema versions: re-extraction, not transformation + +- The revision-identity migration (#87) is a **transformation**: it moves an existing typed value + (`commitSha`) from a JSON blob into an indexed column. Transformation is allowed **only** for + values that are already exact and typed. +- **Graph facts are never transformed across schema versions.** Moving from `ri.v1` to a future + `ri.v2` graph MUST be done by **re-extraction** (re-running extractors/resolvers against the + stored revision to produce a fresh `ri.v2` snapshot), never by mechanically rewriting `ri.v1` rows + into `ri.v2` shape. Old `ri.v1` snapshots are retained under their original version + ([§9.6](#96-historical-snapshots-retain-their-version)). + +### 10.3 Legacy regex intelligence + +The historical `repo_metadata["intelligence"]` blob (produced by the removed +legacy regex engine and carrying **no line spans**) is handled as follows — +this is a decision the RFC must not leave open: + +- **Legacy regex facts are NOT promoted to `observed`.** They have no valid spans and no + extractor/version provenance; per [§6.2](#62-line-and-span-rules-decided-not-optional) and + [§7.1](#71-definitions-and-emission-rules) they cannot be `observed`. +- **They are retained as explicitly legacy/unverified data, and superseded by reanalysis.** The + legacy blob MAY remain readable, clearly labeled `legacy_unverified` (never `observed`, + `resolved`, or `inferred`), until a fresh `ri.v1` snapshot exists for the repository. A `ri.v1` + snapshot, once sealed, is the authoritative source for that repository/revision and the consumer + migration (#95) reads only snapshots. +- The legacy blob is **not migrated into the snapshot graph**. Its facts are not copied into node/ + edge tables. The path to a real graph is reanalysis (#88 + #89/#90/#91), not transformation of + regex output. + +### 10.4 Failure and recovery + +- A migration that fails MUST roll back within its transaction, leaving the prior schema intact. +- A partially built snapshot that fails ([§11](#11-snapshot-lifecycle-and-immutability)) never + becomes visible as `completed`; recovery is a fresh analysis run (#93 job lifecycle), not repair + of a half-sealed snapshot. + +--- + +## 11. Snapshot lifecycle and immutability + +### 11.1 States + +`ri.v1` defines exactly these snapshot states: + +- **`building`** — the snapshot is being populated. Its complete semantic identity, including the + planned `producer_version_set`, was fixed before the job was enqueued. Facts and diagnostics are + being written. Not visible to consumers as an authoritative result. +- **`completed`** (a.k.a. **sealed**) — validation passed, the canonical hash is computed and + stored, and the snapshot is **immutable**. +- **`failed`** — a `fatal` diagnostic ([§8.4](#84-which-diagnostics-fail-a-snapshot)) or an + unrecoverable error occurred. The snapshot did not seal; it is retained for diagnosis but is never + served as an authoritative graph. + +### 11.2 Sealing transaction and pre-seal validation + +- **The seal is a single database transaction** that: (a) validates the snapshot, (b) computes and + stores the canonical graph hash ([§12](#12-canonical-graph-hash)), and (c) flips the status from + `building` to `completed`. Either all three commit or none do. There is no observable + intermediate "sealed but unvalidated" state. +- **Required validation before sealing:** + 1. Every `observed` fact has ≥1 valid evidence record ([§6.2](#62-line-and-span-rules-decided-not-optional)). + 2. Every edge's subject and object reference nodes that exist in the snapshot (or a permitted + external `dependency` node) ([§5.2](#52-subject-and-object-identity)). + 3. No `fatal` diagnostic is present ([§8.4](#84-which-diagnostics-fail-a-snapshot)). + 4. Every stable key is well-formed ([§4](#4-node-identity-and-stable-keys)); collisions are + resolved or recorded. + 5. Exactly one `repo:root` node exists; every other node stable key is unique under + `(snapshot_id, stable_key)`; no inferred assertion duplicates or mutates an entity node. + 6. Every output producer is declared in the precomputed `producer_version_set`. Every resolved + edge and inferred assertion has an immediate `producer`/`producer_version` pair matching that + set, and every diagnostic's combined `producer@version` identifier matches it. + 7. Every `derived_from` reference resolves to an observation or canonical fact in the same + snapshot, and the derivation graph is acyclic ([§6.4](#64-observations-and-the-derived_from-reference-model)). + 8. Assertion subjects and all edge endpoints resolve; assertion identities recompute from their + canonical identity documents; duplicate semantic edge records have been consolidated. + 9. Every set-semantic array is normalized, sorted, and deduplicated under the total rules in + [§12.3](#123-total-ordering-and-deduplication). + 10. The canonical hash is reproducible (computing it twice yields the same value). + +### 11.3 Immutability + +- **A completed snapshot is immutable.** Its planned producer set, optional `actual_producers`, + nodes, edges, assertions, observations, evidence, derivation references, diagnostics, and hash + MUST NOT change. +- **Mutation attempts are rejected, not ignored.** A write against a `completed` snapshot MUST raise + an error (#88 tests this). This is the successor to today's silent-overwrite behavior, where + re-analysis rewrites `repo_metadata["intelligence"]` in place. +- **Corrections and reanalysis come from a NEW snapshot produced by changed inputs** + ([§3.4](#34-reanalysis-reuse-and-idempotency)). There is no edit-in-place path, and an analysis + request whose composite identity matches a sealed snapshot reuses it rather than producing a + correction. +- **Diagnostics become immutable at seal**, together with the facts — they are part of the sealed, + hashed artifact. + +### 11.4 Failed extraction, cancellation, retry (#93 interaction) + +- A `building` snapshot that hits a `fatal` condition transitions to `failed` and is never served. +- **The job's completion is the thing that seals the snapshot** (#93): a durable job's successful + terminal step is the sealing transaction. #93 and #88 MUST agree that "job completed" ⇔ "snapshot + sealed." +- **Cancellation** (#93) aborts a `building` snapshot; the aborted snapshot is discarded or marked + `failed`, never `completed`. +- **Retry** (#93) starts a **new** `building` attempt. Retries and failed attempts MAY accumulate + multiple `building`/`failed` rows for one composite identity, but **only one snapshot may ever + become `sealed`/`completed`** for that identity ([§3.4](#34-reanalysis-reuse-and-idempotency)); a + retry never resumes and seals a previously abandoned attempt. + +### 11.5 Concurrency and idempotency + +- **Idempotent submission** (#93): a request for a composite identity + ([§3.3](#33-snapshot-identity)) that already has a sealed snapshot **MUST reuse** it and **MUST + NOT** produce a second sealed snapshot ([§3.4](#34-reanalysis-reuse-and-idempotency)). +- **Concurrent builds** for the same composite identity **MUST coordinate so at most one attempt + seals**; after one seals, the others reuse the sealed result. The uniqueness constraint permitting + at most one sealed snapshot per composite identity ([§3.3](#33-snapshot-identity)) is the backstop. + +--- + +## 12. Canonical graph hash + +The canonical graph hash makes snapshot output **deterministic and comparable** and is the basis +for #88's stored hash and #94's determinism check. **Identical input revision, schema version, +planned producer versions, and configuration MUST produce the same canonical hash, independent of +record insertion order.** + +### 12.1 Serialization format + +- Canonical serialization is **UTF-8 JSON with lexicographically sorted object keys, no + insignificant whitespace, and no trailing newline** (RFC 8785 JSON Canonicalization Scheme + semantics). Numbers are integers (line numbers); no floats appear in hashed content. +- The hash is `sha256` of the canonical serialization, stored as `sha256:`. + +### 12.2 What is hashed + +A single canonical document with five ordered arrays plus scalar inputs: + +```jsonc +{ + "schema_version": "ri.v1", + "revision": { "kind": "...", "value": "..." }, + "producer_version_set": [ + "architecture-classifier@1.0.0", + "import-resolver@1.0.0", + "python-ast@1.0.0", + "reference-resolver@1.0.0", + "repository-inventory@1.0.0", + "route-resolver@1.0.0", + "typescript-ast@1.0.0" + ], + "config_hash": "sha256:...", + "nodes": [""], + "edges": [""], + "assertions": [""], + "observations": [""], + "diagnostics": [""] +} +``` + +### 12.3 Total ordering and deduplication + +Every hashed array uses the same final procedure: + +1. Normalize the complete record under [§12.4](#124-normalization), including its nested arrays. +2. Serialize that complete normalized record to JCS bytes ([§12.1](#121-serialization-format)). +3. Remove byte-identical duplicate records. +4. Sort by the semantic tuple documented below, then by the **complete JCS record bytes as the final + tie-breaker**. Therefore two unequal records can never compare equal, even when all earlier tuple + fields match. + +The semantic tuples are: + +- **Nodes:** `(stable_key, complete_record_jcs)`. Two unequal node records with the same stable key + are not alternative orderings; they violate the one-entity invariant and pre-seal validation MUST + reject them ([§4.1](#41-principle-and-cross-revision-guarantees)). +- **Edges:** first consolidate every `(subject.stable_key, predicate, object.stable_key)` group into + one edge by set-unioning and normalizing `evidence` and `derived_from`. All other fields, including + truth class and immediate producer/version, MUST agree or the snapshot fails. Sort consolidated + edges by `(subject.stable_key, predicate, object.stable_key, complete_record_jcs)`. +- **Assertions:** `(subject.stable_key, predicate, assertion_id, complete_record_jcs)`. +- **Observations:** `(observation_id, complete_record_jcs)`. +- **Evidence within a fact:** `(path, start_line, end_line, granularity, extractor, + extractor_version, complete_record_jcs)`. Canonicalization materializes the default + `granularity: "span"`; all other present optional evidence fields participate through the final + JCS bytes. +- **Tagged `derived_from` references:** `(kind_rank, referenced_identity, complete_record_jcs)`, + where the fixed rank is `observation=0`, `node=1`, `edge=2`, `assertion=3`, and the referenced + identity is respectively `observation_id`, `stable_key`, `edge_id`, or `assertion_id`. References + are set-semantic and therefore sorted and deduplicated. +- **Diagnostics:** `(code, category, severity, path_or_empty, span.start_line_or_0, + span.end_line_or_0, producer, subject_or_empty, object_or_empty, message, + canonical_details_jcs, complete_record_jcs)`. This includes the complete span, immediate producer, + and every deterministic `details` field; diagnostics that differ only in optional/details content + consequently still have a deterministic order. +- **`producer_version_set`:** lexicographically sort identifiers by normalized UTF-8 byte order and + remove duplicates before both identity lookup and hashing. + +For any property/configuration array whose documented semantics are a **set**, normalize each +element, sort by its complete JCS bytes, and remove byte-identical duplicates. Arrays whose order +changes meaning (for example an ordered resolver pipeline) preserve their declared order. Every +producer MUST declare each array field as set-semantic or order-significant; an undeclared array is +invalid and prevents sealing. Thus the same logical output generated in different insertion orders +always hashes identically. + +### 12.4 Normalization + +- All paths are repository-relative POSIX, normalized per [§4.2](#42-path-normalization-applies-to-every-path-in-a-stable-key-or-evidence-record). +- All strings are **Unicode NFC**. +- Evidence with omitted `granularity` is materialized as `"granularity": "span"` before sorting and + hashing, so omitted-default and explicit-default encodings cannot diverge. +- Reserved-but-absent optional fields (e.g. columns in `ri.v1`) are **omitted**, and their omission + is canonical for `ri.v1`. A producer that emits a reserved column field is non-conforming and the + snapshot MUST NOT seal. + +### 12.5 Excluded volatile fields + +The following MUST be **excluded** from the hash, because they vary between otherwise-identical +runs: `snapshot_id`, `repository_id`, any `created_at`/`sealed_at`/wall-clock timestamp, any +autoincrement/database row id, host/environment identifiers, and optional execution-audit metadata +such as `actual_producers`. `revision.ref` (a moving branch name) is **excluded**; `revision.value` +(the immutable SHA/content hash) is **included**. Canonical `edge_id`, `assertion_id`, and +`observation_id` values are deterministic content identities and are included where present. + +`repository_id` remains part of snapshot ownership and semantic identity ([§3.3](#33-snapshot-identity)) +but is deliberately excluded from graph-content hashing. The repository entity key is `repo:root`, +so an opaque database UUID cannot leak into the node array; equivalent stored source and pipeline +inputs can produce the same graph hash across separate repository records. + +### 12.6 Diagnostics in the hash + +Diagnostics **are included** in the canonical document ([§12.2](#122-what-is-hashed)) because a +change in what the pipeline *could not* handle is a real change in the snapshot's meaning. Their +`message` field MUST be deterministic (no timestamps, no absolute paths) so it does not destabilize +the hash. If an implementation needs to compare graphs while ignoring diagnostics, it MAY compute a +secondary `graph_only_hash`, but the **primary** `canonical_graph_hash` +covers all five arrays (`nodes`, `edges`, `assertions`, `observations`, `diagnostics`) and is the one +stored and compared for determinism. A secondary graph-only hash, if exposed, covers +`nodes`+`edges`+`assertions` and follows the same total-order rules. + +### 12.7 `config_hash` + +`config_hash` is the deterministic fingerprint of the **output-affecting analysis configuration**. It +is a component of snapshot identity ([§3.3](#33-snapshot-identity)) and an input to the canonical +graph hash ([§12.2](#122-what-is-hashed)), so it MUST be computed by exactly this procedure: + +1. **Included (output-affecting) configuration only.** `config_hash` covers configuration that can + change graph output, for example: enabled-producer selection and pipeline order; extractor, + resolver, and classifier support-matrix options; resource limits that change *what is extracted* + (max file size, file-count caps, per-file node caps — cf. the current 512 KiB default in + [`pipeline.py`](../../apps/backend/app/extraction/pipeline.py#L31)); and any language/parse options + that alter emitted facts. +2. **Excluded operational settings.** Configuration that cannot change graph output MUST be excluded: + database URL, storage path, worker/queue concurrency, timeouts and retry counts (#93), log level, + rate-limit budgets, and credentials. (Note: producer **versions** are identity inputs but are + carried separately in `producer_version_set` ([§3.3](#33-snapshot-identity)); they + are not part of `config_hash`.) +3. **Canonical serialization.** Serialize the included configuration as a canonical JSON document + using the **same JCS rules** as [§12.1](#121-serialization-format): UTF-8, lexicographically + **sorted object keys**, no insignificant whitespace, no trailing newline. +4. **String normalization.** Normalize all string keys and values to **Unicode NFC**; normalize any + path-valued setting per [§4.2](#42-path-normalization-applies-to-every-path-in-a-stable-key-or-evidence-record). +5. **Array ordering.** **Sort arrays whose semantics are sets** (e.g. enabled extractors or + classifiers) ascending; **preserve declared order for arrays whose order affects output** (e.g. + an ordered resolver pipeline). Each array's setting documents which rule applies; when in doubt a + setting is treated as order-significant. +6. **Hash.** Compute `sha256` over the canonical UTF-8 bytes and store as `sha256:`. +7. **Empty/default configuration.** The hash input represents the **effective** output-affecting + configuration after defaults are applied, not only user-supplied overrides. Every output-affecting + default MUST therefore appear explicitly. The empty configuration is used only when no such + setting exists; it is the canonical JSON object `{}`, whose + `config_hash` is therefore the SHA-256 of the two bytes `{}` — + `sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a`. + An implementation MUST NOT use this empty hash merely because the caller supplied no overrides. + +**Normative example.** Configuration enabling three extractors, three resolvers, one classifier, and a +512 KB file cap: + +```jsonc +// input (pre-canonicalization) +{ "max_file_bytes": 524288, + "extractors": ["typescript-ast", "python-ast", "repository-inventory"], + "resolvers": ["route-resolver", "import-resolver", "reference-resolver"], + "classifiers": ["architecture-classifier"] } +``` + +```text +canonical bytes: +{"classifiers":["architecture-classifier"],"extractors":["python-ast","repository-inventory","typescript-ast"],"max_file_bytes":524288,"resolvers":["import-resolver","reference-resolver","route-resolver"]} +config_hash = "sha256:" + hex(sha256(canonical bytes)) + = "sha256:48e96ba328a03db38556f22d2831d171b82e1ce9287c575328de4bc249da1abe" +``` + +`config_hash` MUST be referenced by both snapshot identity ([§3.3](#33-snapshot-identity)) and the +canonical graph hash ([§12.2](#122-what-is-hashed)); the same configuration therefore always yields +the same identity and hash inputs. + +--- + +## 13. Security and ownership + +These are requirements on every downstream issue, restating and extending the existing invariants in +[CONTRIBUTING §11](../../CONTRIBUTING.md), [SYSTEM_OVERVIEW.md](SYSTEM_OVERVIEW.md), and the #63/#65 +owner-scoping work: + +- **Repository and snapshot queries MUST be owner-scoped at the service layer** (#92), consistent + with #63. Every snapshot lookup goes through owner-scoped accessors (like + [`RepositoryRepository.get_for_owner`](../../apps/backend/app/repositories/repository_repository.py#L25)); a cross-owner + request receives the same `404` as a missing one and never learns the resource exists. A query API + that bypasses owner scoping reopens exactly the gap #63 closed. +- **Evidence MUST NOT permit filesystem traversal.** Every path is repository-relative and + normalized per [§4.2](#42-path-normalization-applies-to-every-path-in-a-stable-key-or-evidence-record); + a path escaping the repository root is rejected with `RI-SEC-PATH-ESCAPE` and never stored. +- **Paths remain repository-relative** everywhere — in stable keys, evidence, and diagnostics. + Absolute paths and host paths MUST NOT appear in any stored fact, diagnostic, or API response. +- **Secrets and repository contents MUST NOT be logged**, per [CONTRIBUTING §11.8](../../CONTRIBUTING.md) + and the existing log redaction ([SYSTEM_OVERVIEW.md](SYSTEM_OVERVIEW.md)). Diagnostic `message` + and `details` MUST NOT embed source content or secrets. +- **Query APIs MUST NOT read the working tree** (#92). The query path reads the snapshot store only; + no filesystem read anywhere in it. #95 asserts this with a test that the migrated consumer performs + zero filesystem reads. +- **Consumers cannot create independent parsers** ([CONTRIBUTING §11.1–11.3](../../CONTRIBUTING.md), + [REPOSITORY_INTELLIGENCE.md](REPOSITORY_INTELLIGENCE.md)). A consumer that can only query cannot + invent its own parser; this contract makes the architectural invariant enforceable rather than + aspirational. + +--- + +## 14. Normative examples + +Each example is illustrative of the `ri.v1` shape; field names are normative, surrounding envelope +is not. + +### 14.1 Observed TypeScript symbol + +```json +{ + "kind": "node", + "node_kind": "symbol", + "stable_key": "src/auth/service.ts::AuthService.login", + "name": "login", + "language": "typescript", + "truth_class": "observed", + "evidence": [ + { "path": "src/auth/service.ts", "start_line": 41, "end_line": 58, + "extractor": "typescript-ast", "extractor_version": "1.0.0" } + ], + "schema_version": "ri.v1" +} +``` + +### 14.2 Observed Python function with a decorator + +```json +{ + "kind": "node", + "node_kind": "symbol", + "stable_key": "app/api/routes/auth.py::login", + "name": "login", + "language": "python", + "truth_class": "observed", + "properties": { "decorators": ["router.post"] }, + "evidence": [ + { "path": "app/api/routes/auth.py", "start_line": 22, "end_line": 35, + "extractor": "python-ast", "extractor_version": "1.0.0" } + ], + "schema_version": "ri.v1" +} +``` + +### 14.3 Resolved import / route relationship + +```json +{ + "kind": "edge", + "edge_id": "edge:sha256:e20b7e1135e0535ffb7c19cb2066a0645d9e980d2071816ddfc967431b774807", + "subject": { "kind": "symbol", "stable_key": "app/api/routes/auth.py::login" }, + "predicate": "routes_to", + "object": { "kind": "symbol", "stable_key": "app/services/auth_service.py::AuthService.authenticate" }, + "truth_class": "resolved", + "producer": "route-resolver", + "producer_version": "1.0.0", + "evidence": [ + { "path": "app/api/routes/auth.py", "start_line": 21, "end_line": 21, + "extractor": "route-resolver", "extractor_version": "1.0.0" } + ], + "derived_from": [ + { + "kind": "observation", + "observation_id": "obs:sha256:86730689178079a960cf3019128882be518e92e7cdedb67d3dd4351f0201fc7e" + } + ], + "schema_version": "ri.v1" +} +``` + +The referenced observation is the observed route declaration, e.g.: + +```json +{ + "observation_id": "obs:sha256:86730689178079a960cf3019128882be518e92e7cdedb67d3dd4351f0201fc7e", + "observed_kind": "route", + "subject": { "kind": "symbol", "stable_key": "app/api/routes/auth.py::login" }, + "referent_text": "/login", + "ordinal": 1, + "evidence": { "path": "app/api/routes/auth.py", "start_line": 21, "end_line": 21, + "extractor": "python-ast", "extractor_version": "1.0.0" } +} +``` + +The observation ID above is computed under [§6.4](#64-observations-and-the-derived_from-reference-model) +against the immutable Git revision in [§14.8](#148-git-revision-identity). The edge ID is the +SHA-256 of its canonical relationship triple under [§5.3](#53-edge-identity-and-ids). + +### 14.4 Inferred architecture classification (must be labeled inferred) + +The entity exists exactly once as an observed node: + +```json +{ + "kind": "node", + "node_kind": "module", + "stable_key": "mod:app/services", + "name": "services", + "truth_class": "observed", + "evidence": [ + { + "path": "app/services/__init__.py", + "start_line": 1, + "end_line": 1, + "granularity": "file", + "extractor": "repository-inventory", + "extractor_version": "1.0.0" + } + ], + "schema_version": "ri.v1" +} +``` + +The classification is a separate inferred assertion: + +```json +{ + "kind": "assertion", + "assertion_id": "assertion:sha256:c081743f9923c3f5036ebda30de9be443deb58002b6bfa1102f388e64a024d57", + "subject": { "kind": "module", "stable_key": "mod:app/services" }, + "predicate": "classified_as", + "value": { "classification": "business-logic-layer", "confidence": "heuristic" }, + "truth_class": "inferred", + "producer": "architecture-classifier", + "producer_version": "1.0.0", + "derived_from": [ + { "kind": "node", "stable_key": "mod:app/services" }, + { + "kind": "edge", + "edge_id": "edge:sha256:e20b7e1135e0535ffb7c19cb2066a0645d9e980d2071816ddfc967431b774807" + } + ], + "schema_version": "ri.v1" +} +``` + +The classifier consumes the observed module node and resolved route edge directly, so those are its +immediate references. Following the edge reaches its route observation and exact line evidence. The +assertion never overwrites the observed node. Its ID is the verified hash of the identity document +defined in [§5.6](#56-inferred-property-assertions). + +### 14.5 Unresolved / ambiguous diagnostic (not a guessed edge) + +```json +{ + "code": "RI-RES-AMBIGUOUS", + "category": "ambiguous resolution", + "severity": "warning", + "message": "import 'utils' resolves to more than one candidate module", + "path": "src/app/index.ts", + "span": { "start_line": 3, "end_line": 3 }, + "producer": "import-resolver@1.0.0", + "subject": "file:src/app/index.ts", + "object": null, + "details": { + "observation_id": "obs:sha256:d8544c0e7ec142ab6d1cf98919657cf0d289d7f9490d9d03b55d8aab4fafe98c", + "candidates": ["src/app/utils.ts", "src/shared/utils/index.ts"] + } +} +``` + +The `observation_id` names the stored observed import occurrence that could not be resolved. No +edge is created ([§5.5](#55-unresolved-relationships)); the observation remains stored so a consumer +can still cite the unresolved import. + +The referenced observation is: + +```json +{ + "observation_id": "obs:sha256:d8544c0e7ec142ab6d1cf98919657cf0d289d7f9490d9d03b55d8aab4fafe98c", + "observed_kind": "import", + "subject": { "kind": "file", "stable_key": "file:src/app/index.ts" }, + "referent_text": "utils", + "ordinal": 1, + "evidence": { "path": "src/app/index.ts", "start_line": 3, "end_line": 3, + "extractor": "typescript-ast", "extractor_version": "1.0.0" } +} +``` + +Its ID is computed against the immutable Git revision in [§14.8](#148-git-revision-identity). + +### 14.6 Unsupported construct + +```json +{ + "code": "RI-EXT-UNSUPPORTED", + "category": "unsupported construct", + "severity": "info", + "message": "dynamic import() is outside the TypeScript support matrix", + "path": "src/plugins/loader.ts", + "span": { "start_line": 12, "end_line": 12 }, + "producer": "typescript-ast@1.0.0", + "subject": "file:src/plugins/loader.ts", + "object": null, + "details": { "construct": "dynamic-import" } +} +``` + +### 14.7 Upload revision identity + +```json +{ + "snapshot_id": "snap_9c2…", + "repository_id": "repo_7f3…", + "revision": { "kind": "upload", "value": "sha256:f16d05ec6b29248d2c61adb1e9263f78e4f7bace1b955014a2d17872cfe4064d", "ref": null }, + "schema_version": "ri.v1", + "producer_version_set": [ + "architecture-classifier@1.0.0", + "import-resolver@1.0.0", + "python-ast@1.0.0", + "reference-resolver@1.0.0", + "repository-inventory@1.0.0", + "route-resolver@1.0.0", + "typescript-ast@1.0.0" + ], + "config_hash": "sha256:48e96ba328a03db38556f22d2831d171b82e1ce9287c575328de4bc249da1abe" +} +``` + +The upload hash above is the SHA-256 of the illustrative UTF-8 fixture bytes `fixture`. + +### 14.8 Git revision identity + +```json +{ + "snapshot_id": "snap_1a4…", + "repository_id": "repo_7f3…", + "revision": { "kind": "git", "value": "9f1d0c7a2b6e4c5d8f3a1b0c7d9e2f4a6b8c0d1e", "ref": "refs/heads/main" }, + "schema_version": "ri.v1", + "producer_version_set": [ + "architecture-classifier@1.0.0", + "import-resolver@1.0.0", + "python-ast@1.0.0", + "reference-resolver@1.0.0", + "repository-inventory@1.0.0", + "route-resolver@1.0.0", + "typescript-ast@1.0.0" + ], + "config_hash": "sha256:48e96ba328a03db38556f22d2831d171b82e1ce9287c575328de4bc249da1abe" +} +``` + +### 14.9 Multiple evidence occurrences on one edge + +```json +{ + "kind": "edge", + "edge_id": "edge:sha256:90594a4734e993838e2db11f9d3bb5ede0cab2f1c70730ca8c4ab407c93bd69e", + "subject": { "kind": "symbol", "stable_key": "src/auth/service.ts::AuthService.login" }, + "predicate": "calls", + "object": { "kind": "symbol", "stable_key": "src/auth/tokens.ts::issueToken" }, + "truth_class": "resolved", + "producer": "reference-resolver", + "producer_version": "1.0.0", + "evidence": [ + { "path": "src/auth/service.ts", "start_line": 41, "end_line": 41, + "extractor": "reference-resolver", "extractor_version": "1.0.0" }, + { "path": "src/auth/service.ts", "start_line": 90, "end_line": 90, + "extractor": "reference-resolver", "extractor_version": "1.0.0" } + ], + "derived_from": [ + { + "kind": "observation", + "observation_id": "obs:sha256:ad475fae87c8121b76673172a560885335661f68ea90e4b5fa661be9b884f24a" + }, + { + "kind": "observation", + "observation_id": "obs:sha256:082048c4fd048043fbc22da52166e7fab6f37fa0f8803a1b3444b412b6ba1dd4" + } + ], + "schema_version": "ri.v1" +} +``` + +> The two call-site occurrences are two `observed_kind: "call"` observations (lines 41 and 90) that +> both resolve to the same callee, so they collapse to **one** `calls` edge with two evidence records +> and two `derived_from` observation references ([§5.4](#54-multiple-occurrences-and-multiple-evidence)). + +The referenced observations are: + +```json +[ + { + "observation_id": "obs:sha256:ad475fae87c8121b76673172a560885335661f68ea90e4b5fa661be9b884f24a", + "observed_kind": "call", + "subject": { "kind": "symbol", "stable_key": "src/auth/service.ts::AuthService.login" }, + "referent_text": "issueToken", + "ordinal": 1, + "evidence": { "path": "src/auth/service.ts", "start_line": 41, "end_line": 41, + "extractor": "typescript-ast", "extractor_version": "1.0.0" } + }, + { + "observation_id": "obs:sha256:082048c4fd048043fbc22da52166e7fab6f37fa0f8803a1b3444b412b6ba1dd4", + "observed_kind": "call", + "subject": { "kind": "symbol", "stable_key": "src/auth/service.ts::AuthService.login" }, + "referent_text": "issueToken", + "ordinal": 1, + "evidence": { "path": "src/auth/service.ts", "start_line": 90, "end_line": 90, + "extractor": "typescript-ast", "extractor_version": "1.0.0" } + } +] +``` + +Both IDs are computed against the immutable Git revision in [§14.8](#148-git-revision-identity). + +### 14.10 Generated narrative — explicitly excluded from deterministic facts + +```json +{ + "kind": "generated", + "truth_class": "generated", + "stored_in_graph": false, + "text": "Authentication is handled by AuthService.login, which issues a token via issueToken.", + "claims": [ + { "kind": "node", "stable_key": "src/auth/service.ts::AuthService.login" }, + { + "kind": "edge", + "edge_id": "edge:sha256:90594a4734e993838e2db11f9d3bb5ede0cab2f1c70730ca8c4ab407c93bd69e" + } + ] +} +``` + +> The narrative object above is **never written to the snapshot graph** (`stored_in_graph: false`) +> and is never a node or edge. Each `claim` MUST point at an `observed`/`resolved` fact with +> evidence; a claim with no resolvable span is not displayed as a fact ([§7.4](#74-generated-narrative), #95). + +--- + +## 15. Alternatives and consequences + +| Decision | Chosen | Rejected alternative | Why | +| --- | --- | --- | --- | +| **Storage model** | Immutable, sealed, revision-addressed snapshots ([§11](#11-snapshot-lifecycle-and-immutability)) | The current mutable JSON blob on `repo_metadata` | The blob is not queryable, not indexable, not versioned, and silently rewrites history on every re-analysis ([REPOSITORY_INTELLIGENCE.md](REPOSITORY_INTELLIGENCE.md)). Immutability is what makes any claim about provenance or diffs defensible. | +| **Node identity** | Deterministic stable keys ([§4](#4-node-identity-and-stable-keys)) | Random/autoincrement ids | Random ids are not comparable across snapshots or revisions; every downstream item (#88–#94) needs identity that is a pure function of the source. | +| **Repository node identity** | Snapshot-scoped constant `repo:root` ([§4.3](#43-canonical-stable-key-formats)) | Embed opaque `repository_id` as `repo:` | Repository IDs are generated database ownership keys. Embedding one would violate stable-key rules and make equivalent graph content hash differently after re-import. | +| **Inferred entity properties** | Separate deterministic property assertions ([§5.6](#56-inferred-property-assertions)) | Emit a second inferred node or mutate the observed node | One entity cannot have two existence records/truth classes. Assertions preserve one observed node and keep classifier output independently attributable and queryable. | +| **Evidence** | Path + line span + extractor/version ([§6](#6-provenance-contract)) | Path-only evidence (today's `evidence: [file_path]`) | Path-only cannot cite *where* in a file, cannot be validated against a revision, and cannot support #95's navigable claims. | +| **Legacy facts** | Retain as `legacy_unverified`, supersede by reanalysis ([§10.3](#103-legacy-regex-intelligence)) | Auto-migrate regex facts into the graph as `observed` | Regex facts have no spans and no extractor provenance; labeling them `observed` would be exactly the fabricated certainty this contract exists to prevent. | +| **Unresolved relationships** | Diagnostic, never a stored edge ([§5.5](#55-unresolved-relationships)) | Emit a best-guess edge | A guessed edge presented as fact destroys trust; #91 treats a false `observed` edge as release-blocking. | +| **Schema versioning** | Per-snapshot version, retained forever ([§9.6](#96-historical-snapshots-retain-their-version)) | One global mutable schema version | A global version cannot describe historical snapshots; immutable artifacts must carry the version they were built under. | +| **Occurrences** | One edge, multiple evidence ([§5.4](#54-multiple-occurrences-and-multiple-evidence)) | One edge per occurrence (parallel edges) | Parallel edges complicate canonicalization and de-dup with no consumer benefit; "does A call B?" is answered once, with citations. | +| **Columns** | Deferred to v2, reserved ([§6.2](#62-line-and-span-rules-decided-not-optional)) | Require columns in v1 | Line granularity is enough to prove the model end-to-end (#95); columns add extractor cost and hash surface without being needed for v1's acceptance bar. | +| **Duplicate/anonymous symbol discriminator** | Revision-local source-order ordinal ([§4.1](#41-principle-and-cross-revision-guarantees)) | Content-based semantic discriminator (signature/body hash) for cross-revision identity | The ordinal is cheap and deterministic within a revision; the semantic discriminator raises extractor cost/complexity (#89/#90) for a cross-revision benefit no v1 criterion needs, and would be a breaking `ri.v2` key change. Deferred, with evidence-comparison as the interim path. | +| **`derived_from` reference** | Same-snapshot tagged observation/node/edge/assertion references ([§6.4](#64-observations-and-the-derived_from-reference-model)) | Observation-only flattening, database ids, placeholders, or free text | Tagged canonical identities preserve immediate resolved/inferred dependencies and allow recursive tracing to source evidence; pre-seal resolution and cycle detection keep the chain valid. | +| **Pipeline identity** | Precomputed planned `producer_version_set` ([§3.3](#33-snapshot-identity)) | Record only producers discovered to have participated | Post-execution participation cannot support idempotent lookup before enqueue and omits enabled classifiers that legitimately produce no output on a particular revision. | +| **Canonical ordering** | Semantic tuple plus complete JCS bytes as final tie-breaker ([§12.3](#123-total-ordering-and-deduplication)) | Partial tuples with insertion order deciding ties | Partial tuples allow unequal evidence/diagnostics to compare equal. Complete bytes make ordering total and reproducible. | +| **Empty text files** | One logical empty line; whole-file evidence `1..1` ([§6.2](#62-line-and-span-rules-decided-not-optional)) | Treat as zero lines or make whole-file evidence invalid | A single exact convention keeps empty-file provenance valid and benchmarkable while binary/undecodable inputs remain explicit diagnostics. | + +### 15.1 Operational costs and limitations (stated honestly) + +- **Storage grows per revision.** Immutable snapshots mean re-analysis stores a new graph rather + than overwriting. This is the deliberate cost of reproducibility; retention/pruning policy is out + of scope for this RFC and will be its own issue. +- **Reanalysis is required to benefit from a better extractor.** Because facts are never upgraded in + place ([§7.3](#73-upgrades-retention-labeling)), an improved extractor helps only new snapshots + until old repositories are re-analyzed. +- **Determinism constrains producers.** Extractors, resolvers, and classifiers must be deterministic and must not + embed timestamps, absolute paths, or nondeterministic ordering, or the canonical hash + ([§12](#12-canonical-graph-hash)) destabilizes. This is a real implementation constraint on #89–#91. +- **`ri.v1` is line-granular only.** No columns, no cross-file type inference beyond documented + resolution, no non-TS/Python deep extraction. These are declared gaps, not hidden ones, consistent + with [REPOSITORY_INTELLIGENCE.md](REPOSITORY_INTELLIGENCE.md). +- **Duplicate/anonymous symbol identity is revision-local.** The `#` and `(anonymous:…#)` + discriminators are assigned by source order, so they are stable within a revision but do not track + the same entity across revisions ([§4.1](#41-principle-and-cross-revision-guarantees)). The + considered-but-**deferred** alternative is a **content-based semantic discriminator** — hashing a + normalized signature (parameter arity/types for overloads, a normalized body digest for anonymous + symbols) instead of a source-order ordinal. It was deferred from `ri.v1` because it materially + raises extractor cost and language-specific complexity (#89/#90) for a benefit — cross-revision + matching of duplicate/anonymous symbols — that no v1 acceptance criterion requires; consumers that + need it use evidence (path + span) comparison in the interim. Introducing it later is a breaking + key-format change and therefore an `ri.v2` concern ([§9.2](#92-breaking-changes-require-riv2)). +- **This RFC changes no runtime behavior.** The system behaves as documented today until each + downstream implementation lands; this document records the *accepted contract*, not a claim that + every capability is current behavior. + +--- + +## 16. Dependency gate + +This RFC records the exact sequencing rule for the intelligence track (the comment on #86 is +binding). The governance gate now has the required independent approval, and this final pre-merge +update records that approval in the contract: + +- **#87, #88, #89, #90, #91, #92, #93, and #95 may proceed against the accepted contract**, while + continuing to honor the dependency order below and their own acceptance criteria. +- **#94 fixture construction was permitted before approval** — writing expected facts down first is + a genuine test of whether the support matrix is coherent. +- **#94 scoring and provenance validation depend on the accepted contract** (they need the + evidence-record definition in [§6](#6-provenance-contract) and the canonical hash in + [§12](#12-canonical-graph-hash)). +- **Contract acceptance and implementation status are distinct.** Approval permitted downstream + work; it did not make that work current product behavior. The durable pipeline and product + consumer migration have since landed, while + [§17](#17-current-behavior-vs-accepted-contract-vs-implementation-status) remains the authority + for implemented and outstanding contract capabilities. + +Dependency order (from the #86 comment): #87 → #88 → {#89, #90} → #91 → #92 → #93 (on #88); #94 in +parallel (fixtures early, scoring after approval); #95 last (on #92 and #94). + +--- + +## 17. Current behavior vs. accepted contract vs. implementation status + +To keep this document honest about what exists (per [docs/README.md](../README.md) documentation +rules), the three columns are explicit: + +| Concern | Current behavior (today) | Accepted `ri.v1` contract (this RFC) | Status | +| --- | --- | --- | --- | +| Storage | Sealed snapshot tables are the sole product read model; historical mutable JSON may remain stored but is ignored | Immutable sealed snapshots with nodes, edges, assertions, observations, evidence, and diagnostics (§11) | **Implemented** (#88/#171) | +| Pipeline identity | Durable submission and execution share a fixed planned producer set and config hash before enqueue | Precomputed `producer_version_set` covers every enabled extractor/resolver/classifier (§3.3) | **Implemented** (#88/#93) | +| Repository graph key | Durable analysis runs `ExtractionPipeline`, which emits deterministic `repo:root`; `SnapshotStore` validates exactly one before sealing | Deterministic snapshot-scoped `repo:root`; database `repository_id` excluded from graph keys (§4.3) | **Implemented** (#88–#90/#93) | +| Symbol spans | Python/TypeScript extractors emit and durable analysis stores required spans | Required line spans (§6) | **Producer and durable population implemented** (#89/#90/#93) | +| Extraction | Durable analysis runs the AST/tree-sitter and dependency-manifest extractors and writes no legacy compatibility model | Syntax-aware extractors with support matrices | **Durable product integration implemented** (#89/#90/#93/#171) | +| Revision identity | Indexed `revision_kind`/`revision_value`/`revision_ref`; `commitSha` is API compatibility only | Indexed immutable columns (§3) | **Implemented** (#87) | +| Relationships | The durable job runs the deterministic resolver over stored observations, with resolved edges and explicit unresolved/ambiguous diagnostics | Resolved edges + diagnostics (§5) | **Implemented** (#91/#93) | +| Inferred entity properties | Heuristic roles are stored as explicit `classified_as` assertions and remain labelled inferred | Separate inferred property assertions; observed nodes remain unique (§5.6) | **Implemented** (#95/#171) | +| Provenance | Durable analysis stores extractor path + span + producer/version and the normalized store validates them; structural consumers disclose when they do not expose source evidence | Path + span + extractor/version (§6) | **Producer, persistence, querying, and durable population implemented** (#88–#93/#171) | +| Query API | Every product consumer uses the versioned owner-scoped current-revision read boundary with no fallback | Versioned owner-scoped read API (§9.5) | **Implemented and durably populated** (#92/#93/#171) | +| Evidence-backed output | AI emits empty citation lists | Every claim cites a valid span (§7.4) | **Unimplemented** (#95) | + +**This RFC does not claim every capability in the "Accepted contract" column is current product +behavior.** RFC-0001 is **Accepted** — independently ratified by +[@SHAURYAKSHARMA24](https://github.com/SHAURYAKSHARMA24) on 2026-07-16 +([§1](#1-status-and-approval)); acceptance records the governing contract, not a claim of +implementation. The #87 revision identity and #88 immutable snapshot-persistence boundary are +implemented against that accepted contract, as the status column records. The +#89/#90 producers, #94 benchmark, #91 deterministic resolution, #92 +sealed-snapshot queries, #93 durable job population, and subsequent +product-consumer migration are implemented. The unimplemented evidence-backed +AI output row remains an explicit gap. No normative requirement is weakened by +this implementation-status update. + +--- + +## 18. Decision matrix — acceptance criteria and dependency requirements → RFC section + +### 18.1 Issue #86 acceptance criteria + +| #86 acceptance criterion | Satisfied by | +| --- | --- | +| Node/edge identity (stable-key format) specified; assertion identity also specified | [§4](#4-node-identity-and-stable-keys), [§5](#5-edgefact-identity) | +| Provenance record specified: path, start line, end line, extractor, extractor version | [§6.1](#61-the-evidence-record), [§6.2](#62-line-and-span-rules-decided-not-optional) | +| Truth classes defined, with rules for which extraction paths may emit each | [§7](#7-truth-classes), emission matrix [§7.2](#72-emission-matrix-producer--truth-class) | +| Schema versioning and migration policy specified | [§9](#9-schema-versioning), [§10](#10-migration-policy) | +| Immutability rules for completed snapshots specified | [§11](#11-snapshot-lifecycle-and-immutability) | +| Diagnostics model (unsupported construct, ambiguous resolution, extraction failure) specified | [§8](#8-diagnostics-model) | +| Accepted as an ADR/RFC with recorded independent-maintainer approval | [§1](#1-status-and-approval) — approved by [@SHAURYAKSHARMA24 in Issue #86](https://github.com/Second-Origin/PARTHA/issues/86#issuecomment-4990687780) and in the [PR #101 review](https://github.com/Second-Origin/PARTHA/pull/101#pullrequestreview-4712687647) on 2026-07-16 | + +### 18.2 Required RFC decisions (issue body §1–§15) and #86-comment dependency requirements + +| Requirement | RFC section | +| --- | --- | +| 1. Status/number/issue/authors/dates/status/approval rule | [§1](#1-status-and-approval) | +| 2. Normative terminology (MUST/…); snapshot, fact, node, edge, assertion, observation, evidence, provenance, diagnostic, producer, schema/producer version | [§2](#2-normative-terminology) | +| 3. Revision & snapshot identity (git SHA + ref; upload sha256; moving names; precomputed planned producer set; composite identity; reanalysis; reuse; #87 migration) | [§3](#3-revision-and-snapshot-identity) | +| 4. Node identity & stable keys (`repo:root`; snapshot uniqueness; all node types; path normalization; `.`/`..`; symlinks/escape; case; Unicode; qualified/nested/overloads/anonymous; language namespace; external deps; collisions; TS/Python examples) | [§4](#4-node-identity-and-stable-keys) | +| 5. Edge/assertion identity (relationship and assertion predicates; subject/object/value; deterministic IDs; snapshot scope; unique entity nodes; multiple evidence; ordering/dedup; #91 relationships) | [§5](#5-edgefact-identity) | +| 6. Provenance contract (min record; logical line count and empty files; one-based inclusive spans; binary/undecodable behavior; whole-file; columns deferred; multiple evidence; tagged acyclic derivation; revision tie; no provenance ⇒ not observed) | [§6](#6-provenance-contract) | +| 7. Truth classes (definitions; producers; no upgrade; retention; API/UI labeling; generated never stored; unresolved ⇒ diagnostic; emission matrix) | [§7](#7-truth-classes) | +| 8. Diagnostics model (all listed categories; required fields; which fail a snapshot) | [§8](#8-diagnostics-model) | +| 9. Schema versioning (accepted `ri.v1`; compatible additions; breaking; when v2; schema vs producer version; API versioning #92; reader rejection/negotiation; historical retention) | [§9](#9-schema-versioning) | +| 10. Migration policy (DB migrations; backfills; rollback; cross-version; re-extraction vs transformation; legacy regex facts; failure/recovery) | [§10](#10-migration-policy) | +| 11. Snapshot lifecycle & immutability (states; seal transaction; producer coverage; entity uniqueness; derivation resolution/cycle rejection; total-order normalization; immutability; rejection; corrections ⇒ new snapshot; failed; #93 cancel/retry; concurrency/idempotency) | [§11](#11-snapshot-lifecycle-and-immutability) | +| 12. Canonical graph hash (five arrays; planned producer inputs; total ordering with JCS tie-breakers; evidence/derivation/property ordering; diagnostics/details; excluded volatile ids; determinism) | [§12](#12-canonical-graph-hash) | +| 13. Security & ownership (owner-scoped queries; no traversal; repo-relative paths; no secret/content logging; no working-tree reads; no independent parsers) | [§13](#13-security-and-ownership) | +| 14. Examples (all ten required) | [§14](#14-normative-examples) | +| 15. Alternatives & consequences (all six listed; operational costs) | [§15](#15-alternatives-and-consequences) | +| Dependency gate (accepted contract governs #87–#93 and #95; #94 fixtures were permitted early; #94 scoring uses the accepted contract) | [§16](#16-dependency-gate) | +| #87 — revision identity & migration of `commitSha` | [§3.2](#32-revision-identity), [§3.5](#35-migration-of-the-existing-commitsha-handled-by-87) | +| #88 — immutable snapshot persistence, node/edge/assertion uniqueness & canonical hash | [§4.1](#41-principle-and-cross-revision-guarantees), [§5.6](#56-inferred-property-assertions), [§11](#11-snapshot-lifecycle-and-immutability), [§12](#12-canonical-graph-hash) | +| #89 — TypeScript extraction (spans, stable keys, support matrix, diagnostics) | [§4](#4-node-identity-and-stable-keys), [§6](#6-provenance-contract), [§7.1](#71-definitions-and-emission-rules), [§8](#8-diagnostics-model) | +| #90 — Python extraction (decorators/routes, spans, shared interface) | [§4](#4-node-identity-and-stable-keys), [§6](#6-provenance-contract), [§8](#8-diagnostics-model), example [§14.2](#142-observed-python-function-with-a-decorator) | +| #91 — deterministic relationship resolution (contains/defines/imports/calls/routes_to/depends_on/implements; immediate producer; tagged derivation; resolved class; diagnostics) | [§5.1](#51-canonical-predicates), [§5.5](#55-unresolved-relationships), [§6.4](#64-observations-and-the-derived_from-reference-model), [§7](#7-truth-classes) | +| #92 — versioned owner-scoped query API for nodes, edges, assertions, derivations & evidence | [§5.6](#56-inferred-property-assertions), [§9.5](#95-api-response-versioning-92-and-negotiation), [§13](#13-security-and-ownership) | +| #93 — durable job lifecycle & snapshot sealing | [§11.2](#112-sealing-transaction-and-pre-seal-validation), [§11.4](#114-failed-extraction-cancellation-retry-93-interaction), [§11.5](#115-concurrency-and-idempotency) | +| #94 — golden benchmark (logical-line/empty-file vectors, provenance validity, total canonical ordering & hashing) | [§6.2](#62-line-and-span-rules-decided-not-optional), [§6.3](#63-multiple-evidence-resolvedinferred-evidence-and-the-revision-tie), [§12](#12-canonical-graph-hash), [§16](#16-dependency-gate) | +| #95 — first evidence-backed consumer | [§7.4](#74-generated-narrative), [§13](#13-security-and-ownership), example [§14.10](#1410-generated-narrative--explicitly-excluded-from-deterministic-facts) | + +--- + +## 19. References + +- [`apps/backend/app/intelligence/models.py`](../../apps/backend/app/intelligence/models.py) — retained historical compatibility types; not the product read model. +- [`apps/backend/app/extraction/typescript.py`](../../apps/backend/app/extraction/typescript.py) — syntax-aware TypeScript producer used by durable analysis. +- [`apps/backend/app/extraction/pipeline.py`](../../apps/backend/app/extraction/pipeline.py) — repository-level source policy and producer dispatch. +- [`apps/backend/app/services/repository_service.py`](../../apps/backend/app/services/repository_service.py) — immutable revision identity and service use of the owner-scoped repository accessor. +- [`apps/backend/app/repositories/repository_repository.py`](../../apps/backend/app/repositories/repository_repository.py) — owner-scoped `RepositoryRepository.get_for_owner`. +- [`apps/backend/app/github/client.py`](../../apps/backend/app/github/client.py) — `read_head_commit` (`git rev-parse HEAD`). +- [Repository Intelligence](REPOSITORY_INTELLIGENCE.md) — current deterministic-vs-heuristic behavior; the informal ancestor of the truth classes. +- [System Overview](SYSTEM_OVERVIEW.md) — current components, persistence, trust boundaries. +- [docs/README.md](../README.md) — documentation rules. +- [CONTRIBUTING.md](../../CONTRIBUTING.md) — contribution workflow, migrations, architectural rules. +- Issues [#86](https://github.com/Second-Origin/PARTHA/issues/86) (this RFC) and [#87](https://github.com/Second-Origin/PARTHA/issues/87)–[#95](https://github.com/Second-Origin/PARTHA/issues/95) (downstream track). diff --git a/docs/architecture/REPOSITORY_LINEAGE_MIGRATION_PLAN.md b/docs/architecture/REPOSITORY_LINEAGE_MIGRATION_PLAN.md new file mode 100644 index 00000000..6686c8d1 --- /dev/null +++ b/docs/architecture/REPOSITORY_LINEAGE_MIGRATION_PLAN.md @@ -0,0 +1,631 @@ +# Repository Lineage — Alembic Migration & Implementation Plan + +| Field | Value | +| --- | --- | +| Planning issue | [#299](https://github.com/Second-Origin/PARTHA/issues/299) | +| Governing design | [RFC-0002](REPOSITORY_LINEAGE_RFC.md) | +| Live-code baseline | `origin/dev` at `373306d` | +| Purpose | Specify the proposed migration mechanics, backfill, integrity rules, and validation for #299 | +| Runtime changes in this document | None | +| Authorization status | **PR #328 amendment approved; #299 authorized for implementation** | + +This document is an implementation-grade plan, not an implementation. It does not create an +Alembic revision, change an ORM model, change `RepositoryService`, or alter an API/frontend +contract. It records the current schema and specifies a safe migration and backfill. RFC-0002 is the +architecture contract; this plan defines the migration and test mechanics without extending it. + +## 1. Executive verdict + +**#299 AUTHORIZED FOR IMPLEMENTATION.** + +The owner explicitly approved the architecture amendment in +[PR #328 review](https://github.com/Second-Origin/PARTHA/pull/328#pullrequestreview-4975035985). +The migration can now be written and tested against these two implementation-critical decisions: + +1. Uploads and unresolved legacy GitHub rows are **unlineaged standalone imports**: their lineage + fields are null and no synthetic lineage exists. +2. A race-free, non-reusing sequence needs durable allocation state. RFC-0002 now specifies + `repository_lineages.next_sequence`, 1-based ordinals, transactional allocation, valid deletion + gaps, and preservation of empty lineages. + +This authorization does not mean #299 has been implemented. The #322 rehearsal and recovery +process remains required against the eventual migration before the #299 implementation PR merges, +but it does not block writing or testing that implementation. No runtime or migration +implementation is present here. + +## 2. Current-state data model + +### 2.1 What a `RepositoryRecord` means today + +One `repositories` row is primarily **one imported immutable revision**, but it also owns the +mutable import/analysis workspace for that revision: + +- GitHub: `revision_kind = 'git'`, `revision_value` is the 40-character lowercase commit SHA, and + `revision_ref` is a resolved `refs/heads/...` or `refs/tags/...` value. +- Upload: `revision_kind = 'upload'`, `revision_value` is the hash of the uploaded archive bytes, + and `revision_ref` is `NULL`. +- The row owns a distinct `local_path`, parsed `file_tree`, parser metadata, size/count fields, and + mutable analysis status/progress/timestamps. +- An import attempt that fails before insertion is not a row. An exact duplicate rejected by the + service is not another import-event row. The table is therefore neither a pure logical + repository nor an import-event log. + +The right description is: **one persisted imported revision plus its revision-local workspace and +lifecycle state**. RFC-0002 correctly needs a separate logical grouping above it. + +### 2.2 Relevant tables + +#### `users` + +Introduced by `0002_users_and_repo_owner`; `password_hash` was added by `0003_auth_credentials`. + +- PK: `id` (`String(36)`). +- Unique/index: `email` (`String(320)`) through unique index `ix_users_email`. +- Non-null: `id`, `email`, `is_active`, `created_at`, `updated_at`. +- Nullable: `password_hash` (the seed user deliberately cannot authenticate). +- Ownership root: repositories and other owner-scoped tables ultimately cascade from `users.id`; + `0010_account_deletion` finalized the explicit database cascades. + +#### `repositories` + +Introduced by `0001_initial`; `owner_id` by `0002`; revision columns/constraints by `0005`; the +placeholder `data_source` was removed by `0007`; owner cascade was normalized by `0010`. + +- PK: `id` (`String(36)`). +- FK: `owner_id -> users.id`, named `fk_repositories_owner_id_users`, `ON DELETE CASCADE`. +- Required fields: `id`, `owner_id`, `name`, `source`, `local_path`, `size`, `file_count`, `status`, + `analysis_progress`, `uploaded_at`, `file_tree`, `created_at`, `updated_at`. +- Nullable fields: `description`, `source_url`, `branch`, all three revision fields (to preserve + unidentified legacy rows), `analysis_stage`, `analysed_at`, `error_message`, `repo_metadata`. +- Unique: `uq_repositories_id_revision(id, revision_kind, revision_value)`. Because `id` is already + the PK, this is principally the composite FK target used by snapshots/jobs; it does not dedupe + commits across repository rows. +- Checks: revision kind; all-or-none revision completeness; exact upload hash shape; exact Git SHA + and `refs/...` shape. +- Indexes: owner, name, source, status, and `ix_repositories_revision_value`. +- There is no unique database constraint for current service-level GitHub or upload duplicate + detection. + +`source_url`, `branch`, and `repo_metadata` are descriptive/mutable import data. Only the typed +revision columns are revision identity. Historical `repo_metadata['intelligence']` can remain, but +all executable Repository Intelligence consumers use sealed snapshots. + +#### `ri_snapshots` and Repository Intelligence persistence + +Introduced by `0005_revision_snapshots`. + +- `ri_snapshots` PK: `snapshot_id` (`String(48)`). +- Composite FK: + `(repository_id, revision_kind, revision_value) -> repositories(id, revision_kind, + revision_value)`, named `fk_ri_snapshots_repository_revision`, `ON DELETE CASCADE`. +- Snapshot identity/lifecycle fields are non-null except `revision_ref`, graph hash, actual + producers, failure code, and sealed timestamp as allowed by state. +- Indexes include repository lookup, repository+revision lookup, and the partial unique completed + semantic identity over `(repository_id, revision_value, schema_version, producer_set_hash, + config_hash)`. +- `ri_nodes`, `ri_edges`, `ri_assertions`, `ri_observations`, `ri_evidence`, `ri_derivations`, and + `ri_diagnostics` all refer to `ri_snapshots.snapshot_id` with cascade deletion. Their + same-snapshot composite constraints prevent cross-snapshot fact leakage. + +All normalized child tables were introduced by `0005`; the two directional edge indexes were added +by `0008`: + +- `ri_nodes`: integer PK `id`; all fields required except `name`, `language`, and `properties`; + unique `(snapshot_id, stable_key)` and `(snapshot_id, id)`; snapshot index; partial unique root + index; snapshot FK cascades. +- `ri_edges`: integer PK `id`; all fields required; unique snapshot edge ID, resolved triple, and + `(snapshot_id, id)`; both endpoint composite FKs resolve to nodes in the same snapshot and + cascade; indexes on snapshot and both `(snapshot, endpoint, predicate)` directions. +- `ri_assertions`: integer PK `id`; all fields required; unique snapshot assertion ID and + `(snapshot_id, id)`; same-snapshot subject-node FK cascades; snapshot index. +- `ri_observations`: integer PK `id`; only `referent_text` nullable; unique snapshot observation ID + and `(snapshot_id, id)`; same-snapshot subject-node FK cascades; positive ordinal check and + snapshot index. +- `ri_evidence`: integer PK `id`; exactly one of `node_ref`, `edge_ref`, or `observation_ref` is + non-null; all span/extractor fields required; same-snapshot composite parent FKs cascade; indexes + for snapshot and each parent; three partial unique fact indexes prevent duplicate evidence for + each parent kind. +- `ri_derivations`: integer PK `id`; exactly one of `edge_ref`/`assertion_ref` is non-null; + `ref_kind` and `ref_identity` required; same-snapshot composite parent FKs cascade; snapshot index + and two partial unique derivation indexes. +- `ri_diagnostics`: integer PK `id`; path/span/subject/object/details nullable, all diagnostic + identity/message/producer fields required; snapshot FK cascades; snapshot index and severity/span/ + relative-path checks. + +Lineage does not change any of these keys. A snapshot remains an immutable artifact of one +repository row/revision. + +#### `analysis_jobs` + +Introduced by `0006_analysis_jobs`. + +- PK: `id` (`String(36)`). +- Composite repository-revision FK with `ON DELETE CASCADE`. +- `owner_id -> users.id` with `ON DELETE CASCADE`. +- Optional `snapshot_id -> ri_snapshots.snapshot_id` with `ON DELETE SET NULL`. +- Required identity/state includes repository, owner, revision kind/value, config hash, status, + progress, attempts, cancellation flag, and timestamps. +- Partial unique indexes protect one effective job identity and one non-null snapshot association. + +Jobs remain revision-scoped, not lineage-scoped. + +#### Other direct repository identity references + +- `ai_conversation_messages` (`0009`): PK `id`; owner FK and repository FK both cascade; required + integer `sequence`; unique `(owner_id, repository_id, sequence)`. Conversation history remains + attached to one imported revision. +- Account deletion (`0010`): `users` deletion cascades repository rows, which cascade their + snapshots, jobs, and AI turns. The non-PII `account_deletion_audits` table deliberately has no + user FK. +- `ai_provider_configs` and `refresh_tokens` are owner-scoped but do not reference repository + identity. + +## 3. RFC-0002 to live-code mapping + +| RFC requirement | Status at baseline | Mapping / discrepancy | +| --- | --- | --- | +| A repository row is one immutable imported revision | Implemented | Typed revision columns and snapshot/job composite FKs implement this. The row also owns mutable revision-local lifecycle/workspace state. | +| Durable logical lineage above revisions | Absent | No lineage model, table, repository FK, query, or API field exists. | +| Key `(owner_id, canonical_source_key, canonical_branch)` | Absent | Current dedupe uses exact stored `source_url`, commit, owner. No canonical source field exists. | +| GitHub URL normalization accepts HTTPS/SSH variants | Incompatible/partial | Live imports accept only lowercase-host public HTTPS URLs. RFC examples also include mixed-case host and `git@github.com:` syntax. Backfill can normalize only strictly recognized historical values; live validation must not be broadened implicitly by #299. | +| Branch-scoped identity | Partial input exists | `revision_ref` is normalized to `refs/heads/...` or `refs/tags/...`; `branch` is only the requested input. The canonical component must be `revision_ref` verbatim, not `branch`. The RFC calls this `canonical_branch` even when it is a tag. | +| Upload/unresolved-ref imports are unlineaged standalone imports | Defined by amended RFC | §4.3/§6 require no lineage row and null repository lineage fields. Live GitHub imports reject an unresolved ref, so only legacy GitHub rows can hit that case. | +| `repository_lineages` fields in §5.1 | Absent from code; defined by amended RFC | The architecture includes the durable counter, canonical-pair check, and same-lineage latest-pointer integrity implemented by this plan. | +| Partial unique canonical key | Defined by amended RFC | The canonical pair check and non-null partial uniqueness are architecture; this plan supplies exact DDL and migration ordering. | +| `repositories.lineage_id` and `sequence` | Absent | They must remain nullable permanently if §4.3/§6 is followed, not merely during expansion. | +| Monotonic sequence | Defined by amended RFC | Starts at 1, is allocated transactionally from `next_sequence`, is unique per lineage, and is never reused; deletion gaps are valid. | +| `latest_repository_id` rolls back on deletion | Incompatible with simple FK alone | `SET NULL` can clear the pointer but cannot choose the next-highest sequence. Service-level deletion must update it before deleting the revision, in the same transaction. | +| Earliest name and created-at ordering | Partial data exists | Both columns exist, but timestamp ties require `id` as a deterministic secondary sort key. | +| Idempotent backfill | Partially specified | Grouping inputs are reproducible, but random lineage IDs/inserts are not. Use a documented UUIDv5 namespace for backfill lineage IDs plus upsert/reconciliation logic. | +| No API/frontend change | Compatible | Lineage fields can remain internal. Existing response schemas need no change. | +| Snapshot immutability/identity unchanged | Compatible | Snapshots continue pointing to repository rows. | + +### Architecture amendments captured in RFC-0002 + +1. **Standalone semantics.** The RFC now uses “unlineaged standalone import” and states that no + synthetic lineage exists. +2. **Sequence state.** The RFC now requires a durable per-lineage counter, 1-based never-reused + ordinals, transactional allocation, and valid deletion gaps. +3. **Latest-pointer and owner integrity.** The RFC now requires the composite membership and + latest-member foreign keys; this plan supplies their exact names and migration order. +4. **Canonical-pair integrity.** The RFC now requires both canonical fields to be null or non-null + together; this plan supplies the check and partial-index mechanics. +5. **Repeatable backfill.** The RFC requires deterministic identity and reconciliation; this plan + owns the UUID construction, verification, interruption recovery, and downgrade mechanics. + +## 4. RFC target schema + +### 4.1 `repository_lineages` + +| Column | Type/nullability | Rule | +| --- | --- | --- | +| `id` | `String(36)`, PK, non-null | UUID4 for new live lineages; deterministic UUID5 for backfill. | +| `owner_id` | `String(36)`, non-null | FK to `users.id`, `ON DELETE CASCADE`. | +| `canonical_source_key` | `Text`, nullable | Strict canonical GitHub key, e.g. `github.com/acme/widgets`; null only for a future manually created upload lineage. | +| `canonical_branch` | `Text`, nullable | Exact normalized `revision_ref`, including `refs/heads/` or `refs/tags/`. | +| `display_name` | `Text`, non-null | Permanently copied from the first repository row. | +| `latest_repository_id` | `String(36)`, nullable | Current highest surviving sequence; null for an empty lineage. | +| `next_sequence` | `Integer`, non-null | RFC-required next never-issued ordinal; initial value 1; check `>= 1`. | +| `created_at` | timezone-aware `DateTime`, non-null | Earliest member's `created_at` for backfill; current UTC for live creation. | + +Constraints/indexes: + +- `uq_repository_lineages_id_owner(id, owner_id)` as the composite ownership FK target. +- `ck_repository_lineages_canonical_pair`: source key and canonical branch are either both null or + both non-null. +- partial unique index + `uq_repository_lineages_owner_source_branch(owner_id, canonical_source_key, canonical_branch)` + where both canonical fields are non-null. This is also the owner-scoped matching index. +- an index on `owner_id` for owner deletion/listing where the canonical lookup index is not used. +- a same-lineage latest-pointer FK described below. + +Do not add provider type, GitHub numeric repository ID, upload hash, mutable source metadata, or a +JSON metadata bag. Current code does not provide a reliable provider-stable ID and RFC-0002 does +not authorize those fields. + +### 4.2 New `repositories` columns and constraints + +- `lineage_id String(36) NULL`. +- `sequence Integer NULL`. +- `ck_repositories_lineage_sequence_pair`: both are null or both are non-null, and a non-null + sequence is at least 1. +- `uq_repositories_lineage_sequence(lineage_id, sequence)`. Because SQL uniqueness permits multiple + null pairs, standalone rows do not collide. This index also serves ordered lineage reads. +- `uq_repositories_id_lineage(id, lineage_id)` as the target that proves a latest pointer belongs + to the named lineage. +- composite ownership FK + `(lineage_id, owner_id) -> repository_lineages(id, owner_id)`, named + `fk_repositories_lineage_owner`, deferrable/initially deferred, with no automatic delete action. + This makes cross-owner attachment impossible at the database layer. +- composite latest-member FK + `repository_lineages(latest_repository_id, id) -> repositories(id, lineage_id)`, named + `fk_repository_lineages_latest_member`, deferrable/initially deferred, with no automatic delete + action. This prevents a latest pointer to another lineage or owner. + +These RFC-required cyclic FKs are intentional and require ordered writes: insert a lineage with a +null latest pointer, insert/attach the repository, then set latest; on deletion, set latest to the +replacement or null before deleting the repository. Deferral permits these operations in one transaction. +Both dialects must be tested; if SQLite's batch/reflection path cannot preserve the deferrable +composite constraints, implementation must stop rather than silently weaken owner integrity. + +`lineage_id` and `sequence` remain nullable at final head because RFC §4.3/§6 explicitly keeps +uploads and unidentified legacy rows as unlineaged standalone imports. They must not be tightened +to `NOT NULL` by #299. + +### 4.3 Sequence semantics + +- First repository in a lineage has `sequence = 1`. +- A sequence is a never-reused import ordinal within that lineage, not commit time and not Git + ancestry. +- Deleting a revision does not decrement `next_sequence` and does not renumber survivors. +- `latest_repository_id` means the surviving member with greatest sequence, not the greatest + timestamp and not necessarily an analyzed/completed member. +- Backfilled sequences are dense only at migration time, ordered by `(created_at ASC, id ASC)`. + +The RFC makes the ordinal 1-based, matching its “third import” language and avoiding a zero ordinal +in future internal tooling. + +## 5. Concurrency design + +### 5.1 Options evaluated + +| Approach | PostgreSQL | SQLite/test | Failure/retry | Assessment | +| --- | --- | --- | --- | --- | +| Unlocked `MAX(sequence)+1` | Two transactions can choose the same value. | Same logical race; writer upgrade can also raise busy errors. | Unique conflict catches damage but requires full retry. | Unsafe as the primary allocator. | +| `SELECT ... FOR UPDATE` on lineage, then `MAX+1` | Correctly serializes existing-lineage allocation. | SQLite ignores row-level `FOR UPDATE`; database writer locking occurs later. | First-lineage creation still needs unique-conflict reconciliation. Highest-number deletion permits reuse. | Better on PostgreSQL, incomplete cross-dialect. | +| Retry only on `(lineage_id, sequence)` conflict | Correct if bounded and the transaction is fully retried. | Matches the existing AI-turn pattern, but can amplify lock contention. | Correctness comes from unique constraint; repeated conflicts eventually fail. | Viable fallback/defense, not deterministic allocation by itself. | +| Serializable transactions | Correct with serialization-failure retries. | SQLite semantics differ and writer contention is database-wide. | Every serialization failure requires transaction retry. | Excessive scope/complexity for one counter. | +| Global database sequence | Race-free but not per-lineage/dense. | SQLite has no equivalent PostgreSQL sequence object. | Creates gaps and non-portable behavior. | Reject. | +| Per-lineage `next_sequence` updated transactionally | Row update locks exactly one lineage. | The first update obtains SQLite's serialized writer lock; configured busy timeout bounds waiting. | Rollback restores the counter; unique constraint remains defense in depth. | **Selected by RFC-0002.** | + +### 5.2 Allocation algorithm + +After cloning/upload extraction and parsing succeed, perform all lineage and repository database +writes in one transaction: + +1. Find the lineage by all three owner-scoped canonical key columns. +2. If absent, insert it with `next_sequence = 1` and null latest pointer. The partial unique index + is the authority if two creators race. +3. Atomically update that one lineage row: + + ```sql + UPDATE repository_lineages + SET next_sequence = next_sequence + 1 + WHERE id = :lineage_id AND owner_id = :owner_id; + ``` + + Verify exactly one row changed, then read the row in the same transaction and allocate + `sequence = next_sequence - 1`. The write lock remains held through commit on both databases. + `UPDATE ... RETURNING` may be used only if the supported SQLite version is explicitly pinned and + tested; update-then-read is portable. +4. While holding that serialization point, check whether this lineage already contains the same + `revision_value`. If so, roll back and return the existing 409 behavior. This closes the current + simultaneous same-commit race without requiring a backfill-breaking unique revision constraint. +5. Insert the repository with the allocated lineage/sequence and update the latest pointer. +6. Commit once. Any failure rolls back lineage creation, counter allocation, repository insertion, + and latest-pointer change together. + +If two transactions race to create the first canonical lineage, one wins the canonical-key unique +index. The loser rolls back, reloads the winning lineage by the owner-scoped key, and retries the +database phase a bounded number of times (five is consistent with `AiConversationRepository`). Only +the expected canonical-key or sequence constraint is reconciled; unrelated `IntegrityError`s are +re-raised. + +The current `RepositoryRepository.add()` commits immediately, so #299 must add a transaction-aware +repository/lineage persistence path rather than composing existing `add()` calls. This is a scoped +requirement, not a general repository-layer refactor. + +### 5.3 Filesystem/database failure boundary + +Cloning/extraction/parsing remains outside the database transaction to avoid holding a DB lock +during slow I/O. The final DB phase owns no external side effect. If it fails—including a duplicate +won by another request—the newly staged repository directory must be removed, extending the +current pre-insert cleanup across the commit phase. A database commit that succeeds followed by an +HTTP serialization failure must not remove the committed repository directory. + +## 6. Backfill design + +### 6.1 Strict GitHub canonicalization + +Backfill only a row satisfying all of these conditions: + +- `source = 'github'`; +- `revision_kind = 'git'` with a valid existing revision value; +- `revision_ref` is a valid non-empty `refs/heads/...` or `refs/tags/...` value; and +- `source_url` parses as one of the RFC-supported, unambiguous GitHub repository forms: + `https://github.com//[.git]` (optionally followed by one trailing slash) or + `git@github.com:/[.git]`, with exactly two path components and no query, fragment, + port, user-info, traversal, or percent-encoded ambiguity. + +Normalization: + +1. Trim surrounding whitespace. +2. Parse one of the exact accepted forms; do not perform a generic string replacement. +3. Case-fold the host and both GitHub owner/repository components. +4. Remove one terminal `.git` and trailing slash. +5. Emit `github.com//`. +6. Set `canonical_branch = revision_ref` verbatim. Git refs are case-sensitive; do not lowercase it. + +Current live imports normally store normalized public HTTPS URLs, but historical pre-hardening rows +may not. A row outside the strict grammar stays standalone. The migration must not infer from +repository name, local path, file tree, metadata, current GitHub redirects, or network calls. + +This cannot recognize a renamed/transferred GitHub repository as the same lineage because no +stable GitHub repository ID is stored. Different canonical owner/repository paths remain different +lineages. That limitation is truthful and is not rename/move detection. + +### 6.2 Grouping and deterministic ordering + +Group eligible rows by +`(owner_id, canonical_source_key, canonical_branch)`. For each group: + +1. Sort rows by `(created_at ASC, id ASC)`; the primary-key tie-break makes ordering deterministic. +2. Derive the backfill lineage ID with UUIDv5 from a fixed, migration-local namespace and an + unambiguous length-delimited encoding of the three group values. +3. Create/reconcile one lineage. `display_name` and `created_at` come from the first sorted row. +4. Assign sequences `1..N` in sorted order. +5. Set `latest_repository_id` to row `N` and `next_sequence = N + 1`. + +UUIDv5 is only a migration recovery mechanism. Live imports use random UUIDs. The fixed namespace +and encoding must be constants in the revision and covered by a deterministic test. + +Existing duplicate commit rows in one canonical group are preserved and ordered; deleting or +coalescing them would be data loss. The migration should report their count in test/preflight +evidence. Future serialized import logic prevents new duplicates within a lineage. + +### 6.3 Uploads and unsupported metadata + +Existing uploads contain an archive-byte hash, filename-derived name, extracted tree, and parser +metadata. None proves that two archives are revisions of the same logical repository. Uploads +therefore remain `lineage_id = NULL`, `sequence = NULL`; do not group by filename, archive hash, +tree similarity, repository name, or metadata. + +GitHub rows with missing/corrupt source URL or unresolved ref receive the same null pair. This is +the safest deterministic behavior and follows RFC §6 literally. It means historical standalone +rows do not acquire a stable logical lineage under #299; resolving that requires a future manual +linking feature or a change to RFC-0002. + +### 6.4 Backfill verification + +Before final constraints, abort the migration unless all invariants hold: + +- every eligible source group has exactly one lineage; +- every eligible repository has the expected lineage and a positive sequence; +- no ineligible/upload repository has either lineage field populated; +- owner IDs match on every attachment; +- each group has exactly sequences `1..N`, latest points to `N`, and next is `N+1`; +- each lineage's display name/created time comes from the deterministic first row; +- canonical fields are both null or both non-null; and +- no duplicate `(lineage_id, sequence)` exists. + +Do not silently skip a failed update or coerce corrupt data to satisfy final constraints. + +## 7. Concrete Alembic migration sequence + +Use two new revisions after the current head, each with an ID under the existing PostgreSQL +`alembic_version VARCHAR(32)` limit. Suggested IDs were `0011_lineage_expand` and +`0012_lineage_constraints`; two unrelated migrations (`0011_invite_tokens`, `0012_waitlist_entries`) +landed on `dev` ahead of this one, so the implementation uses `0013_lineage_expand` and +`0014_lineage_constraints` instead. The mechanics below are unaffected by the renumbering. + +Imports must be quiesced while the migrations run. The application currently has no dual-write +compatibility for lineage and a concurrent repository insert could escape the backfill. Existing +read traffic may continue subject to the deployment platform's normal DDL locks. + +### Revision A — expand and backfill + +1. Create `repository_lineages` with all columns, PK, owner FK/cascade, canonical-pair and counter + checks, `(id, owner_id)` unique target, canonical partial unique index, and owner index. Create it + with `latest_repository_id` nullable but defer its repository-member FK until both sides exist. +2. Add nullable `repositories.lineage_id` and `repositories.sequence` in **one** + `op.batch_alter_table('repositories')` block. Keeping additions together avoids repeated SQLite + table copies. +3. Run the strict, deterministic backfill in bounded batches using SQLAlchemy Core tables defined + inside the migration—not application models, which will evolve. +4. Run all §6.4 verification queries and fail explicitly on any mismatch. +5. Create the lineage-sequence pair check, unique sequence index, `(id, lineage_id)` unique target, + and composite repository-to-lineage ownership FK. On SQLite, use batch mode with an explicit + naming convention, following `0010_account_deletion`; never try to drop an anonymously reflected + constraint later. + +PostgreSQL: table creation/add-column are fast metadata operations; backfill and index creation +scan/write `repositories` and may lock writes. The migration is transactional. For a database large +enough that ordinary index creation is unacceptable, `CREATE INDEX CONCURRENTLY` would require a +non-transactional migration and a different recovery plan; current project policy/test shape favors +the transactional path. + +SQLite: adding constraints requires batch recreation. Ensure `PRAGMA foreign_keys=ON`; batch mode +must preserve every existing named repository check, unique constraint, FK, and index. The +migration test must compare them, not only assert the new columns exist. + +Downgrade is handled after Revision B's constraints are removed: drop new repository constraints, +indexes, and columns in batch, then drop `repository_lineages`. Existing repository data survives; +lineage-only grouping/counter data is intentionally lost. + +### Revision B — close the cyclic integrity boundary + +1. Add `fk_repository_lineages_latest_member(latest_repository_id, id) -> + repositories(id, lineage_id)` as deferrable/initially deferred. +2. Re-run cross-table verification under the final constraints. + +On SQLite this requires batch-recreating `repository_lineages`; use explicit names/naming +conventions. Splitting the cyclic constraint into a second revision makes recovery clear: if it +fails, Revision A is a backward-compatible additive state and can be inspected/fixed before +rerunning B. The new application must not start unless Alembic is at Revision B/head. + +Revision B downgrade drops only the latest-member FK. Revision A downgrade then removes all +lineage additions. Full `head -> base -> head` remains required by project policy. + +### Interruption and rerun + +- PostgreSQL failure rolls back the active revision transaction. +- SQLite DDL/batch behavior must not be assumed identical; deterministic UUIDs and reconciliation + make the backfill repeatable if an operator restores/stamps to Revision A and reruns. +- Never manually stamp past a failed backfill or constraint verification. +- If Revision A is recorded as applied and B fails, leave imports stopped, correct the data or + migration, and rerun upgrade. Do not start code that assumes the latest-member invariant. +- Downgrade from a live lineage-aware application requires stopping that application first. It + discards lineage records and new columns but preserves all pre-#299 repository/snapshot/job data. + +## 8. Future import and deletion rules for #299 + +### 8.1 GitHub + +After the existing clone resolves `revision_value` and `revision_ref`: + +1. Canonicalize the already validated URL to `github.com//`. +2. Use `revision_ref` verbatim as canonical branch/ref. Never use requested `branch` as identity. +3. Lookup only by owner plus both canonical values. +4. Create or reconcile the lineage, allocate the next sequence, dedupe the commit within that + lineage, insert the repository, and update latest in one transaction (§5.2). +5. Do not make a network call during matching and do not follow GitHub rename redirects. + +Different owners, canonical repositories, branches, or tags produce different lineages. The same +commit can legitimately occur in different branch-scoped lineages. Case variants and `.git` or +trailing-slash variants converge only after URL validation; broadening live input to SSH/mixed-case +host is outside #299 unless separately approved. + +### 8.2 Upload and unresolved ref + +`import_uploaded_repository` continues exact owner-scoped archive-hash duplicate detection but +sets both lineage fields null. It does not create or search a lineage. This is why #299 can touch +the upload path without inventing upload identity: the implementation makes the standalone rule +explicit. + +Live GitHub imports currently fail if no resolved ref is available. Preserve that behavior; do not +use #299 to introduce raw-SHA import. Legacy unresolved rows remain null after migration. + +The existing simultaneous-upload duplicate race is not a lineage allocation race. A database +partial unique index for uploads would close it, but that is a separate change with historical-data +preflight implications and should not be smuggled into #299. Record it as follow-up if the +concurrency test proves it matters. + +### 8.3 Repository deletion + +Replace the current delete commit boundary with one transaction: + +1. Owner-scope the repository and, if lineaged, lock/update its lineage counter row. +2. If deleting `latest_repository_id`, select the surviving member with greatest sequence and set + latest to that ID, or null if none. +3. Delete the repository row (which cascades snapshots/jobs/AI turns). +4. Commit, then remove the filesystem path. The current code deletes storage before DB commit; #299 + should reverse this order or explicitly handle DB failure so a failed FK/update cannot leave a + database row whose source directory has already vanished. + +Keep empty lineages. Their canonical identity and `next_sequence` preserve stable matching and +never-reused ordering for a later import. Garbage collection is future work. + +Account deletion continues to delete the user in one transaction. Both repository and lineage +owner FKs cascade. The cyclic, deferred member FKs must be exercised on real PostgreSQL and SQLite +to prove all rows disappear without cross-owner effects. + +## 9. Ownership and security invariants + +- Every canonical lookup includes `owner_id`; never lookup a lineage by canonical source alone. +- The repository-to-lineage composite FK makes a cross-owner attachment invalid even if service + code is wrong. +- The latest-member composite FK ensures the pointer names a repository in that exact lineage. +- Repository and lineage identifiers are never added to public responses in #299. Existing + owner-scoped 404 behavior remains unchanged, so IDs cannot probe another owner's history. +- There is no cross-owner comparison or organization lineage. +- User deletion cascades only from one `users.id`; no lineage is shared by owners, so another + user's repository cannot be reached by cascade. +- Canonical keys are identifiers, not authorization. They must not be logged with repository + contents or used to bypass owner scoping. + +## 10. Snapshot and future-evolution boundaries + +Snapshots continue to point to a `repositories` row and exact revision. No snapshot column, +constraint, query contract, API route, response schema, or frontend surface changes in #299. +Multiple revisions in one lineage expose their snapshots exactly as they do now: through their +individual repository IDs. Lineage-aware history APIs are later work. + +#299 establishes only: + +- stable owner-scoped logical grouping for resolvable GitHub branch/tag imports; +- deterministic repository ordering and a current surviving member pointer; +- migration/backfill and import-time assignment; and +- integrity/ownership foundations for later revision-aware queries. + +It thereby enables—but does not implement—two-revision comparison, snapshot/graph diff, +architecture drift, change-impact analysis, and cross-revision node/fact identity. It does not add +graph diff, impact scoring, rename/move detection, historical UI, PR review, architecture drift, +incremental analysis, new snapshot contracts, or manual upload linking. + +## 11. Failure modes and required behavior + +| Scenario | Required behavior | +| --- | --- | +| Same GitHub repo/branch, different commits concurrently | Both serialize on the lineage counter; distinct increasing sequences; latest is the later allocation. | +| Same GitHub commit concurrently, existing lineage | First wins; second observes duplicate after serialization and returns conflict; no orphan DB row/directory. | +| First-ever same lineage concurrently | Canonical unique index chooses one lineage; loser reloads/retries; no duplicate lineage. | +| Different lineage imports concurrently | PostgreSQL locks different rows; SQLite serializes writers as it does today; no shared counter. | +| Failed import before DB phase | No lineage/repository row; staged files cleaned. | +| DB failure after counter allocation | Whole transaction rolls back, including the counter/latest changes; staged files cleaned. | +| Repository deletion | Latest rolls back to highest surviving sequence; counter never decreases; snapshots/jobs/turns cascade. | +| Last revision deleted | Empty lineage remains with null latest and preserved next sequence. | +| User deletion | Repositories and lineages cascade for that owner only; audit survives. | +| Corrupt historical metadata | Row remains standalone; migration never guesses. | +| Migration interruption | Active revision rolls back where supported; deterministic reconciliation supports controlled rerun; never stamp past verification. | +| Downgrade | New grouping data is lost; all pre-existing repository/revision/snapshot data is preserved. | +| SQLite writer contention | Busy timeout bounds waiting; atomic counter update serializes; tests must use separate connections/threads. | +| PostgreSQL concurrency | Row update provides real row-level serialization; test with separate transactions in CI. | + +## 12. Authorization gate + +PR #328 does not implement #299. The owner's explicit approval authorizes writing and testing the +#299 implementation against this plan. It does not remove the separate requirement to complete the +[#322](https://github.com/Second-Origin/PARTHA/issues/322) rehearsal and recovery process against +the eventual migration before the #299 implementation PR merges. + +## 13. Test matrix for implementation + +### Migration tests + +- Fresh empty DB: `base -> head`, expected tables/columns/named constraints/indexes, then + `head -> base -> head`. +- Populated DB at `0010`: multiple commits in one URL/ref become one lineage; different refs, + repositories, and owners do not. +- URL variants normalize exactly as approved; malformed/ambiguous URLs remain null. +- Deterministic timestamp ties use repository ID. +- Uploads, unresolved refs, unidentified legacy rows remain null/null. +- Existing duplicate commits are preserved without sequence collision. +- Display name/created time/latest/next counter are correct. +- Backfill rerun/reconciliation is deterministic. +- Existing repository revision checks/FKs/indexes survive SQLite batch recreation. +- Downgrade preserves all old columns and data, while documenting loss of lineage-only data. +- Account-delete cascades work after migration. +- Run all above on local SQLite and the isolated real PostgreSQL database used by + `PARTHA_TEST_PG_URL` CI. + +### Service tests + +- First GitHub revision creates lineage with sequence 1 and next 2. +- Subsequent commit on same canonical source/ref reuses lineage and increments. +- URL case/`.git`/trailing-slash variants allowed by live validation match as specified. +- Different repository, ref/tag, or owner gets a different lineage. +- Same commit in different branch lineages is allowed. +- Upload sets null/null and preserves current response contract. +- Unresolved Git ref still fails import. +- Failed clone/parse leaves no DB row; failed DB insert cleans staged storage. +- Deleting non-latest leaves latest unchanged; deleting latest rolls back; deleting last keeps empty + lineage; next import never reuses a sequence. +- Cross-owner lineage lookup/attachment returns indistinguishable not-found behavior at service + boundaries and fails the composite FK if forced directly. + +### Concurrency/integrity tests + +- Two different commits imported into an existing lineage receive unique consecutive sequences. +- Two first imports racing create one lineage. +- Two identical commits racing create one repository and one conflict. +- Forced duplicate `(lineage_id, sequence)` fails. +- Cross-owner composite membership fails. +- Cross-lineage latest pointer fails. +- Transaction rollback restores counter/latest. +- Account and repository cascades preserve other owners. + +SQLite tests prove migration portability, constraint reflection, and serialized-writer behavior. +They **cannot prove PostgreSQL row-lock behavior, transaction isolation, partial-index semantics, or +concurrent create reconciliation**. Those concurrency cases and deferred cyclic FK/account-delete +cases must run against real PostgreSQL using separate connections and synchronization barriers—not +thread timing or sleeps. diff --git a/docs/architecture/REPOSITORY_LINEAGE_RFC.md b/docs/architecture/REPOSITORY_LINEAGE_RFC.md new file mode 100644 index 00000000..105b2140 --- /dev/null +++ b/docs/architecture/REPOSITORY_LINEAGE_RFC.md @@ -0,0 +1,351 @@ +# RFC-0002 — Repository Lineage: Identity and Schema Design + +| Field | Value | +| --- | --- | +| **RFC number** | RFC-0002 | +| **Title** | Repository Lineage: Identity and Schema Design | +| **Tracking issue** | [Second-Origin/PARTHA#298](https://github.com/Second-Origin/PARTHA/issues/298) | +| **Author** | @parthrohit22 | +| **Owner sign-off** | Confirmed | +| **Ratifier** | Independent ratification was waived by the owner on 2026-08-11; the implementation-critical amendment in [PR #328](https://github.com/Second-Origin/PARTHA/pull/328) was explicitly approved by the owner on 2026-08-19 | +| **Approval evidence** | Original owner sign-off is recorded below; the sequence, standalone-import, integrity, and deletion amendments were explicitly approved in [PR #328 review](https://github.com/Second-Origin/PARTHA/pull/328#pullrequestreview-4975035985) | +| **Created** | 2026-08-11 | +| **Last updated** | 2026-08-20 | +| **Status** | **Accepted; PR #328 amendment approved and #299 authorized for implementation** | +| **Supersedes** | — | +| **Superseded by** | — | + +> **This RFC records a design decision; it is not application code.** Acceptance does not by +> itself create the `repository_lineages` table, add columns to `repositories`, change +> `RepositoryService`, or alter any API or frontend surface. Implementation is tracked as a +> separate issue, [#299](https://github.com/Second-Origin/PARTHA/issues/299). The exact authorization +> rule for that issue is recorded in [§1.2](#12-implementation-authorization). + +--- + +## 1. Status and sign-off + +### 1.1 Status + +The baseline RFC was **Accepted** at the owner level: @parthrohit22 reviewed and confirmed its +identity design and the five questions in [§9](#9-open-questions-and-resolutions). PR #328 amends +that baseline with the implementation-critical sequence, integrity, standalone-import, deletion, +and migration contracts below. The owner explicitly approved those amendments on 2026-08-19 under +[§1.2](#12-implementation-authorization). + +### 1.2 Implementation authorization + +The independent-ratification condition originally recorded in [Q5](#q5-independent-ratification) +was waived by the owner on 2026-08-11. That waiver did not approve the implementation-critical +choices that were still absent from this RFC: durable sequence allocation, never-reused ordinals, +standalone-import storage semantics, and database-enforced membership integrity. PR #328 adds those +choices to the architecture contract and supplies the separately reviewed Alembic plan required by +#299. The owner explicitly approved them in +[PR #328 review](https://github.com/Second-Origin/PARTHA/pull/328#pullrequestreview-4975035985). + +The authorization state is therefore explicit: + +- **#299 AUTHORIZED FOR IMPLEMENTATION.** Implementation may be written and tested against this RFC + and the reviewed migration plan. +- [#322](https://github.com/Second-Origin/PARTHA/issues/322) is a separate operational gate: + repeatable migration rehearsal and rollback evidence are **required before the #299 + implementation PR merges**, but that gate does not prevent implementation work from proceeding. + +The approval evidence is the owner review linked above. Approval authorizes writing and testing; +it does not mean #299 has been implemented or remove its operational pre-merge gate. + +## 2. Terminology + +The key words **MUST**, **MUST NOT**, **SHOULD**, **SHOULD NOT**, and **MAY** in this document are +to be interpreted as described in RFC 2119, consistent with the convention already established in +[RFC-0001 §2](REPOSITORY_INTELLIGENCE_V1_RFC.md#2-normative-terminology). + +Domain terms used throughout this document: + +| Term | Meaning | +| --- | --- | +| **Repository row** | A single `repositories` row (`RepositoryRecord`), i.e. one imported revision — one GitHub commit or one uploaded archive. Revision identity is already governed by [RFC-0001 §3](REPOSITORY_INTELLIGENCE_V1_RFC.md#3-revision-and-snapshot-identity); this RFC does not change it. | +| **Lineage** | A durable grouping of repository rows that represent successive imports of "the same repository" over time, from the same owner. A lineage has no revision content of its own — it is purely an identity/grouping record. | +| **Canonical source key** | A normalized, stable identifier derived from a GitHub import's source location, used to decide whether a new import joins an existing lineage. `NULL` when no such stable identity exists. | +| **Canonical branch** | The resolved ref component of a lineage's join key, added by [Q1](#q1-branch-scoped-lineage). It stores `revision_ref` verbatim and may therefore be a branch or tag; `NULL` under the same no-stable-identity rule as canonical source key. | +| **Unlineaged standalone import** | A repository row that does not auto-join a lineage. Its `lineage_id` and `sequence` are both `NULL`; no synthetic `repository_lineages` row exists for it. | + +## 3. Problem statement + +`RepositoryRecord` (`apps/backend/app/models/repository.py`) currently identifies one imported +revision: a GitHub commit (`revision_kind="git"`, `revision_value=`) or an uploaded archive +(`revision_kind="upload"`, `revision_value=sha256:`), each addressed independently per +[RFC-0001 §3](REPOSITORY_INTELLIGENCE_V1_RFC.md#3-revision-and-snapshot-identity). `RepositoryService.import_github_repository` +and `import_uploaded_repository` (`apps/backend/app/services/repository_service.py`) dedupe on +`(owner_id, source_url, revision_value)` or `(owner_id, revision_value)` respectively — there is no +concept linking repeated imports of the same GitHub repository across different commits, or +representing "this is the third time this owner imported this repo" as a first-class relationship. + +The gap is material because a flat set of independent imported revisions has no durable, +owner-scoped identity for the logical repository those revisions came from. A consumer cannot +deterministically group successive imports or identify a current surviving member without that +separate identity. + +[RFC-0001 §9 (Schema versioning)](REPOSITORY_INTELLIGENCE_V1_RFC.md#9-schema-versioning) and + [§11 (Snapshot lifecycle)](REPOSITORY_INTELLIGENCE_V1_RFC.md#11-snapshot-lifecycle-and-immutability) + already treat each revision's snapshot as immutable and independently addressable. Without a + lineage concept sitting above that, any future cross-revision feature (diff, drift, "has this + repository been re-analysed since I last looked") has no stable anchor to group revisions by. + +This RFC proposes the identity rule and schema for that grouping, without changing revision +identity, snapshot immutability, or any existing API/frontend contract. + +## 4. Identity design + +### 4.1 Lineage key + +A lineage is identified by the triple: + +```text +(owner_id, canonical_source_key, canonical_branch) +``` + +Two repository rows belong to the same lineage if and only if they share the same `owner_id` and +both have a non-`NULL`, equal `canonical_source_key` and `canonical_branch`. This is the join key +`RepositoryService.import_github_repository` MUST use when deciding whether a new GitHub import +attaches to an existing lineage or starts a new one. + +### 4.2 Canonical source key derivation (GitHub imports) + +For a GitHub import with a resolved `revision_ref` (a named branch or tag the commit was resolved +from — see `RepositoryRecord.revision_ref`, `apps/backend/app/models/repository.py:44-46`), +`canonical_source_key` is derived by normalizing a source URL accepted by the import boundary: +lowercased host and owner/repository path, with scheme, trailing `.git`, and trailing slash removed +(for example, `https://github.com/Acme/Widgets.git` normalizes to +`github.com/acme/widgets`). #299 does not broaden the live importer beyond its currently accepted +public HTTPS GitHub URL grammar. The backfill may recognize strictly parsed historical HTTPS or SSH +GitHub repository forms as detailed in the migration plan; doing so does not expand live request +validation. Ambiguous values remain unlineaged. + +`canonical_branch` stores the resolved `revision_ref` verbatim, including its `refs/heads/` or +`refs/tags/` prefix. Despite the retained field name, it is a normalized ref component and may +therefore identify a tag. The requested `branch` input is not lineage identity. + +### 4.3 Uploads and unresolved-ref imports: unlineaged standalone imports + +Uploaded archives (`revision_kind="upload"`) have no source location to normalize — there is +nothing for `canonical_source_key` to key off. The same is true of a GitHub import whose +`revision_ref` did not resolve to a branch (a raw-SHA import with no branch context): there is a +source URL, but no stable branch component to complete the join key. + +In both cases the repository row has `lineage_id = NULL` and `sequence = NULL` and **no synthetic +lineage row is created**, even if another row has a matching `source_url`, upload hash, filename, +name, or similar tree. It is an **unlineaged standalone import**. This is one deterministic rule: +a repository row with no resolvable stable identity is never grouped automatically. Future manual +upload linking remains outside this RFC and must not be anticipated with heuristic grouping. + +## 5. Schema proposal + +### 5.1 New table `repository_lineages` + +| Column | Type | Notes | +| --- | --- | --- | +| `id` | String(36), PK | | +| `owner_id` | String(36), non-null, FK → `users.id`, indexed | `ON DELETE CASCADE` | +| `canonical_source_key` | Text, nullable, indexed | See [§4.2](#42-canonical-source-key-derivation-github-imports) / [§4.3](#43-uploads-and-unresolved-ref-imports-unlineaged-standalone-imports) | +| `canonical_branch` | Text, nullable, indexed | Added by [Q1](#q1-branch-scoped-lineage); mirrors `canonical_source_key`'s NULL-for-no-stable-identity rule | +| `display_name` | Text, non-null | Inherited permanently from the first revision's `name` at lineage creation; see [Q4](#q4-display-name) | +| `latest_repository_id` | String(36), nullable | Highest-sequence surviving member, or `NULL` for an empty lineage; membership is enforced by the composite constraint below | +| `next_sequence` | Integer, non-null | Next never-issued 1-based import ordinal; initialized to `1`, constrained to `>= 1`, incremented transactionally, and never decremented | +| `created_at` | timezone-aware DateTime, non-null | Earliest member time for backfill; current UTC for live creation | + +Required constraints: + +```sql +UNIQUE (owner_id, canonical_source_key, canonical_branch) + WHERE canonical_source_key IS NOT NULL AND canonical_branch IS NOT NULL +``` + +- A check requires `canonical_source_key` and `canonical_branch` to be both `NULL` or both + non-`NULL`. Null canonical fields are reserved for a possible future manually created lineage; + #299 does not create a lineage for an unlineaged standalone import. +- `(id, owner_id)` is unique so repository membership can prove owner equality through a composite + foreign key. +- `(latest_repository_id, id)` references `repositories(id, lineage_id)`, deferrable and initially + deferred, so a non-null latest pointer must identify a repository in that exact lineage. + +The partial unique key is also the authority when two transactions race to create the first +lineage for one canonical owner/source/ref identity. + +### 5.2 New columns on `repositories` + +| Column | Type | Notes | +| --- | --- | --- | +| `lineage_id` | String(36), nullable, indexed | `NULL` for unlineaged standalone imports; membership uses the composite owner constraint below | +| `sequence` | Integer, nullable | 1-based, never-reused import ordinal within the lineage; `NULL` for unlineaged standalone imports | + +No changes are proposed to `RepositoryRecord`'s existing identity columns +(`id`, `revision_kind`, `revision_value`, `revision_ref`) or to the existing +`uq_repositories_id_revision` constraint — revision identity per RFC-0001 is unaffected. + +The database MUST enforce all of these invariants: + +- `lineage_id` and `sequence` are either both `NULL` or both non-`NULL`; a non-null sequence is at + least `1`. +- `(lineage_id, sequence)` is unique. Gaps are permitted, but an ordinal cannot identify two + repository rows in one lineage. +- `(id, lineage_id)` is unique as the target for the latest-member constraint. +- `(lineage_id, owner_id)` references `repository_lineages(id, owner_id)`, deferrable and initially + deferred. A repository cannot join a lineage belonging to another owner. + +The two composite foreign keys are architectural integrity requirements, not optional ORM checks. +Their deferral supports creating a lineage, attaching its first repository, and setting the latest +pointer—or moving that pointer before deletion—within one transaction. Both PostgreSQL and SQLite +migration paths MUST preserve and validate them. + +### 5.3 Concurrency: `sequence` assignment + +Sequence allocation is race-free and monotonically increasing. Previously issued sequence numbers +are never reused; gaps caused by deletion are valid. A sequence is an import ordinal, not commit +ancestry, commit time, analysis time, or proof that all lower ordinals still exist. + +Each lineage owns durable allocation state in `next_sequence`. In the same database transaction +that creates the repository row, the importer atomically increments that lineage row and assigns +the previously unissued value. A rollback restores both the counter and repository/latest-pointer +changes. The unique `(lineage_id, sequence)` constraint remains defense in depth. + +Concurrent first-lineage creation is reconciled through the owner-scoped canonical-key uniqueness +constraint: one insert wins, and the loser reloads that lineage and retries the database phase in a +bounded manner. On PostgreSQL, updating the lineage counter provides row-level serialization for +that lineage. SQLite uses its serialized writer behavior rather than pretending to provide +PostgreSQL row locks; its transaction, contention, retry, and constraint behavior MUST be validated +independently. The migration plan specifies the operational algorithm without changing this +contract. + +### 5.4 Deletion semantics + +Deleting a repository row never renumbers surviving members and never decrements `next_sequence`. +If the deleted row is `latest_repository_id`, the pointer moves in the same transaction to the +surviving member with the greatest sequence. If no member survives, the lineage row remains, +`latest_repository_id` becomes `NULL`, and `next_sequence` is preserved. A later import therefore +reuses the canonical lineage identity but never reuses an issued ordinal. + +## 6. Backfill approach + +Existing `repositories` rows predate `lineage_id`/`sequence` and must be backfilled by the +versioned Alembic migration sequence (per the existing +[migration policy in RFC-0001 §10](REPOSITORY_INTELLIGENCE_V1_RFC.md#10-migration-policy)): + +1. For every existing row with `source="github"` and a resolved `revision_ref`, compute + `canonical_source_key` / `canonical_branch` per [§4.2](#42-canonical-source-key-derivation-github-imports) + and group rows by `(owner_id, canonical_source_key, canonical_branch)`. +2. For each group, create one `repository_lineages` row. `display_name` is seeded from the + **earliest** row's `name` in that group by `(created_at, id)` (per + [Q4](#q4-display-name)). `sequence` is assigned as `1..N` in that deterministic order. + `latest_repository_id` is set to row `N` and `next_sequence` to `N + 1`. +3. Every row with `source="upload"`, or `source="github"` with no resolved `revision_ref`, is left + with `lineage_id = NULL` and `sequence = NULL`—an unlineaged standalone import, per + [§4.3](#43-uploads-and-unresolved-ref-imports-unlineaged-standalone-imports). + No synthetic lineage is created for these rows. +4. The backfill uses deterministic lineage identifiers and explicit reconciliation/verification so + an interrupted attempt can be inspected and safely rerun. It never guesses from ambiguous URLs + or mutable display metadata. + +This section defines the design-level outcome. Revision boundaries, DDL ordering, deterministic ID +construction, verification queries, interruption recovery, and downgrade mechanics belong to the +separately reviewed migration plan. #322 must supply the repeatable rehearsal and rollback evidence +before the #299 implementation PR merges. + +## 7. Out of scope + +- Any migration code, Alembic revision, or SQLAlchemy model change (see hard boundaries on the + tracking issue and [§1.2](#12-implementation-authorization)). +- Any change to `RepositoryService.import_github_repository` / `import_uploaded_repository` + behavior, the dashboard "most recently analysed repository" feature, or any other application + code. +- Any API contract or frontend surface change. +- Upload-to-lineage linking UX — deferred per [Q3](#q3-upload-linking). Concurrent upload-hash + deduplication is also outside #299 and is not solved by lineage allocation. +- A rename/re-titling action for a lineage's `display_name` — deferred per [Q4](#q4-display-name). +- Cross-repository or cross-owner lineage matching. Lineage membership is always scoped to a single + `owner_id`; this RFC does not propose any notion of shared or organization-wide lineage. + +## 8. Alternatives rejected + +- **Repository-scoped lineage key, ignoring branch** (`(owner_id, canonical_source_key)` only, + branch-agnostic). Rejected by [Q1](#q1-branch-scoped-lineage): it would silently merge imports of + different branches of the same repository into one lineage, which is a materially different + identity claim than "the same branch over time" and would make any future diff/drift feature + built on lineage produce misleading comparisons across unrelated branches. +- **Content-hash-based lineage matching for uploads** (attempt to match uploads into a lineage by + fuzzy content similarity rather than requiring a stable source key). Rejected: this contradicts + the project's standing anti-goal of a mutable "latest truth" in spirit — fuzzy matching would make + lineage membership a heuristic, non-reproducible judgment rather than a deterministic identity + rule. Uploads remain unlineaged standalone imports per + [§4.3](#43-uploads-and-unresolved-ref-imports-unlineaged-standalone-imports) + until a future, explicit, user-driven linking action exists ([Q3](#q3-upload-linking)). +- **Automatic lineage-level rename when a revision's name changes.** Rejected by + [Q4](#q4-display-name): a lineage's `display_name` permanently inherits the first revision's name + instead, avoiding any implicit rename action with no user confirmation step. + +## 9. Open questions and resolutions + +All five open questions raised during design review are resolved as follows. + +### Q1: Branch-scoped lineage + +Key is `(owner_id, canonical_source_key, canonical_branch)`. New +`repository_lineages.canonical_branch` column (Text, nullable, indexed) added, mirroring +`canonical_source_key`'s NULL-for-no-stable-identity rule. A pair check requires both fields to be +null or non-null together. The constraint becomes `UNIQUE(owner_id, canonical_source_key, +canonical_branch) WHERE canonical_source_key IS NOT NULL AND canonical_branch IS NOT NULL`. +A GitHub import with no resolved `revision_ref` (raw-SHA import, no branch context) does not +auto-join any lineage—an unlineaged standalone import, the same treatment as uploads +([§4.3](#43-uploads-and-unresolved-ref-imports-unlineaged-standalone-imports)). + +### Q2: `latest_repository_id` rollback + +`latest_repository_id` moves to the highest surviving sequence on revision deletion. Deleting the +final member preserves the empty lineage with a null latest pointer and unchanged `next_sequence`. + +### Q3: Upload linking + +Manual-only upload linking, deferred to a future feature. + +### Q4: Display name + +`display_name` permanently inherits the first revision's name — no rename action for now. + +### Q5: Independent ratification + +Independent ratification by [@SHAURYAKSHARMA24](https://github.com/SHAURYAKSHARMA24) was requested +before implementation and waived by the owner on 2026-08-11. The implementation-critical +amendment in PR #328 instead received explicit owner approval under +[§1.2](#12-implementation-authorization). + +## 10. Sign-off + +```text +Owner sign-off: confirmed +Open questions: 5/5 resolved +Tracking issue: Second-Origin/PARTHA#298 +PR #328 architecture amendment: explicitly approved by owner on 2026-08-19 +#299 implementation: authorized for writing and testing; not yet implemented +#322 operational evidence: required before the #299 implementation PR merges +``` + +## Ratification waiver (owner decision, 2026-08-11) + +Independent ratification by @SHAURYAKSHARMA24, as recommended in Q5, is waived by the owner. +Reasoning stated by the owner: sole-maintainer authority, plus the assessment that a review +conducted without the full context built up across this design's discussion would not be a +substantive independent check and risks being reviewed in name only. + +This waiver did not resolve or retract Q5's underlying concern—it recorded acceptance of the +ratification risk, not evidence that the sequence design was complete. The PR #328 amendment now +resolves that design gap normatively in [§5.3](#53-concurrency-sequence-assignment) with durable +per-lineage allocation state, transaction semantics, canonical-key race reconciliation, and +database uniqueness. The explicit owner approval recorded under +[§1.2](#12-implementation-authorization) on 2026-08-19 authorized those additions. + +## 11. References + +- [RFC-0001 — Repository Intelligence v1 Schema and Evidence Contract](REPOSITORY_INTELLIGENCE_V1_RFC.md) +- `apps/backend/app/models/repository.py` — `RepositoryRecord` +- `apps/backend/app/services/repository_service.py` — `RepositoryService.import_github_repository`, `import_uploaded_repository` +- `apps/frontend/src/app/pages/DashboardPage.tsx` — client-side "most recently analysed repository" computation (`feat/dashboard-latest-analysis-summary`) diff --git a/docs/architecture/SYSTEM_OVERVIEW.md b/docs/architecture/SYSTEM_OVERVIEW.md new file mode 100644 index 00000000..044b6723 --- /dev/null +++ b/docs/architecture/SYSTEM_OVERVIEW.md @@ -0,0 +1,298 @@ +# System Overview + +This document describes the system **as it exists today**. Where something is a placeholder, a constant, or a known gap, it says so. Nothing here is aspirational. + +Audience: contributors and maintainers who need to know what runs, where the boundaries are, and what they must not break. + +--- + +## Shape of the system + +PARTHA is a monorepo: a React frontend, a FastAPI backend, and a local +filesystem plus relational database for persistence. Durable analysis uses the +database as its queue behind an explicit control-plane boundary, driven by a +daemon worker thread inside the API process; there is no external message queue +or separate analysis service. + +```mermaid +flowchart LR + UI["Frontend
    React 18 · Vite · TypeScript
    apps/frontend"] + + subgraph Server["Backend — FastAPI · apps/backend"] + direction TB + MW["Middleware
    rate limit · security headers
    CORS · request ID"] + Routes["Routes
    app/api/routes/"] + Services["Services
    app/services/"] + Queue["Control plane
    app/workers/control_plane.py
    claim · lease · reclaim"] + Worker["Analysis worker
    app/workers/ · daemon thread"] + Extract["Extractors
    app/extraction/"] + RI["Repository Intelligence
    app/intelligence/
    sealed read model"] + Consumers["Consumers
    analysis · graph · review · insights
    documentation · ai · reports"] + + MW --> Routes --> Services + Services -->|"enqueue"| Queue + Queue -->|"lease"| Worker + Worker --> Extract --> RI + Services --> Consumers + Consumers -->|"read only"| RI + end + + subgraph Store["Persistence"] + direction TB + DB[("Relational DB
    SQLite local
    PostgreSQL configured")] + Disk[("Filesystem
    STORAGE_PATH")] + end + + subgraph Ext["External"] + direction TB + GH["GitHub
    git clone over HTTPS"] + LLM["AI providers
    OpenAI · Anthropic · Gemini
    OpenRouter · Ollama"] + end + + UI --> MW + RI --> DB + Queue --> DB + Worker --> DB + Services --> Disk + Services --> GH + Consumers --> LLM +``` + +--- + +## Components and responsibilities + +### Frontend (`apps/frontend`) + +| Area | Responsibility | +| --- | --- | +| `src/app/` | Shell, router, pages, and global stores. Every application route sits behind the `RequireAuth` guard; only `/login` and `/register` are public. | +| `src/features/` | Domain features with colocated hooks, components, and stores: repositories, upload, explorer, architecture, dependencies, review, documentation, AI workspace, insights, auth, settings. | +| `src/shared/` | API clients, error mapping, feature-state helpers, reusable UI, config, and types. All backend calls go through the shared client, which owns access-token attachment and 401 handling. | + +### Backend (`apps/backend/app`) + +| Module | Responsibility | Must not do | +| --- | --- | --- | +| `api/` | HTTP boundary. Routes stay thin; `deps.py` wires every dependency. | Contain business logic. | +| `services/` | Application services: repository import, analysis orchestration, documentation, AI. | Parse repository files directly. | +| `intelligence/` | **The Repository Intelligence engine.** Builds, persists, and reloads reusable repository facts. | Render API response shapes. | +| `parsers/` | `RepositoryParser` walks the extracted tree and produces the file tree plus basic metadata. Legacy ingestion still uses the parser's heuristic symbol path. | Produce feature-specific output. | +| `extraction/` | The producer set behind the engine: syntax-aware `python.py` and `typescript.py`, dependency `manifests.py` and `lockfiles.py`, outbound service interactions (`http.py`), Docker Compose resources (`iac.py`), and the `support_matrix.py` that generates the public capability registry. | Persist snapshots or answer product queries. | +| `analysis/` | Architecture model — modules, layers, edges, request-flow hints. **Consumer.** | Read the filesystem. | +| `graph/` | Dependency graph response model. **Consumer.** | Re-read dependency manifests. | +| `review/` | Deterministic `engineering-review.v2` findings and category assessment over one sealed snapshot. **Consumer.** | Read the legacy JSON model, invent scores/grades, or emit a finding without exact same-snapshot evidence. | +| `insights/` | Defined `repository-insights.v1` metrics and breakdowns over one sealed snapshot. **Consumer.** | Read legacy metadata, infer trends without history, or publish undefined health metrics. | +| `ai/` | Context builder, prompt builder, orchestrator, provider registry/factory, five provider implementations, and the central egress policy/pinned sender. **Consumer.** | Parse repositories, read source files, or let a tenant expand provider destinations. | +| `reports/` | `ReportDocument` intermediate representation, builders, and JSON/Markdown/HTML/PDF renderers. **Second-order consumer** — renders analysis output that already exists. | Re-analyse a repository. | +| `auth/` | Argon2 password hashing, HS256 access tokens, rotating refresh tokens with reuse detection. | — | +| `core/` | Settings and validation, database engine, structured logging with redaction, request IDs, metrics, rate limiting, security headers. | — | +| `storage/` | Local filesystem storage for uploads and extracted/cloned repositories. Enforces path safety on extraction. | — | +| `workers/control_plane.py` | The queue boundary: which jobs are eligible, and who owns them — claiming, lease renewal, expiry, reclaim, and every ownership guard. | Analyse a repository, or read anything but `analysis_jobs`. | +| `workers/runner.py` | The in-process runner: worker identity, the poll/sweep loop, and shutdown. The same loop a standalone worker process would run. | Contain queue policy of its own, or be imported by the API for anything but start/stop. | +| `workers/analysis_worker.py` | Execution of an *already-claimed* job: the extraction pipeline, snapshot sealing, bounded retry, cancellation and stale-job reconciliation. | Decide who owns a job, or serve request-specific data. | + +--- + +## Ingestion flow + +Both entry points converge on the same import path: land the source on disk, +compute immutable revision identity, parse the bounded file tree, and persist the +repository revision. A separate durable job then builds and seals the normalized +`ri.v1` snapshot off the request path. + +```mermaid +sequenceDiagram + participant UI as Frontend + participant API as FastAPI route + participant Repo as RepositoryService + participant Store as LocalStorage + participant Parser as RepositoryParser + participant Queue as Control plane + participant Worker as AnalysisWorker + participant RI as Extraction + Intelligence + participant DB as Database + + UI->>API: POST /repositories/upload (archive)
    or POST /repositories/github (URL) + API->>Repo: import + Repo->>Store: save + extract archive, or shallow-clone + Note over Store: path traversal and symlink escape rejected on upload
    upload and clone size caps enforced + Store-->>Repo: repository root on disk + Repo->>Parser: parse(root) + Parser-->>Repo: FileTreeNode[] + RepositoryMeta + size + Repo->>DB: insert row (revision kind/value/ref + metadata + file_tree) + DB-->>UI: RepositoryResponse + UI->>API: POST /analysis/{id}/start + API->>DB: insert queued analysis_jobs row + API-->>UI: queued + job id + Worker->>Queue: claim + Queue->>DB: compare-and-swap queued -> running + lease + Queue-->>Worker: JobLease + Worker->>Queue: renew lease by stage (reports cancellation) + Worker->>RI: extract and resolve repository facts + Worker->>DB: persist and seal normalized ri.v1 snapshot +``` + +Clone/archive extraction and initial file-tree parsing run synchronously during +import. `POST /analysis/{id}/start` durably enqueues the analysis and returns +immediately. A daemon worker thread in the API process claims jobs *through the +control plane*, reports progress at completed stage boundaries, seals the +normalized snapshot, and serves every product consumer from that immutable read +model. + +The API process hosts that worker but does not own the queue. Claiming, leases, +expiry, reclaim and ownership guards live in `app/workers/control_plane.py`, and +the poll/sweep loop lives in `app/workers/runner.py`, so the same components +would drive a standalone worker process without changing the claim/lease +contract. Running analysis in a separate process is not implemented today. + +--- + +## Persistence boundaries + +| Store | Holds | Notes | +| --- | --- | --- | +| Relational DB | `users`, `refresh_tokens`, `repositories`, `analysis_jobs`, `ai_provider_configs`, `ai_conversation_messages`, and normalized `ri_*` snapshot tables | SQLite by default for local development; PostgreSQL is supported through `DATABASE_URL`. Analysis jobs and their worker leases are durable database state. | +| `repositories.revision_kind`, `revision_value`, `revision_ref` | Exact imported source identity: Git commit + resolved ref, or upload archive hash. | `revision_value` is indexed and immutable; a moving branch name is metadata, never identity. | +| `repositories.repo_metadata` (JSON column) | Import/parser metadata; historical rows may also retain a **legacy/unverified** `intelligence` value. | New analysis does not write the legacy value, and executable product consumers ignore it. New imports no longer stash `commitSha` here. | +| `ri_snapshots`, `ri_nodes`, `ri_edges`, `ri_assertions`, `ri_observations`, `ri_evidence`, `ri_derivations`, `ri_diagnostics` | Revision-addressed normalized `ri.v1` artifacts, provenance, lifecycle state, and canonical hash. | Durable analysis runs the Python/TypeScript/manifest producers and resolver, then seals a snapshot. Architecture, authentication explanation, Dependencies, Engineering Review, Insights, Documentation, exports, and AI context consume it. | +| `repositories.file_tree` (JSON column) | The parsed file tree. | Serves the explorer. | +| `ai_provider_configs` | One row per user: provider, model, base URL, and the **Fernet-encrypted** API key plus its last four characters. | Owner-scoped; the plaintext key is never stored or returned. A stored endpoint remains unusable unless it satisfies the current deployment egress policy. | +| `ai_conversation_messages` | One row per AI Workspace turn: role, content, optional citations, and an explicit `sequence`. | **The AI Workspace thread is durable, not ephemeral.** One ordered thread per owner per repository, so history survives navigating away and returning. `UNIQUE(owner_id, repository_id, sequence)` is the concurrency guard; the repository foreign key cascades, so deleting a repository deletes its turns. A cross-owner read resolves to the same 404 as a missing repository. | +| Filesystem (`STORAGE_PATH`) | Extracted archives and cloned repositories; uploaded archives (deleted after extraction). | Repository source is read from here on demand for file preview. | + +AI provider configuration is **per-user and encrypted at rest**. Each user's API key is encrypted with a Fernet key from `AI_ENCRYPTION_KEY` (required outside `development`/`test`), decrypted only in-process at request time, and injected per request — so a query runs against the caller's own key and bill, never a shared one. + +--- + +## Authentication and session flow + +```mermaid +sequenceDiagram + participant UI as Frontend + participant API as /auth + participant Auth as AuthService + participant DB as Database + + UI->>API: POST /auth/register or /auth/login + API->>Auth: verify (Argon2) + Auth->>DB: persist rotating refresh token (hashed) + API-->>UI: access token (HS256, in body)
    + refresh token (httpOnly cookie, path=/auth) + Note over UI: access token held in memory,
    attached as Authorization: Bearer + UI->>API: POST /auth/refresh (cookie) + Note over Auth: reuse of a spent token revokes
    the entire token family + API-->>UI: new access token + rotated refresh cookie +``` + +The access token is short-lived (15 min default); the refresh token lasts 14 days and rotates on every use. Refresh-token reuse revokes the whole family. `AUTH_SECRET_KEY` is required and length-checked outside `development`/`test`; in dev it falls back to a fixed insecure value. + +**Cookie scope and the same-site deployment requirement.** The refresh cookie is `HttpOnly`, `Path=/auth`, `SameSite=Lax`, and `Secure` outside `development`/`test` (`_set_refresh_cookie` in `app/api/routes/auth.py`). `SameSite=Lax` is a deliberate CSRF control, and it means **the frontend and the API must be served from the same site** (same registrable domain) in any deployment — e.g. one origin behind a path prefix, or `app.example.com` + `api.example.com`. If they are cross-site, the browser withholds the cookie from the background `POST /auth/refresh`, so the session cannot be re-established and the user is bounced to login on every reload. Locally this only bites if the frontend origin and `VITE_API_URL` disagree on host (`localhost` vs `127.0.0.1`); keep both on the same host. + +**Enforcement.** Every non-public route requires a valid access token. The `/repositories`, `/analysis`, `/ai`, `/documentation`, and `/export` routers each apply `get_current_user` at the router level, so a request with no token — or an invalid one — is rejected with 401 before reaching a handler, and a newly added route under those prefixes is protected by default. The pre-auth `get_current_user_or_default` fallback and its `X-Dev-User` header were removed in E1.3; there is no anonymous seed-user bucket. Data is additionally owner-scoped in the service layer (below), so authentication and authorization are enforced independently. + +--- + +## The Repository Intelligence boundary + +This is the one architectural invariant of the system. + +> **Repository Intelligence is the single repository-understanding boundary. Consumers must not independently parse repositories or construct a second source of repository truth.** + +The AI subsystem gets no exemption from this: + +> **AI is a consumer of Repository Intelligence and must not independently parse or reinterpret repositories.** + +Repository source is read only by the bounded import/analysis pipeline +(`RepositoryParser` and `AnalysisWorker`) and by +`RepositoryService.read_file`, which serves the explorer's path-checked preview +and feeds no analysis. + +Every repository-derived consumer uses the owner-scoped `SnapshotQueryService` +and requires a sealed snapshot for the current repository revision. Historical +legacy JSON may remain stored but is ignored. See +[REPOSITORY_INTELLIGENCE.md](REPOSITORY_INTELLIGENCE.md) for what each path +extracts and what it does not. + +### Current consumers + +| Consumer | Reads | Produces | +| --- | --- | --- | +| `analysis/` | sealed `ri.v1` nodes, edges, assertions, diagnostics, evidence | Architecture nodes, edges, layers, request-flow hints | +| `review/` | sealed `ri.v1` diagnostics plus exact evidence and manifest identity | Supported findings and assessed/not-assessed category matrix; no scores | +| `insights/` | sealed `ri.v1` nodes, edges, diagnostics, evidence and extractor set | Defined counts, ratios and breakdowns; no inferred trends | +| `graph/` | sealed dependency nodes, declarations, resolved edges, diagnostics, evidence | Direct dependency graph with explicit not-computed assessments | +| `services/documentation_service.py` | shared sealed structural projection and snapshot/revision identity | Markdown / HTML documentation | +| `ai/repository_context.py` | shared sealed structural projection, without source bytes, plus the recent turns of the stored conversation thread | Preview `RepositoryContext` → `PromptBundle` for a configured provider | +| `reports/` | snapshot-backed analysis and documentation output | JSON / Markdown / HTML / PDF | + +--- + +## External dependencies + +| Dependency | Used for | Failure mode | +| --- | --- | --- | +| `git` (system binary) | Shallow-cloning public GitHub repositories. | Import fails with a normalized external-service error; the partial clone is cleaned up. | +| GitHub (HTTPS) | Source for public repository import. Only `https://github.com/owner/repo` URLs are accepted; no authentication, so no private repositories. | Timeout and size caps abort and clean up. | +| AI providers | Answering repository questions. Configured per user with an encrypted API key; destinations are centrally policy-checked and DNS-pinned. | A missing, stale, or policy-denied configuration produces a normalized error; the rest of the system is unaffected. | +| PostgreSQL, Redis | Optional configured services and CI integration. Redis backs the rate limiter when `RATE_LIMIT_BACKEND=redis`. | Local development uses SQLite and the in-memory rate limiter; neither service is required. | + +--- + +## Trust boundaries + +```mermaid +flowchart TB + subgraph Untrusted["Untrusted input"] + Archive["Uploaded archive"] + RepoURL["GitHub URL / branch"] + Source["Repository source content"] + end + + subgraph Backend["Backend process — trusted"] + Validate["Validation
    URL allowlist · branch charset · size caps
    path traversal + symlink rejection (upload)"] + Engine["Parse + Repository Intelligence"] + Thread[("Stored conversation
    ai_conversation_messages
    both turns of every query")] + end + + subgraph Out["Egress"] + Providers["AI providers"] + Logs["Logs"] + end + + Archive --> Validate + RepoURL --> Validate + Validate --> Engine + Source --> Engine + Engine -->|"structure + file paths only —
    never source content"| Providers + Engine -->|"redacted; never repository content"| Logs + Engine --> Thread + Thread -->|"recent turns replayed as context"| Providers +``` + +- **Uploaded archives and cloned repositories are untrusted input.** Upload extraction rejects path traversal and symlink escape; the GitHub clone tree walk rejects symlinked files and directories the same way (#182); upload and clone sizes are capped; only allowlisted archive suffixes are accepted. Repository *content* is never executed — it is only read as text. +- **Repository source never leaves the process.** The AI context builder passes structure and metadata only: languages, frameworks, modules, dependency names, and file paths. No file contents and no line numbers are sent to any provider, and the system prompt explicitly tells the model not to claim line numbers or quote code it was not given. +- **Conversation turns do leave the process, and are retained.** Alongside that structural context, the most recent turns of the AI Workspace thread are replayed to the provider so a follow-up question resolves. Those turns are user-authored text, not repository source, but they are egress and they are persisted in `ai_conversation_messages` rather than discarded at the end of a session. Interface copy must describe this accurately: the workspace does not forget, and telling a user otherwise would be a false privacy assurance. +- **Logs are redacted.** Keys containing `api_key`, `apikey`, `authorization`, `password`, `secret`, or `token` are redacted from structured log extras. Repository contents and credentials must never be logged. +- **The authenticated-user boundary is enforced.** Every non-public route requires a valid token, and every repository lookup is owner-scoped in the service layer, so a caller sees only their own data. Provider API keys are encrypted at rest and injected per user. Provider destinations are selected by a deployment-owned egress policy, not by a tenant: configured endpoints must match an exact allowlist, DNS answers are checked again immediately before the request and pinned to the connection, redirects are denied, and policy errors omit destination details. Rate-limit budgets are keyed on the validated user id for authenticated requests (falling back to the client IP otherwise), so one user cannot spend another's budget. See [AI provider egress policy](../security/AI_PROVIDER_EGRESS.md). + +--- + +## Current architectural limitations + +These are properties of the system as built, not a wish list. + +1. **Production classification remains partly heuristic.** File roles, derived modules, and layers are inferred from observed path segments and filenames and are labelled as heuristic. +2. **Line-level evidence is surface-dependent.** Syntax-aware Python and TypeScript facts can carry validated spans, but Documentation uses structural facts and free-form AI receives no source bytes, so provider answers have no automatic citations. +3. **The graph store is the sole product read model.** Durable analysis + populates immutable normalized snapshots, and every product consumer + requires the latest owner-scoped snapshot matching the current revision. + Missing or stale snapshots return 404 without fallback. +4. **Analysis is whole-repository, and its worker is still in-process.** It runs in a durable, cancellable background job with bounded retry and stale-worker recovery, claimed through an explicit control plane, but incremental re-analysis is not implemented and no standalone worker process is deployed — one API process runs one worker. Import extraction and file-tree parsing remain synchronous. +5. **The rate limiter trusts only the direct socket peer for unauthenticated requests.** `X-Forwarded-For` is deliberately ignored, so behind a reverse proxy every unauthenticated client shares one IP budget until a trusted-proxy allowlist is designed. Authenticated requests are keyed per user and unaffected. +6. **Dependency coverage is narrow.** Three manifest formats plus two lockfile formats (`package-lock.json`, `poetry.lock`), whose exact pins are recorded as resolutions on the same dependency identity rather than as direct edges. There is no transitive resolution and no vulnerability or outdated-version scanning. The API exposes explicit `not_computed` assessment statuses and does not emit a clean result or count without a scanner. +7. **Frontend assurance remains focused.** Vitest covers shared and feature + behavior, and a disposable Playwright acceptance suite exercises the defined + Architecture, Engineering Review, and Insights browser journeys. This is + not comprehensive end-to-end product coverage. + +These are missing guarantees in the system as built. They are not scheduled work, and this document does not commit to when or whether any of them change. diff --git a/docs/assets/Partha-logo v1.svg b/docs/assets/Partha-logo v1.svg new file mode 100644 index 00000000..669ed86e --- /dev/null +++ b/docs/assets/Partha-logo v1.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/docs/assets/Partha-logo v2.svg b/docs/assets/Partha-logo v2.svg new file mode 100644 index 00000000..cd25d4cd --- /dev/null +++ b/docs/assets/Partha-logo v2.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/docs/assets/partha-hero.svg b/docs/assets/partha-hero.svg index 99450191..019612d3 100644 --- a/docs/assets/partha-hero.svg +++ b/docs/assets/partha-hero.svg @@ -1,96 +1,141 @@ - - PARTHA Engineering Intelligence Platform - A dark engineering intelligence banner with a small Partha precision mark, repository graph paths, architecture layers, and actionable engineering outputs. + + PARTHA — Repository Intelligence Platform + PARTHA is being built toward a private, versioned intelligence layer for software repositories. Today it analyzes supported repository revisions into sealed ri.v1 intelligence snapshots for architecture, dependency, review, insights, documentation, exports, and optional governed AI context. + - - - - + + + + - - - - - + + + + + - - - - - - + + + + + + + - - - - - - - + + + + + + - - - + + + + + - - - - - - + + + + + + + - - - - - + - - - - - - - - - - - + Repository Intelligence Platform + Understand unfamiliar code faster with + sealed repository intelligence and governed AI context. - - - - - Repository Intelligence - Architecture Models - Engineering Outputs + + ADVANCED PROTOTYPE / PRE-ALPHA - - - - - - - - - - - - + + THE SHARED READ MODEL + + + SOURCE + SEALED SNAPSHOT + CONSUMERS + + + + 01 + Archive + + + 02 + Public GitHub - - - - - - - + + + + - + + - PARTHA - Engineering Intelligence Platform - Transform Repositories into Actionable Engineering Intelligence + + + + + + ri.v1 + SEALED + + + repository_id + revision + schema_version + producer_version_set + config_hash + + + + + + + + + + + + facts + provenance + IMMUTABLE + + + + + + + + + + + + + + + + Architecture + + + Dependency graph + + + Review + insights + + + Documentation + exports + + + AI (optional) + + + Consumers read the sealed snapshot; they do not re-parse the repository. + diff --git a/docs/assets/partha-logo.svg b/docs/assets/partha-logo.svg deleted file mode 100644 index d10c141a..00000000 --- a/docs/assets/partha-logo.svg +++ /dev/null @@ -1,38 +0,0 @@ - - PARTHA logo - A dark geometric mark inspired by Arjuna's precision, combining a minimal bow, focused arrow trajectory, target point, and repository graph nodes. - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/docs/audit/CORE_1_INGESTION_PIPELINE_AUDIT.md b/docs/audit/CORE_1_INGESTION_PIPELINE_AUDIT.md deleted file mode 100644 index 02693623..00000000 --- a/docs/audit/CORE_1_INGESTION_PIPELINE_AUDIT.md +++ /dev/null @@ -1,194 +0,0 @@ -# CORE 1 Ingestion Pipeline Audit - -Issue: GitHub Issue #11, CORE 1 — End-to-End Repository Ingestion & Analysis Pipeline -Date: 2026-07-08 -Branch: `feature/core-1-repository-ingestion` - -## Scope - -This audit traces the complete repository ingestion path: - -```text -Frontend Upload UI --> Upload Hooks --> Repository API Client --> FastAPI Routes --> Repository Service --> GitHub Clone / ZIP Extraction --> Repository Parser --> Repository Storage --> Analysis Pipeline --> Repository Intelligence --> Frontend Repository State -``` - -The objective was stabilization, not feature expansion. Fixes were made incrementally and preserve the existing frontend/backend architecture. - -## Executive Summary - -The ingestion pipeline was functional for happy-path uploads, but it mixed real backend work with frontend-owned state transitions. The most serious failure was that the frontend forced imported repositories into an `analysing` state even when the backend had already returned a different state. Backend errors were also flattened into generic messages, and some repository API calls swallowed failures entirely. - -The backend also marked imports as fully `completed` immediately, while `/analysis/{id}/start` only stamped completion metadata. This made the lifecycle misleading and prevented users from trusting the analysis screen. - -This pass fixes the highest-risk issues without introducing a large rewrite: - -- backend duplicate detection for uploads and GitHub imports; -- meaningful conflict, timeout, validation, and backend error responses; -- enforced Git clone timeout using subprocess execution; -- archive cleanup on failed imports; -- empty archive rejection; -- safer tar handling for links/devices; -- backend-owned `analysing -> completed/error` analysis state; -- frontend error messages now surface backend response messages; -- repository provider no longer hides backend load failures; -- upload/GitHub hooks no longer fabricate local `analysing` state; -- frontend refreshes repository state from the backend after analysis starts; -- cancel no longer deletes a repository from local state. - -## Pipeline Audit Table - -| Component | Current behaviour before fixes | Expected behaviour | Root cause | Severity | Recommended fix | Status | -| --- | --- | --- | --- | --- | --- | --- | -| Upload page | Accepted file/GitHub input and navigated to analysis page after hook success. | Navigate according to backend repository state. | Page assumed every import should show analysis progress. | Medium | Route completed repositories to detail; only show analysis page for backend `analysing` state. | Fixed | -| `useUpload` | Uploaded file, called analysis start, then overwrote backend response with local `status: analysing`. | Store only backend-returned repository state. | Frontend simulated lifecycle after backend response. | High | Start backend analysis, fetch repository, store refreshed backend state. | Fixed | -| `useGitHubImport` | Same local `analysing` override after GitHub import. | Store only backend-returned repository state. | Duplicate local state transition. | High | Start backend analysis, fetch repository, store refreshed backend state. | Fixed | -| Repository API client errors | Backend `message` fields were ignored for common statuses such as 422. | Display backend validation/conflict/clone messages. | `getErrorMessage` returned generic status strings before inspecting body. | High | Prefer backend `body.message`; retain network/timeout fallbacks. | Fixed | -| Backend facade `fetchRepositories` | Returned `[]` on any backend failure. | Distinguish empty repository list from failed request. | Catch-all swallowed API/network errors. | High | Let errors propagate to RepositoryProvider. | Fixed | -| Backend facade `fetchRepository` | Returned `null` on any backend failure. | Preserve backend error semantics. | Catch-all swallowed API/network errors. | Medium | Let errors propagate. | Fixed | -| Backend facade `deleteRepository` | Returned `false` on any delete failure. | Surface deletion failure. | Catch-all swallowed API/network errors. | Medium | Let errors propagate; page displays action error. | Fixed | -| RepositoryProvider | Always exposed `loading: false`, `error: null`, no retry. | Expose loading/error/retry from backend fetch. | Provider did not model request state. | High | Add loading, error, and refresh state. | Fixed | -| Repository store | `addRepository` appended records blindly. | Upsert by repository id. | Frontend could duplicate repository state. | Medium | Make `addRepository` idempotent/upsert. | Fixed | -| Local cancel analysis | Removed repository from local store only. | Do not mutate repository state without backend confirmation. | Cancel was a frontend-only simulation. | High | Cancel viewing only; preserve repository. | Fixed | -| Upload route | Accepted archive but ignored extra form fields. | Import archive and persist parsed repository. | Name is derived server-side; extra fields are currently not part of backend contract. | Low | Keep contract; use server-derived name. | Accepted | -| GitHub route | Cloned synchronously with GitPython and no enforced timeout. | Clone must have bounded execution and clear failure messages. | `clone_timeout_seconds` was stored but unused. | High | Use `git clone` subprocess with timeout and cleanup. | Fixed | -| GitHub malformed URLs | Rejected non-GitHub HTTPS URLs. | Reject malformed/non-GitHub URLs with 422. | Backend URL validation exists. | Low | Keep validation. | Working | -| GitHub branch input | Branch was passed directly to clone. | Validate branch syntax before clone. | Missing branch validation. | Medium | Add conservative branch name validation. | Fixed | -| GitHub private/invalid repos | Clone failure returned generic external error with stderr. | Return clear public/branch failure message. | Git provider failures were not normalized. | Medium | Return 502 with actionable message. | Fixed | -| Duplicate GitHub import | Duplicate URL could create duplicate repository records. | Detect duplicate URL/branch. | No repository lookup before clone. | High | Add `find_by_source` and return 409 conflict. | Fixed | -| Duplicate archive upload | Frontend checked names, backend did not. | Backend is source of truth for duplicate detection. | Validation existed only in hooks. | High | Add `find_by_name` and return 409 conflict. | Fixed | -| ZIP upload | Happy path worked. | Parse, persist, and expose analysis state. | Baseline path was functional but completion state was misleading. | Medium | Persist as `analysing` after parse; complete through analysis endpoint. | Fixed | -| TAR.GZ upload | Supported by `tarfile.is_tarfile`. | Supported and tested. | No integration test existed. | Medium | Add TAR.GZ integration test. | Fixed | -| Invalid archive | Returned unsupported archive validation for non-archive bytes. | Return meaningful 422. | Basic validation existed. | Low | Add regression test. | Fixed | -| Corrupted archive | Some extraction errors were not normalized. | Return meaningful 422 and cleanup. | Missing extraction exception handling. | Medium | Catch extraction errors and cleanup. | Fixed | -| Empty archive | Could persist an empty completed repository. | Reject empty repositories. | Parser allowed zero files. | High | Validate parsed file count before record creation. | Fixed | -| Large upload | Storage enforces max size while streaming. | Reject over configured max size. | Existing chunked size guard. | Low | Keep; add future explicit test. | Partially covered | -| Tar links/devices | Path traversal checked but links/devices were not rejected. | Reject unsafe tar member types. | Tar extraction trusted member types. | Medium | Reject symlinks, hardlinks, and device entries. | Fixed | -| Upload cleanup | Failed imports could leave extracted directories or uploaded archives. | Failed imports should not leave partial artifacts. | No cleanup on exception. | Medium | Delete upload and repository path on failure. | Fixed | -| Repository parser | Builds file tree and metadata. | Parse once and provide repository intelligence. | Parser is heuristic but functional. | Medium | Keep parser as single source; improve AST depth later. | Remaining | -| Repository persistence | Records saved and listable. | Repository visible after refresh and reusable. | Happy path worked; duplicate policy missing. | High | Add duplicate checks and refreshed frontend state. | Fixed | -| Analysis start | Stamped repository completed without running analyzers. | Execute analysis work and persist completed/error state. | Mocked lifecycle. | High | Run architecture/dependency/review builders before completion. | Fixed | -| Analysis status | Reflected stored record state. | Report backend-owned state. | Status was only as truthful as stored lifecycle. | High | Store meaningful `analysing/completed/error` transitions. | Fixed | -| Analysis cancellation | No backend endpoint. | Do not fake cancellation. | Frontend-only local deletion. | High | Remove destructive local cancel behavior. | Fixed; backend cancel remains missing | -| Repository refresh | Provider loaded list on startup. | Refresh should preserve backend truth or show error. | Fetch failures were hidden. | High | Add provider loading/error/retry. | Fixed | - -## API Audit - -| Endpoint | Response codes observed/expected | Failure handling | State consistency | Status | -| --- | --- | --- | --- | --- | -| `GET /repositories` | `200` on success. | Previously frontend swallowed failures. | Now provider surfaces request failures. | Fixed frontend handling | -| `GET /repositories/{id}` | `200` on success, `404` when missing. | Backend emits structured `not_found`. | Frontend no longer converts all failures to `null`. | Fixed frontend handling | -| `DELETE /repositories/{id}` | `204` on success, `404` when missing. | Backend emits structured errors. | Frontend no longer silently ignores delete failures. | Fixed frontend handling | -| `POST /repositories/upload` | `201` success, `409` duplicate, `422` invalid/empty/unsafe archive. | Structured backend messages are surfaced by frontend. | Failed imports do not create repository records. | Fixed | -| `POST /repositories/github` | `201` success, `409` duplicate, `422` malformed URL/branch, `502` clone failure, `504` clone timeout. | Clone failures and timeouts are normalized. | Failed imports clean repository path. | Fixed | -| `POST /analysis/{id}/start` | `200 completed` on success/idempotent completed, `200 failed` if already error, `404` missing. | Analyzer exceptions set repository `error` and raise service error. | No longer blind completion stamping. | Fixed | -| `GET /analysis/{id}/status` | `200` with `processing/completed/failed`, `404` missing. | Uses stored repository state. | Reflects backend-owned status/progress. | Fixed | -| `GET /analysis/{id}/architecture` | `200`, `404` missing. | Existing behavior. | Builds on parsed repository. | Unchanged | -| `GET /analysis/{id}/dependencies` | `200`, `404` missing. | Existing behavior. | Builds on parsed repository. | Unchanged | -| `GET /analysis/{id}/review` | `200`, `404` missing. | Existing behavior. | Builds on parsed repository. | Unchanged | - -## Frontend Audit - -| Area | Finding | Root cause | Fix | -| --- | --- | --- | --- | -| Generic “No Internet Connection” style errors | Backend validation/conflict messages were replaced by generic strings. | API error formatter ignored backend `message`. | `getErrorMessage` now prefers backend messages. | -| Empty repository page on backend failure | `fetchRepositories` returned `[]` for any failure. | Catch-all in backend facade. | Errors now propagate; provider exposes error/retry. | -| Upload fake state | Hooks set `status: analysing` locally after backend response. | Frontend duplicated backend lifecycle. | Hooks fetch backend repository after analysis start and store that state. | -| Fake progress screen | Upload always navigated to `/analysis/:id`. | UI assumed an in-progress lifecycle even when backend completed. | Upload page routes completed repos to detail. | -| Fake cancel | Cancel removed local repository only. | No backend cancel endpoint. | Cancel now leaves repository state intact and navigates back. | -| Duplicate local repository records | Store appended every imported repository. | No idempotent upsert. | `addRepository` now upserts by id. | - -## Repository Lifecycle Verification - -| Transition | Before | After | -| --- | --- | --- | -| Repository created | Created after parsing; no duplicate guard. | Created after parsing and duplicate validation. | -| Repository stored | Stored as `completed` immediately. | Stored as `analysing` with parsed file tree and metadata. | -| Repository parsed | Parser ran during import. | Parser still runs once during import. Empty parse is rejected. | -| Analysis started | Frontend called start, backend stamped completed. | Frontend calls start, backend runs analyzers and persists completion/error. | -| Analysis completed | Completed state was partly mocked. | Completed only after analyzer execution returns. | -| Repository visible after refresh | Worked on success; backend fetch failure looked empty. | Success persists; failures show load error/retry. | -| Repository reusable | Completed records could be reused. | Completed records remain reusable; duplicate local state reduced. | - -## Prioritized Bug List - -| Priority | Bug | Status | -| --- | --- | --- | -| Critical | Frontend fabricated repository `analysing` state after backend response. | Fixed | -| Critical | Analysis start blindly stamped completed. | Fixed | -| High | Backend duplicate detection missing. | Fixed | -| High | Backend facade swallowed repository load/delete errors. | Fixed | -| High | Backend error messages hidden by generic frontend messages. | Fixed | -| High | GitHub clone timeout was configured but not enforced. | Fixed | -| High | Empty archives could become completed repositories. | Fixed | -| Medium | Failed uploads/clones left partial artifacts. | Fixed | -| Medium | Unsafe tar link/device entries were not rejected. | Fixed | -| Medium | Repository cancel deleted local state only. | Fixed | -| Medium | No backend cancellation endpoint. | Remaining | -| Medium | No async queue/worker for long-running imports and analysis. | Remaining | -| Medium | Parser and intelligence layer remain heuristic. | Remaining | - -## Completed Fixes - -- Added `ConflictServiceError` and `TimeoutServiceError` for precise API semantics. -- Added repository duplicate lookup by name and by source URL/branch. -- Enforced GitHub clone timeout with `subprocess.run(..., timeout=...)`. -- Added GitHub branch validation. -- Normalized clone failures and timeout responses. -- Rejected empty parsed repositories. -- Cleaned uploaded archives and extracted repository paths on failed imports. -- Rejected tar symlink, hardlink, and device members. -- Changed imports to persist `analysing` state after successful parsing. -- Changed analysis start to execute analyzers and persist `completed` or `error`. -- Added backend ingestion tests for ZIP, TAR.GZ, invalid archive, empty archive, duplicates, GitHub validation, and clone timeout. -- Updated frontend error handling to surface backend messages. -- Removed swallowed repository API failures from the frontend backend facade. -- Added RepositoryProvider loading/error/retry state. -- Removed frontend local fake `analysing` repository overrides. -- Refreshed repository state from backend after analysis start. -- Changed upload navigation to respect backend repository status. -- Removed destructive local-only analysis cancel behavior. - -## Tests Added - -New file: `apps/backend/tests/test_ingestion_pipeline.py` - -Coverage includes: - -- ZIP upload persists repository and completes analysis through `/analysis/{id}/start`. -- TAR.GZ upload is accepted and parsed. -- Invalid archive returns backend validation error. -- Empty archive is rejected. -- Duplicate upload returns `409` conflict. -- GitHub import duplicate detection and branch validation. -- GitHub clone timeout raises `TimeoutServiceError`. - -## Remaining Issues - -| Issue | Why it remains | Recommended next step | -| --- | --- | --- | -| No true async queue | Current architecture has no worker/job table. Long clone/analysis requests still block the request. | Introduce job records and worker-backed ingestion/analysis. | -| No backend cancel endpoint | There is no cancellable job abstraction yet. | Add cancellation once async jobs exist. | -| Progress is coarse | Backend progress is state-based, not event-streamed. | Emit real job stage transitions from worker. | -| File-content preview remains generated | Parser stores tree metadata, not safe file contents. | Add safe file content/indexing endpoint separately. | -| Dependency/architecture/review analyzers are heuristic | CORE 1 stabilizes ingestion, not analysis depth. | Continue under repository intelligence/knowledge graph issues. | -| Large repository scalability | Clone/upload parsing still happens in process. | Move heavy work to background workers with limits and retention. | - -## Verification Commands - -```bash -npm run test:backend -npm --prefix apps/frontend run lint -npm run build:frontend -``` - -All three commands passed after the fixes. diff --git a/docs/audit/CORE_2_REPOSITORY_INTELLIGENCE_AUDIT.md b/docs/audit/CORE_2_REPOSITORY_INTELLIGENCE_AUDIT.md deleted file mode 100644 index a9080b24..00000000 --- a/docs/audit/CORE_2_REPOSITORY_INTELLIGENCE_AUDIT.md +++ /dev/null @@ -1,101 +0,0 @@ -# CORE 2 Repository Intelligence Engine Audit - -Issue: GitHub Issue #12, CORE 2 — Repository Intelligence Engine -Date: 2026-07-08 - -## Objective - -Transform PARTHA from independent feature analyzers into a backend platform powered by one reusable Repository Intelligence Engine. - -The target execution path is: - -```text -Repository --> Repository Parser --> Repository Intelligence Engine --> Knowledge Graph --> Feature Consumers -``` - -## Audit Table - -| Component | Responsibility | Current behaviour before refactor | Problems | Proposed refactor | Status | -| --- | --- | --- | --- | --- | --- | -| `RepositoryParser` | Build file tree and basic metadata. | Walked repository files, detected language/framework/package manager/config/entrypoint. | Correct place for first parse, but downstream features repeated derived analysis. | Keep as parser-only boundary. Intelligence Engine consumes parser output. | Done | -| `ArchitectureAnalyzer` | Build architecture graph. | Walked `record.file_tree`, grouped paths by route/service/model/config heuristics, generated feature-specific edges. | Duplicated traversal and module heuristics. | Consume `RepositoryIntelligence.modules` and discovery data. | Done | -| `DependencyGraphBuilder` | Build dependency graph. | Re-read `package.json`, `requirements.txt`, and `pyproject.toml` directly from disk. | Duplicated dependency discovery and bypassed parser/intelligence model. | Consume `RepositoryIntelligence.dependencies` and graph relationships. | Done | -| `EngineeringReviewBuilder` | Generate review findings. | Walked `record.file_tree`, inspected metadata, and repeated README/license/test/env heuristics. | Duplicated repository facts and file traversal. | Consume `RepositoryIntelligence.discovery`, statistics, and files. | Done | -| `DocumentationService` | Generate docs. | Walked `record.file_tree`, rebuilt architecture, filtered API/env/deploy files independently. | Duplicated traversal and file classification. | Consume discovery, files, API routes, and architecture from intelligence-backed consumers. | Done | -| `AiService` | Build repository context for AI. | Walked file tree and used shallow metadata only. | AI did not consume reusable intelligence or graph context. | Consume Repository Intelligence modules, dependencies, files, and discovery. | Done | -| `AnalysisService` | Orchestrate analysis. | Called feature consumers, but no durable central intelligence artifact existed. | Analysis did not guarantee a single source of truth. | Build and persist Repository Intelligence before consumer generation. | Done | -| Repository persistence | Store parsed metadata and file tree. | Stored `repo_metadata` and `file_tree`. | No persisted knowledge graph. | Persist serialized intelligence under `repo_metadata.intelligence` to avoid DB migration. | Done | - -## Duplicated Logic Removed - -| Duplicate area | Previous locations | New source of truth | -| --- | --- | --- | -| File traversal | Architecture, Review, Documentation, AI | `RepositoryIntelligenceEngine._flatten_files` | -| Dependency manifest reading | Dependency Graph | `RepositoryIntelligenceEngine._dependencies` | -| Module classification | Architecture | `RepositoryIntelligenceEngine._modules` and file roles | -| API route discovery | Documentation heuristics | `SourceFileIntelligence.api_routes` | -| Environment/deploy/config discovery | Documentation and Review | `RepositoryDiscovery` | -| AI context file selection | AI file-tree walker | `RepositoryIntelligence.files`, modules, dependencies | - -## New Central Model - -The engine produces `RepositoryIntelligence` containing: - -- repository metadata; -- discovery facts; -- source file intelligence; -- symbols; -- modules; -- dependencies; -- serializable knowledge graph nodes and relationships. - -Relationship types include: - -- `contains` -- `imports` -- `depends_on` -- `exports` - -The model is intentionally serializable so future workers, AI providers, search indexes, and hosted services can consume the same artifact. - -## Consumer Refactor Summary - -| Consumer | Refactor result | -| --- | --- | -| Architecture | Builds nodes/layers/modules from `RepositoryIntelligence.modules`. | -| Dependency Graph | Builds dependency nodes and edges from `RepositoryIntelligence.dependencies` and graph relationships. | -| Engineering Review | Uses discovery statistics, environment files, CI files, README/license facts. | -| Documentation | Uses discovery, API routes, env/deployment files, and intelligence-backed architecture. | -| AI Workspace | Uses modules, dependencies, discovery, selected files, and file list from intelligence. | -| Repository Explorer | Continues to use parsed file tree from repository record. Future enhancement can consume file intelligence directly. | -| Insights | Not implemented yet; should consume `RepositoryIntelligence` when added. | - -## Remaining Work - -| Area | Reason | -| --- | --- | -| Local import resolution | The graph records imports, but local import-to-file resolution is still shallow. | -| Call graph extraction | Symbol extraction exists, but call relationships are not yet deeply parsed. | -| Inheritance/composition | Regex/Tree-sitter integration can be expanded for language-specific relationships. | -| Database column | Intelligence is persisted inside `repo_metadata.intelligence` to avoid migration risk. A dedicated JSON column can be added later. | -| Repository Explorer | Explorer still reads `file_tree`; it does not yet expose richer file intelligence in the UI. | -| Insights | The Insights backend consumer remains future work. | - -## Verification - -Tests added in `apps/backend/tests/test_repository_intelligence.py` cover: - -- language detection; -- framework detection; -- package manager/build system detection; -- module detection; -- API route extraction; -- symbol extraction; -- dependency extraction; -- knowledge graph relationships; -- serialization/persistence; -- architecture, dependency, and review consumers. diff --git a/docs/brand/VISUAL_IDENTITY.md b/docs/brand/VISUAL_IDENTITY.md deleted file mode 100644 index 275c5c50..00000000 --- a/docs/brand/VISUAL_IDENTITY.md +++ /dev/null @@ -1,109 +0,0 @@ -# PARTHA Visual Identity - -PARTHA should feel like an engineering platform, not a generic AI assistant. - -## Brand Position - -- **Name:** PARTHA -- **Category:** Engineering Intelligence Platform -- **Tagline:** Transform Repositories into Actionable Engineering Intelligence -- **Voice:** precise, systems-oriented, trustworthy, direct -- **Avoid:** chatbot language, generic AI imagery, exaggerated automation claims - -## About the Name - -PARTHA can internally expand to: - -> Platform for Architecture, Repository Intelligence, Transformation & Heuristic Analysis - -Use this expansion only in project identity or about sections. The public brand is simply **PARTHA**. - -## Color System - -| Role | Color | Use | -| --- | --- | --- | -| Deep Space | `#070B16` | Primary dark backgrounds. | -| Graphite | `#0B1020` | Cards, logo background, elevated surfaces. | -| Slate | `#1E293B` | Borders and secondary surfaces. | -| Signal Blue | `#38BDF8` | Repository intelligence, links, primary accents. | -| System Violet | `#8B5CF6` | Architecture/model accents. | -| Knowledge Green | `#22C55E` | Success, validated outputs, completion. | -| Text Primary | `#F8FAFC` | High-contrast text on dark backgrounds. | -| Text Secondary | `#CBD5E1` | Supporting copy. | - -## Typography - -Use system UI fonts for GitHub compatibility: - -```text -Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif -``` - -In prose, prefer short sections, direct headings, and tables when comparing capabilities or responsibilities. - -## Logo - -The mark should be: - -- monochrome; -- scalable; -- recognisable at small sizes; -- based on connected system paths, not AI symbolism; -- usable on dark or light surfaces. - -Current asset: - -- `docs/assets/partha-logo.svg` - -## Hero Banner - -The banner uses: - -- dark engineering workspace background; -- repository graph nodes; -- architecture flow lines; -- module cards; -- no robots, brains, chat bubbles, or generic AI shapes. - -Current asset: - -- `docs/assets/partha-hero.svg` - -## Diagram Style - -Use Mermaid diagrams for architecture docs and README diagrams. Prefer: - -- left-to-right pipeline diagrams for workflows; -- layered graphs for subsystem architecture; -- boundary tables for responsibilities; -- explicit “does not do” columns where misuse would create technical debt. - -## Badge Style - -Use a small, functional badge set: - -- license; -- frontend runtime; -- backend runtime; -- Docker/CI; -- documentation. - -Avoid decorative badge walls. - -## Illustration Style - -Illustrations should use abstract engineering language: - -- repository graphs; -- dependency networks; -- architecture layers; -- request/data lifecycles; -- knowledge models; -- engineering outputs. - -Avoid: - -- anthropomorphic robots; -- glowing brains; -- chat bubbles as primary metaphor; -- generic “AI magic” visuals. diff --git a/docs/operations/AI_PROVIDER_SETUP.md b/docs/operations/AI_PROVIDER_SETUP.md new file mode 100644 index 00000000..82ec0141 --- /dev/null +++ b/docs/operations/AI_PROVIDER_SETUP.md @@ -0,0 +1,139 @@ +# Connecting an AI provider + +PARTHA works fully without an AI provider. Extraction, the sealed `ri.v1` +snapshot, Architecture, Dependencies, Repository Insights, and Engineering +Review are all deterministic and provider-free. A provider is only needed for +the free-form **AI workspace** ("ask about this repository"), which sends the +provider *structural context only* — module roles, dependency names, finding +titles, selected file paths — never source bytes or line spans. + +Provider configuration is **per user** and stored by your own backend. The API +key is Fernet-encrypted at rest and never returned in full (only its last four +characters). One provider configuration is active at a time; saving a new one +replaces the previous. + +## The supported providers + +| Provider | Needs API key | Needs base URL | Default model | Where the key comes from | +| --- | --- | --- | --- | --- | +| OpenAI | yes | no | `gpt-4o-mini` | | +| Anthropic | yes | no | `claude-3-5-haiku-latest` | | +| Google Gemini | yes | no | `gemini-1.5-flash` | | +| OpenRouter | yes | no | `openai/gpt-4o-mini` | | +| Ollama | no | **yes** | `llama3.2` | runs on a machine you control | + +This table is generated in code from one source +(`apps/backend/app/ai/providers/capabilities.py`) and served at +`GET /ai/providers`; the Settings UI renders it rather than hardcoding it, so it +never drifts from what the save/test flow actually requires. + +The four hosted providers reach only their own code-owned HTTPS origins and do +**not** accept a base URL. Only Ollama has a configurable endpoint, and that +endpoint is governed by the deployment's egress policy — see +[Before you start: Ollama and any custom endpoint](#before-you-start-ollama-and-any-custom-endpoint). + +## The recommended way: Settings → AI Providers + +1. Sign in, open **Settings**, and select the **AI Providers** tab. +2. Click the provider you want. The panel shows that provider's short setup + checklist and a link to its official key page. +3. Fill in what the provider needs: + - **Provider model ID** — pre-filled with the default above; change it only + if you want a specific model. A model the provider does not recognise is + the most common cause of a failed request. + - **Base URL** — Ollama only. The origin where Ollama is listening, e.g. + `http://localhost:11434`. No trailing path. + - **API key** — the hosted providers. Pasted once; after saving, the field + shows `•••• 1234` and an empty save keeps the stored key. +4. Click **Test Connection**. This sends one real request through the same + egress policy and transport a live query uses, and reports back in plain + language without storing anything. Fix any error before saving. +5. Click **Save Provider**. The status pill changes to `Saved: `. +6. Open the AI workspace for an analysed repository and ask a question. + +### The same flow over the API + +| Step | Call | +| --- | --- | +| List providers and their requirements | `GET /ai/providers` | +| Read current config (no secret) | `GET /ai/config` | +| Save / replace config | `PUT /ai/config` with `{ "provider", "apiKey?", "model?", "baseUrl?" }` | +| Test without saving | `POST /ai/test` with the same shape (omit `apiKey` to test the stored one) | +| Ask a question | `POST /ai/query` with `{ "repositoryId", "query", "context?" }` | + +Every `ai/*` route requires authentication and is owner-scoped: a query can +never run against another user's repository or spend their key. + +## Before you start: Ollama and any custom endpoint + +The four hosted providers work in every environment with just a key. **A +configurable Ollama base URL additionally requires deployment configuration**, +because PARTHA treats an outbound AI destination as a security boundary: + +- `AI_EGRESS_MODE` defaults to `hosted`. In that mode an Ollama base URL is + accepted only if it exactly matches an entry in `AI_EGRESS_ALLOWED_BASE_URLS` + and every DNS answer for it is public unicast. +- A **local or private-network** Ollama (`localhost`, `127.0.0.1`, a + `192.168.x.x` box) needs `AI_EGRESS_MODE=self_hosted`, an exact + `AI_EGRESS_ALLOWED_BASE_URLS` entry, and a matching `AI_EGRESS_ALLOWED_CIDRS` + range. + +```dotenv +AI_EGRESS_MODE=self_hosted +AI_EGRESS_ALLOWED_BASE_URLS=http://localhost:11434 +AI_EGRESS_ALLOWED_CIDRS=127.0.0.1/32 +``` + +These are deployment-owned environment settings, not user form fields. A bad +value stops the backend from starting rather than weakening the policy. The +policy is enforced twice — once when the config is saved (a bad URL is a normal +`422`, and the existing record is left untouched) and again immediately before +every outbound request. The full rules, including how DNS is pinned and how +redirects are handled, are in the +[AI provider egress policy](../security/AI_PROVIDER_EGRESS.md); read it before +configuring any custom or local endpoint. + +## Ollama specifics + +Ollama runs inference on the machine you point it at, so its behaviour differs +from a hosted API in ways worth knowing: + +- **The first request after startup is slow.** Ollama loads the model into + memory before generating a single token. A review-sized prompt on CPU can + take minutes. PARTHA allows a long read budget for Ollama (a tight 10s + connect so a wrong URL fails fast, then up to 10 minutes for the response) so + a slow-but-healthy generation is not cut off. Hosted providers keep a 60s + timeout. +- **One request at a time.** PARTHA serialises its own requests to Ollama; + additional requests queue rather than fail. Ollama already serialises token + generation by default, so more concurrency would only raise memory pressure + without finishing anything sooner. +- **Pull the model first.** `ollama pull llama3.2` (or whichever model ID you + set) before testing the connection, or the first request fails with an + "unsupported model" style error. +- **Keep it running.** If Ollama is stopped or the base URL is wrong, the test + and every query return "Could not reach the AI provider…" within the connect + timeout. + +## Troubleshooting + +| Symptom | Likely cause | Fix | +| --- | --- | --- | +| Save returns `422 validation_error`, "destination is not permitted" | The base URL is not in the deployment allowlist, or the mode is `hosted` for a local endpoint | Set `AI_EGRESS_MODE` / `AI_EGRESS_ALLOWED_BASE_URLS` / `AI_EGRESS_ALLOWED_CIDRS` to match the exact URL, then save again | +| "AI provider rejected the API key" | Wrong, revoked, or expired key | Regenerate the key at the provider and paste it again | +| "AI provider rejected the request… unsupported model ID" | The model ID is not one the provider serves (or not pulled, for Ollama) | Correct the model ID; for Ollama run `ollama pull ` | +| "Could not reach the AI provider…" | Self-hosted provider not running, or wrong base URL | Start Ollama; confirm the origin and port | +| "AI provider did not respond in time" (hosted) | Provider slow or overloaded | Retry shortly | +| Query works, answers have no citations | Expected | Free-form answers are intentionally uncited — the provider never receives source content or line numbers | + +## What a provider never receives + +- Source file contents or line spans. +- Another user's repository, configuration, or key. +- Anything, if `AI_EGRESS_MODE` and the allowlist do not explicitly permit the + destination. + +See also: [AI provider egress policy](../security/AI_PROVIDER_EGRESS.md), +[Backend README → AI Workspace endpoints](../../apps/backend/README.md#ai-workspace-endpoints), +[Repository Intelligence](../architecture/REPOSITORY_INTELLIGENCE.md) for what +"structural context only" means. diff --git a/docs/operations/DATABASE_MIGRATION_REHEARSAL.md b/docs/operations/DATABASE_MIGRATION_REHEARSAL.md new file mode 100644 index 00000000..b7f647d6 --- /dev/null +++ b/docs/operations/DATABASE_MIGRATION_REHEARSAL.md @@ -0,0 +1,173 @@ +# Database migration rehearsal and recovery + +This runbook is the operational gate for schema work tracked by [#322](https://github.com/Second-Origin/PARTHA/issues/322), +and for backup/restore rehearsal tracked by [#323](https://github.com/Second-Origin/PARTHA/issues/323). +It documents a reproducible rehearsal, not a promise that all historical data can be downgraded safely. + +## Current support and evidence + +PARTHA uses one linear Alembic chain, from `0001_initial` through current head +`0016_approved_emails`. SQLite is the local-development default and is the local +maintainer rehearsal target. PostgreSQL is the supported deployment and CI dialect: the Backend CI job runs +this rehearsal command against its isolated PostgreSQL 16 service after the backend tests. Repository files +are outside the database, under `STORAGE_PATH`; a database restore does not recreate missing repository +storage. + +Every current revision has a `downgrade()` function. That only proves a schema reversal can be attempted: +some revisions drop tables, indexes, or columns. In particular, `0005_revision_snapshots` deliberately +discards snapshot data on downgrade. A downgrade is therefore **not** a production rollback guarantee. + +## Rehearse before a schema release + +From `apps/backend`, with the backend dependencies installed: + +```bash +python scripts/rehearse_migrations.py +``` + +The default command has no database-URL argument and creates two temporary SQLite files itself. It proves: + +1. an empty database upgrades to the current head; +2. that same empty target can downgrade to base and upgrade to head again; and +3. a representative `0004_ai_provider_configs` database containing an old-format GitHub import upgrades + to head with its revision identity backfilled and its legacy metadata retained. + +The command removes its own temporary targets. It never opens the local `.local/partha.db`, a configured +application `DATABASE_URL`, or a production/shared database. Failures name the failed phase and error class +without printing a database URL or credentials. + +To exercise the deployment dialect, use a **dedicated rehearsal PostgreSQL server only**, running +PostgreSQL 13 or newer (cleanup issues `DROP DATABASE ... WITH (FORCE)`, added in PostgreSQL 13; on an +older server the disposable database is left behind and must be dropped manually). The server URL must be +supplied through the environment, and the explicit confirmation prevents accidental use: + +```bash +export PARTHA_MIGRATION_REHEARSAL_PG_URL='postgresql+psycopg://…/postgres' +export PARTHA_MIGRATION_REHEARSAL_CONFIRM=disposable +python scripts/rehearse_migrations.py --postgres +``` + +The command creates only a randomly named `partha_migration_rehearsal_` database and removes that +same database in cleanup, even when the rehearsal itself fails. If cleanup fails independently (for example +the rehearsal server drops the connection mid-teardown), the command prints a `WARNING` naming the orphaned +database so an operator can drop it manually; that name is a random identifier and never contains +connection details. It does not accept a target database name. Do not set these variables to a production, +staging, or shared server; the confirmation is an operator assertion, not an access-control boundary. A CI +job can set them only for an isolated service container, as the existing backend job does. + +## Backup and restore rehearsal (#323) + +`scripts/rehearse_backup_restore.py` proves the same two things a real recovery needs proven, on +disposable targets it creates and removes itself -- it cannot be pointed at an existing application +database or storage directory, and is safe to run repeatedly: + +1. a database backup can be restored into a clean environment with every row, foreign key, and the sealed + ri.v1 snapshot's `canonical_graph_hash` intact; and +2. the paired `STORAGE_PATH` repository files restore alongside it, byte-for-byte (verified by content + hash, not just file presence). + +```bash +python scripts/rehearse_backup_restore.py +``` + +The default target is SQLite: backup and restore are each a plain file copy, matching how SQLite itself +defines a consistent backup. This always runs, with no extra tooling. + +To exercise the deployment mechanism (`pg_dump`/`pg_restore`), use the same dedicated rehearsal server and +confirmation gate as the migration rehearsal above -- and note this mode additionally requires the +`pg_dump`/`pg_restore` **client binaries** on `PATH` (not just a Python Postgres driver): + +```bash +export PARTHA_MIGRATION_REHEARSAL_PG_URL='postgresql+psycopg://…/postgres' +export PARTHA_MIGRATION_REHEARSAL_CONFIRM=disposable +python scripts/rehearse_backup_restore.py --postgres +``` + +Each run prints the wall-clock seed and restore duration; treat these only as a rehearsal-scale sanity +number, not a production capacity-planning figure -- disposable-target file copies and a single-row seed do +not reflect production database size or network/disk throughput. + +**Known gaps, not covered by this rehearsal:** + +- Encryption at rest and backup retention windows are the hosting provider's responsibility (Render-managed + PostgreSQL), not application code -- see "Retention, encryption, access, and deletion" below for the + expected policy, which cannot itself be rehearsed by a local script. +- This rehearses a clean, quiescent restore, not a restore under concurrent production write load, and not + point-in-time recovery to a timestamp between two backups. +- Filesystem backup here is a directory copy of the local storage backend. A different storage backend + (e.g. object storage) would need its own rehearsal. +- Not currently wired into CI (unlike the migration rehearsal above): `pg_dump`/`pg_restore` binary + availability on the CI runner has not been confirmed. Confirm it, then add a CI step mirroring "Rehearse + migrations on isolated PostgreSQL" before relying on this running unattended. + +## Retention, encryption, access, and deletion expectations + +Recorded as the expected policy for the production deployment described in `PARTHA_LIVE_HOSTING_PLAN.md` +(one Render-managed PostgreSQL database plus a persistent disk for repository storage). This is a policy +record, not a claim that every item has been independently verified against the live Render dashboard -- +confirm each one there before the first real backup is relied upon. + +- **Retention:** managed automated backups, retained per the Render PostgreSQL plan's stated window. + Confirm the exact window in the Render dashboard for the provisioned plan before depending on it; do not + assume a specific number of days without checking, since provider retention policy can change. +- **Encryption:** encryption in transit (TLS) for all client connections, and encryption at rest as + provided by the hosting platform for its managed PostgreSQL and persistent disk offerings. This is a + platform guarantee, not something PARTHA's application code implements or can rehearse. +- **Access:** database credentials live only in Render's environment configuration (`DATABASE_URL`), never + committed to source control or logged; access to backups and the ability to trigger a restore is limited + to the same operators who hold platform/dashboard access, not exposed through any PARTHA application + surface. +- **Deletion:** a restored backup used for drilling (rehearsal or an actual incident) must itself be + deleted once validation is complete -- it is a second copy of real user data for as long as it exists. + Account-level deletion (a user exercising their own account-deletion right) is a separate, already-shipped + concern (`AccountDeletionService`, #290): it is a live-database cascade at request time, not a backup + operation, and is out of scope here. A restored *backup* containing an account that has since been + deleted in production is expected during the drill window; do not treat that as a bug in account + deletion, and destroy the drill copy promptly once the drill is done. + +## Production preflight and recovery decision + +Before applying a migration to a non-disposable database: + +1. Confirm the live revision with `alembic current`, the intended head with `alembic heads`, and that the + deployment code and migration artifact are the reviewed release. +2. Take and verify a restorable database backup using the platform-approved mechanism, and rehearse the + restore procedure itself with `python scripts/rehearse_backup_restore.py --postgres` (see above) if it + has not been rehearsed recently. Record the backup location, timestamp, source revision, restore owner, + and tested restore result outside logs and source control. +3. Back up or otherwise preserve the matching `STORAGE_PATH` data. Database and repository-storage recovery + must use a compatible point in time. +4. Quiesce writers and background workers, announce the maintenance window, and ensure one migration owner. + DDL, index creation, table rewrites (including SQLite batch operations), and data backfills can wait on + locks or run longer than normal request timeouts. Monitor locks, database capacity, and migration logs. +5. Run `alembic upgrade head` once. Do not use application startup auto-creation or `alembic stamp` as a + substitute for a reviewed production migration. + +If the migration fails before committing, stop writers, retain the error and exact revision, and assess the +schema with the database operator. Do not repeatedly retry or run an unreviewed downgrade. If a migration +has committed, is data-bearing, or its downgrade would discard data, choose restore: keep writes quiesced, +restore the verified database backup and compatible `STORAGE_PATH` snapshot to an isolated recovery target, +validate application integrity there, then perform the approved cutover. Point-in-time recovery and final +traffic cutover remain the database/platform operator's responsibility. + +Use a downgrade only when the specific revision's review explicitly says it is data-preserving for the +actual live data and it has been rehearsed against an equivalent disposable backup. Otherwise restore is +the rollback path. + +## Future Repository Lineage (#299) migration review checklist + +This is a gate checklist only. It does not authorize or implement Repository Lineage. PR #328 records +the approved architecture contract, and this rehearsal must be rerun against the eventual #299 migration +before that implementation PR merges. + +- [ ] Re-run this rehearsal on the then-current chain and on a representative disposable copy/fixture of the + immediately preceding supported baseline. +- [ ] Review the proposed revision for table rewrites, backfill duration, lock modes, indexes, transaction + boundaries, concurrent imports, and the PostgreSQL/SQLite differences actually supported by the change. +- [ ] Define which fields and rows a downgrade would discard; prove a downgrade only if it is safe for the + representative data, otherwise test the backup/restore recovery path. +- [ ] Re-run `python scripts/rehearse_backup_restore.py --postgres` (see "Backup and restore rehearsal" + above) against the then-current schema before merging the implementation PR, to confirm the #299 schema + change doesn't break the backup/restore integrity checks. +- [ ] Confirm no lineage models, revisions, APIs, services, frontend fields, or backfill code are included in + this operational-gate work. diff --git a/docs/operations/ITERATION_1_ENGINEER_FEEDBACK.md b/docs/operations/ITERATION_1_ENGINEER_FEEDBACK.md new file mode 100644 index 00000000..aa70a459 --- /dev/null +++ b/docs/operations/ITERATION_1_ENGINEER_FEEDBACK.md @@ -0,0 +1,50 @@ +# Iteration 1 engineer feedback + +PARTHA Iteration 1 applies the current product design to the existing authenticated workflow. It does not add new repository facts or product surfaces: Architecture, Dependencies, Engineering Review, Insights, Documentation, exports, and optional AI continue to consume the selected sealed Repository Intelligence snapshot. + +## What to test + +Use a repository you are authorised to inspect and complete these tasks: + +1. Create an account or sign in. +2. Upload a supported archive, or import a public GitHub repository URL. +3. Wait for analysis to complete, then move between Dashboard, Architecture, Dependency Graph, Engineering Review, Documentation, and Insights. +4. Resize the browser to a narrow mobile viewport and confirm navigation and page controls remain usable without page-level horizontal scrolling. +5. In Architecture, switch among Graph, Request Flow, Heatmap, and List View. Confirm graph labels remain readable and revision-manifest controls remain reachable. +6. In Settings → AI Providers, save a provider configuration and use Test Connection. Then open AI Workspace and submit a question. + +AI is optional. Provider questions receive structural context from the sealed snapshot; PARTHA does not send repository source-file contents through this workflow, and provider prose has no automatic source citations. + +**Before you use the AI Workspace, know what it keeps.** Your questions and the +provider's answers are stored server-side, one thread per repository, and are +restored when you come back to the page — the workspace does not forget at the +end of a session. The most recent turns are also replayed to your configured +provider as context on each new question. There is currently no way to clear a +thread from the interface; it is removed only when the repository is deleted. +Treat anything you type there as retained, and keep secrets and personal data +out of your questions. + +## What to report + +Open a GitHub issue using the appropriate template and include: + +- the task you were trying to complete; +- expected and actual behaviour; +- browser, operating system, and viewport size; +- the affected PARTHA page; +- a screenshot or short recording with credentials, repository secrets, email addresses, and other personal data removed; +- whether the problem blocks the workflow or has a workaround. + +Do not attach private source code, provider keys, session tokens, `.env` files, or unredacted repository content. + +## Local verification + +Before proposing a frontend change, run: + +```bash +npm --prefix apps/frontend run test +npm run lint:frontend +npm run build:frontend +``` + +For the full repository validation sequence, follow [CONTRIBUTING.md](../../CONTRIBUTING.md). diff --git a/docs/operations/dependency-management.md b/docs/operations/dependency-management.md deleted file mode 100644 index 901e8203..00000000 --- a/docs/operations/dependency-management.md +++ /dev/null @@ -1,56 +0,0 @@ -# Dependency Management - -This document defines the dependency maintenance baseline for PARTHA. - -## Policy - -- Keep dependency updates reviewable and separate from feature work. -- Prefer patch/minor updates unless a major update has a migration plan. -- Do not commit generated dependency output unless it is the package manager lockfile or required project metadata. -- Treat security advisories as engineering work, not drive-by cleanup. - -## Frontend - -The frontend uses npm with `apps/frontend/package-lock.json`. - -Routine checks: - -```bash -npm ci --prefix apps/frontend -npm --prefix apps/frontend run lint -npm --prefix apps/frontend run build -npm audit --prefix apps/frontend -``` - -When updating dependencies: - -```bash -npm --prefix apps/frontend update -``` - -Review lockfile changes before committing. - -## Backend - -The backend dependencies are declared in `apps/backend/pyproject.toml`. - -Routine checks: - -```bash -cd apps/backend -python -m pip install -e . -python -m pytest -python -m pip list --outdated -``` - -When adding a backend dependency, keep it narrowly scoped and document why the standard library or existing dependencies are insufficient. - -## Security Updates - -For security updates: - -1. Identify the affected package and vulnerable versions. -2. Confirm whether the vulnerable code path is used. -3. Update the dependency. -4. Run relevant frontend/backend validation. -5. Include advisory links and risk notes in the PR. diff --git a/docs/operations/observability.md b/docs/operations/observability.md deleted file mode 100644 index fd5489c9..00000000 --- a/docs/operations/observability.md +++ /dev/null @@ -1,51 +0,0 @@ -# Observability - -PARTHA includes a minimal built-in observability baseline for local, staging, and controlled production deployments. - -## Request IDs - -Every backend response includes `X-Request-ID`. - -- If a request includes `X-Request-ID`, PARTHA preserves it. -- If it is absent, PARTHA generates one. -- JSON logs include `request_id` when available. -- Standard backend error responses include `request_id` for support correlation. - -## Logs - -Set `LOG_FORMAT=json` for structured container logs. Text logs are intended for local development. - -Sensitive log fields are redacted when emitted through structured `extra` data. Keys containing these fragments are redacted: - -- `api_key` -- `apikey` -- `authorization` -- `password` -- `secret` -- `token` - -Do not log repository source contents, provider credentials, database URLs with credentials, or uploaded archive content. - -## Metrics - -`GET /metrics` exposes plain-text counters: - -- total HTTP requests; -- cumulative request duration; -- requests by status family; -- requests by method, route, and status code. - -These counters are intentionally small and dependency-free. A production deployment can scrape or adapt them, but external monitoring should own alerting, retention, and dashboards. - -## Health Checks - -Use: - -- `/health` for process liveness; -- `/ready` for database and writable-storage readiness. - -`/ready` returns `503` when a required dependency check fails. - -## Tracing Baseline - -PARTHA does not yet ship OpenTelemetry instrumentation. The request ID contract is the current trace-correlation baseline. If OpenTelemetry is added later, it should preserve `X-Request-ID` compatibility. diff --git a/docs/operations/production-deployment.md b/docs/operations/production-deployment.md deleted file mode 100644 index 9708111e..00000000 --- a/docs/operations/production-deployment.md +++ /dev/null @@ -1,86 +0,0 @@ -# Production Deployment - -This guide describes the production deployment baseline for PARTHA. It is intentionally platform-neutral: the current repository ships a backend container and Docker Compose development stack, while production hosting can be Docker, a VM, or a managed container platform. - -## Deployment Model - -PARTHA currently supports a controlled single-operator deployment model: - -- FastAPI backend served by the backend Docker image. -- PostgreSQL database managed outside the application container. -- Redis endpoint configured for workflows that need it. -- Writable persistent storage mounted at `STORAGE_PATH`. -- Frontend built with Vite and served by a static host or reverse proxy. -- TLS, domain routing, and network policy handled by the hosting platform or reverse proxy. - -PARTHA does not yet include authentication, authorization, tenant isolation, or multi-user account management. Do not expose a deployment as a public multi-user SaaS until those controls are implemented. - -## Required Environment - -| Variable | Production guidance | -| --- | --- | -| `APP_ENV` | Set to `production`. | -| `LOG_LEVEL` | Use `INFO` by default; use `WARNING` for quieter environments. | -| `LOG_FORMAT` | Use `json` for hosted/container logs. | -| `DATABASE_URL` | Use a managed PostgreSQL URL with TLS where available. | -| `REDIS_URL` | Use a managed Redis URL if Redis-backed workflows are enabled. | -| `STORAGE_PATH` | Mount persistent storage; the app writes uploaded and cloned repository artifacts here. | -| `CORS_ORIGINS` | Set explicit HTTPS frontend origins. Do not use wildcards. | -| `AUTO_CREATE_TABLES` | Set `false`; run migrations explicitly. | -| `CLONE_TIMEOUT_SECONDS` | Tune for repository size and hosting limits. | -| `MAX_UPLOAD_SIZE_BYTES` | Keep aligned with reverse proxy and platform upload limits. | - -Secrets such as database credentials, Redis credentials, and future provider credentials must be supplied through the hosting platform secret manager. Do not bake secrets into images, Compose files, or committed env files. - -## Startup Procedure - -1. Build and publish the backend image from `apps/backend/Dockerfile`. -2. Build the frontend with `npm run build:frontend`. -3. Provision PostgreSQL and Redis. -4. Mount persistent storage for `STORAGE_PATH`. -5. Run database migrations: - - ```bash - cd apps/backend - alembic upgrade head - ``` - -6. Start the backend with: - - ```bash - uvicorn app.main:app --host 0.0.0.0 --port 8000 - ``` - -7. Serve frontend static assets from `apps/frontend/dist`. -8. Configure the frontend `VITE_API_URL` at build time to point at the backend origin. - -## Health and Readiness - -Use: - -- `GET /health` for lightweight liveness. -- `GET /ready` for database and writable-storage readiness. -- `GET /metrics` for basic plain-text runtime counters. - -Production load balancers should use `/ready` for traffic routing and `/health` for basic process liveness. Alert when `/ready` fails or 5xx metrics increase. - -## Rollback - -Rollback should be image/tag based: - -1. Keep the previous backend image available. -2. Keep the previous frontend artifact available. -3. Roll back application containers first. -4. Roll back database migrations only when a migration explicitly documents a safe downgrade. -5. Verify `/ready` and smoke-test repository list/import flows after rollback. - -## Operational Limits - -Before a public multi-user deployment, complete: - -- authentication and authorization; -- source retention and deletion policy; -- provider secret management; -- request and data retention policy; -- abuse controls for repository import and upload size; -- external monitoring and alerting. diff --git a/docs/operations/release-management.md b/docs/operations/release-management.md deleted file mode 100644 index 68f2fe51..00000000 --- a/docs/operations/release-management.md +++ /dev/null @@ -1,57 +0,0 @@ -# Release Management - -PARTHA uses pull requests into `dev` for integration and `main` for stable release snapshots. - -## Versioning - -Use SemVer-style tags: - -```text -vMAJOR.MINOR.PATCH -``` - -Examples: - -- `v0.1.0` for the first operational baseline. -- `v0.1.1` for a patch release. -- `v0.2.0` for compatible feature or platform additions. - -## Release Workflow - -The `.github/workflows/release.yml` workflow validates release candidates on `v*` tags. It runs: - -- frontend install, lint, and build; -- backend install and tests; -- backend Docker image build; -- Docker Compose runtime readiness validation; -- GitHub Release note generation for tag pushes. - -## Release Checklist - -Before tagging: - -- PRs are merged through review. -- CI is green on `dev`. -- The release branch or `main` contains the intended commits. -- `README.md` and docs match shipped behavior. -- New migrations have been run against a staging database. -- Rollback notes are clear for any persistence changes. - -Create a release tag: - -```bash -git checkout main -git pull origin main -git tag v0.1.0 -git push origin v0.1.0 -``` - -## Hotfixes - -For urgent fixes: - -1. Branch from `main`. -2. Apply the smallest safe fix. -3. Run the release validation workflow. -4. Merge back into `main`. -5. Forward-merge or cherry-pick into `dev`. diff --git a/docs/product/PUBLIC_FACE_AUDIT.md b/docs/product/PUBLIC_FACE_AUDIT.md deleted file mode 100644 index d0288925..00000000 --- a/docs/product/PUBLIC_FACE_AUDIT.md +++ /dev/null @@ -1,140 +0,0 @@ -# PARTHA Public Face Audit - -This audit captures the reasoning behind the README, documentation, and visual identity redesign. It is intentionally grounded in the current implementation and avoids claiming future functionality as shipped. - -## 1. Repository Audit - -### Current Product Surface - -PARTHA currently provides: - -- repository import from ZIP/TAR archives and public GitHub URLs; -- repository file tree, metadata, and real file preview; -- a Repository Intelligence Engine that derives reusable repository facts; -- architecture modelling from repository intelligence; -- dependency inventory and dependency graph response shapes; -- engineering review findings, scores, and roadmap suggestions; -- documentation generation from repository intelligence; -- export pipeline for review, architecture, dependencies, and documentation in JSON, Markdown, HTML, and PDF; -- AI Workspace backed by provider configuration and repository context; -- dedicated provider implementations for OpenAI, Anthropic, Gemini, OpenRouter, and Ollama; -- health, readiness, request IDs, metrics, Docker Compose runtime validation, and release workflow baseline. - -### Current Limits - -PARTHA does not currently provide: - -- authentication, authorization, tenant isolation, or multi-user SaaS controls; -- vulnerability or outdated dependency scanning; -- persisted graph tables beyond serialized repository intelligence; -- deep semantic change-impact analysis; -- full OpenTelemetry tracing; -- frontend unit/e2e test coverage; -- production hardening for public multi-user deployment. - -### Product Positioning Problem - -The previous README positioned PARTHA as “AI-powered repository intelligence,” which was directionally correct but too close to “AI chatbot over a repo.” The stronger position is: - -> PARTHA is an Engineering Intelligence Platform. - -That framing makes Repository Intelligence the foundation rather than the final product. The product story becomes: - -```text -Repository Intelligence --> Architecture Intelligence --> Engineering Intelligence --> Software Change Intelligence --> Engineering Decision Intelligence -``` - -## 2. Open-Source Documentation Benchmark - -Mature open-source projects tend to do five things well: - -1. **Immediate category clarity** — Supabase and LangGraph state what they are in the first sentence. -2. **Problem-before-feature framing** — Sourcegraph, Greptile, and PostHog explain the engineering pain before listing capabilities. -3. **Fast orientation** — OpenTelemetry and Supabase make installation, docs, and contribution paths easy to find. -4. **Honest boundaries** — strong projects distinguish current capability from roadmap. -5. **Documentation map** — mature repos separate README marketing/orientation from deep architecture, operations, and contribution docs. - -PARTHA should adopt those patterns without imitating their voice or claiming maturity it does not yet have. - -## 3. Documentation Audit - -| File | Recommendation | Why | -| --- | --- | --- | -| `README.md` | Rewrite | Needs public positioning, product narrative, clear current capabilities, quick start, architecture, and roadmap. | -| `CONTRIBUTING.md` | Rewrite | Current guide is useful but should become a complete open-source contributor guide with setup, branch, PR, tests, docs, and review standards. | -| `docs/README.md` | Expand | Should become the documentation index and recommended hierarchy. | -| `docs/architecture/REPOSITORY_INTELLIGENCE_ENGINE.md` | Expand | Good boundary doc; needs richer Mermaid diagrams, data lifecycle, and consumer flow. | -| `docs/architecture/AI_ARCHITECTURE.md` | Expand | Good AI foundation doc; needs provider/context/prompt lifecycle diagrams and clearer non-goals. | -| `docs/operations/production-deployment.md` | Keep / expand later | Useful baseline; should remain operations-specific. | -| `docs/operations/release-management.md` | Keep | Clear release workflow baseline. | -| `docs/operations/dependency-management.md` | Keep | Good dependency policy baseline. | -| `docs/operations/observability.md` | Keep | Good observability baseline. | -| `docs/audit/*` | Keep | Audit evidence should stay separate from public README narrative. | -| `apps/backend/README.md` | Keep / expand later | Useful app-local quick reference. | -| `apps/frontend/README.md` | Expand later | Too thin for frontend contributors but acceptable outside this redesign. | -| `scripts/README.md` | Keep | Small and sufficient. | -| `packages/README.md` | Keep | Correctly states reserved status. | - -## 4. Recommended Documentation Hierarchy - -```text -docs/ - README.md - assets/ - partha-hero.svg - partha-logo.svg - product/ - PUBLIC_FACE_AUDIT.md - brand/ - VISUAL_IDENTITY.md - architecture/ - REPOSITORY_INTELLIGENCE_ENGINE.md - AI_ARCHITECTURE.md - operations/ - production-deployment.md - release-management.md - dependency-management.md - observability.md - audit/ - CORE_1_INGESTION_PIPELINE_AUDIT.md - CORE_2_REPOSITORY_INTELLIGENCE_AUDIT.md -``` - -Future additions should be real docs, not empty placeholders: - -- `docs/architecture/SYSTEM_ARCHITECTURE.md` after subsystem boundaries stabilize further; -- `docs/development/frontend.md` after frontend testing and contribution patterns mature; -- `docs/decisions/` only when actual architecture decision records are written. - -## 5. Branding Recommendations - -- Public name: **PARTHA**. -- Category: **Engineering Intelligence Platform**. -- Tagline: **Transform Repositories into Actionable Engineering Intelligence**. -- Supporting sentence: **Understand systems, assess change impact, and make engineering decisions with confidence.** -- Keep the internal acronym expansion out of the hero; place it only in a project identity section. - -## 6. Documentation Roadmap - -### Current Milestone - -- Establish public positioning. -- Clarify implemented capabilities. -- Document Repository Intelligence as the architectural source of truth. -- Provide clear local setup and contribution flow. - -### Next Milestones - -- Add frontend-specific development guide after tests exist. -- Add architecture decision records for major system boundaries. -- Add API examples once endpoint contracts stabilize further. -- Add screenshots when the public demo UI is ready. -- Add security/auth documentation when public multi-user controls are implemented. - -## 7. Maintainer Notes - -The README should stay honest about maturity. PARTHA can present a strong long-term Engineering Intelligence vision while clearly saying that current analysis remains heuristic in places and public multi-user SaaS controls are not implemented yet. diff --git a/docs/security/AI_PROVIDER_EGRESS.md b/docs/security/AI_PROVIDER_EGRESS.md new file mode 100644 index 00000000..fc6f5f92 --- /dev/null +++ b/docs/security/AI_PROVIDER_EGRESS.md @@ -0,0 +1,130 @@ +# AI provider egress policy + +PARTHA treats an AI provider destination as a deployment security boundary. An +authenticated user may choose from the supported providers and supply their own +provider credential, but a deployment administrator controls the set of network +destinations the API is allowed to contact. + +This document describes the application control. It does **not** replace a +production network egress control. + +## Safe default and modes + +`AI_EGRESS_MODE` defaults to `hosted`. This is deliberately fail-safe in every +environment, including when deployment configuration is incomplete. + +| Mode | Fixed cloud providers | Configurable Ollama endpoint | +| --- | --- | --- | +| `hosted` | Only their code-owned HTTPS origins are accepted; each DNS answer must be public unicast. | Requires an exact administrator-owned base URL in `AI_EGRESS_ALLOWED_BASE_URLS`; every DNS answer must be public unicast. | +| `self_hosted` | Only their code-owned HTTPS origins are accepted; each DNS answer must be public unicast. | Requires both an exact administrator-owned base URL and that every current DNS answer is within `AI_EGRESS_ALLOWED_CIDRS`. This is the only mode for a local or internal endpoint. | + +There are no wildcard hosts, wildcard paths, user-supplied CIDRs, or fallback +local endpoints. Built-in OpenAI, Anthropic, Gemini, and OpenRouter requests do +not accept a `baseUrl` at all. + +## Configuration + +All three values are deployment-owned environment settings: + +```dotenv +AI_EGRESS_MODE=self_hosted +AI_EGRESS_ALLOWED_BASE_URLS=http://ollama.example:11434 +AI_EGRESS_ALLOWED_CIDRS=192.0.2.0/24 +``` + +Use a comma-separated list if more than one administrator-approved endpoint or +network is required. Base URLs are compared after safe normalization of scheme, +IDNA hostname, one trailing DNS dot, default port, and trailing base-path slash. +The request must still be under that exact normalized base path. + +The settings parser rejects an invalid mode, malformed URL, malformed CIDR, +credentials in a URL, fragments, non-HTTP(S) scheme, missing host, ambiguous +host spelling, invalid port, and ambiguous path encoding. A bad policy setting +prevents startup rather than weakening the policy. + +For a local Ollama installation, use `self_hosted`, an exact local base URL, and +the smallest matching CIDR. Do not expose these values in authenticated-user +forms, and do not add a wildcard so an authenticated user can route the API to +arbitrary hosts. + +## Enforcement lifecycle + +The policy runs twice: + +1. `PUT /ai/config` validates a configurable base URL and its current DNS + answers before the provider configuration record is created or changed. A + denial is the normal `422 validation_error` response and leaves the existing + record untouched. +2. The common provider sender validates again immediately before every outbound + request. This covers `/ai/test` and `/ai/query`, including + configurations saved before this feature existed. + +At request time PARTHA resolves the original hostname, validates **every** +answer, then connects HTTPX to one validated IP literal. The original `Host` +header and HTTPS SNI hostname are retained, so TLS certificate validation stays +enabled while the HTTP client has no reason to look up the original hostname a +second time. Environment proxy variables are ignored for provider traffic, and +connection retries are disabled so a failed request returns through the policy +boundary and the next request performs a fresh validation and pin. + +If resolution returns no answers, an invalid answer, or a mixture of permitted +and disallowed answers, the request is denied. If DNS changes after a valid +save, the request-time check blocks it before the transport is invoked. + +Hosted mode permits public unicast answers only. Multicast, unspecified, +reserved, loopback, private, link-local, shared/non-global, scoped IPv6, and +IPv4-mapped forms of those classes are denied. Self-hosted mode may admit +private or loopback unicast only through an exact URL and matching explicit +CIDR; non-unicast, link-local, reserved, scoped, and ambiguous/shared classes +remain denied even if an administrator supplies a broad CIDR. + +Provider requests explicitly disable redirect following. Any 3xx response is a +controlled provider failure; PARTHA never sends a request to its `Location` +target. + +Policy errors use a generic message. Normal API errors and logs must not include +the rejected URL, hostname, resolved addresses, or any URL credentials. Gemini +credentials are sent in the provider-supported API-key header rather than the +query string, and HTTPX/httpcore request-detail logging is held at warning level +even when application debug logging is enabled. + +## Existing configuration migration + +No migration deletes or rewrites existing provider configurations. A previously +stored endpoint that does not comply with the current deployment policy remains +in the database but cannot be used at request time. A deployment administrator +or authenticated user must replace it with a policy-compliant configuration +before it can run. +Because configurable Ollama destinations are also resolved when saved, a +transient DNS failure rejects the save without changing the existing record; +retry after name resolution is healthy. + +## Production network controls + +A hosted or shared deployment needs a deployment-level firewall, cloud egress +rule, service-mesh policy, or approved egress proxy that: + +- denies unapproved private, loopback, link-local, and external destinations; +- permits only the approved provider traffic; and +- is reviewed together with the application allowlist and DNS assumptions. + +Application URL validation is defence in depth, not a replacement for network +enforcement. This repository does not provide Kubernetes manifests, so no +Kubernetes policy is implied by this document. + +## Rollout and verification checklist + +Before enabling AI providers in a hosted or shared deployment: + +1. Keep `AI_EGRESS_MODE=hosted` unless a trusted administrator truly needs a + self-hosted endpoint. +2. For self-hosted mode, record the exact base URLs and smallest CIDRs in the + deployment secret/configuration system; do not let authenticated users edit them. +3. Apply and test a network egress control independently of the application. +4. Run the focused provider egress regression suite and the full backend suite. +5. Test an approved provider configuration and verify a disallowed or stale + configuration produces the generic validation error without a network call. +6. Confirm redirects are reported as provider failures and no policy details + appear in logs or API responses. + +See the root and backend environment examples for the supported variables. diff --git a/package.json b/package.json index 9bf000d5..255aa66f 100644 --- a/package.json +++ b/package.json @@ -9,14 +9,15 @@ "scripts": { "dev:frontend": "npm --prefix apps/frontend run dev", "build:frontend": "npm --prefix apps/frontend run build", + "generate:api-contract": "npm --prefix apps/frontend run generate:api-contract", "preview:frontend": "npm --prefix apps/frontend run preview", "lint:frontend": "npm --prefix apps/frontend run lint", "test:backend": "node scripts/backend-python.mjs -m pytest", - "dev:backend": "node scripts/backend-python.mjs -m uvicorn app.main:app --reload", + "fixtures:e2e": "node scripts/seed-e2e-fixtures.mjs", + "test:e2e": "npm --prefix apps/frontend exec -- playwright install chromium && node scripts/run-e2e-acceptance.mjs", + "test:accessibility": "node scripts/run-e2e-acceptance.mjs e2e/accessibility.spec.ts", + "dev:backend": "node scripts/backend-python.mjs -m uvicorn app.main:app --reload --reload-dir app", "start:backend": "node scripts/backend-python.mjs -m uvicorn app.main:app --host 0.0.0.0 --port 8000", - "build": "npm run build:frontend && npm run test:backend", - "docker:config": "docker compose config", - "docker:validate": "node scripts/validate-compose.mjs", - "docker:up": "docker compose up --build" + "build": "npm run build:frontend && npm run test:backend" } } diff --git a/packages/README.md b/packages/README.md deleted file mode 100644 index 401264ec..00000000 --- a/packages/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# Shared Packages - -This directory is reserved for future shared packages that need to be consumed by multiple apps. - -Keep app-specific code inside `apps/frontend` or `apps/backend` until a stable cross-app contract exists. diff --git a/render.yaml b/render.yaml new file mode 100644 index 00000000..4eed941c --- /dev/null +++ b/render.yaml @@ -0,0 +1,61 @@ +# Render Blueprint (#340) for a single-service PARTHA deployment: one web +# service (FastAPI serving both the API and the built frontend, per #339), +# one PostgreSQL database, one persistent disk for cloned/uploaded +# repositories. Docker-based rather than Render's native Python runtime, +# because the build needs Node to build the frontend before the Python +# service ever starts -- see ./Dockerfile. +# +# Before deploying from this blueprint: +# 1. Choose a plan for both the web service and the database in Render's +# dashboard when you create them -- this file intentionally doesn't +# pin one, since pricing/plan names change and that's a cost decision, +# not an engineering one. The web service's plan must support a +# persistent disk (the free tier does not). +# 2. After the service exists, set AI_ENCRYPTION_KEY manually in the +# Render dashboard (Environment tab) -- generate it with: +# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" +# It is marked `sync: false` below on purpose: Render's generic +# generateValue only produces a random string, not the specific +# URL-safe-base64-of-32-bytes shape a Fernet key requires. +# 3. AUTH_SECRET_KEY is auto-generated by Render (`generateValue: true`) +# and needs no manual step. +# 4. Update CORS_ORIGINS below if the public domain changes from +# www.partha.uk. +# +# Database migrations run as part of the container's own startup command +# (see the Dockerfile CMD) rather than a separate release-phase step -- +# AUTO_CREATE_TABLES defaults to false outside development/test, so this is +# required for the app to boot against a real schema, not optional polish. + +services: + - type: web + name: partha + runtime: docker + dockerfilePath: ./Dockerfile + dockerContext: . + healthCheckPath: /ready + envVars: + - key: APP_ENV + value: production + - key: LOG_FORMAT + value: json + - key: DATABASE_URL + fromDatabase: + name: partha-db + property: connectionString + - key: STORAGE_PATH + value: /var/data/storage + - key: CORS_ORIGINS + value: https://www.partha.uk + - key: AUTH_SECRET_KEY + generateValue: true + - key: AI_ENCRYPTION_KEY + sync: false + disk: + name: partha-storage + mountPath: /var/data + sizeGB: 5 + +databases: + - name: partha-db + postgresMajorVersion: "16" diff --git a/scripts/README.md b/scripts/README.md index f3d3ce5c..84e078cd 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -1,8 +1,29 @@ # Scripts -Local helper scripts live here. Prefer root `package.json` scripts for common workflows. +Local workflow helpers. Prefer the root `package.json` scripts for common tasks — these are what those scripts call. -| Script | Purpose | -| --- | --- | -| `backend-python.mjs` | Runs backend Python commands through `apps/backend/.venv` when present, with `python` fallback. | -| `validate-compose.mjs` | Validates Docker Compose config, starts the stack, waits for `/ready`, and tears the stack down. | +| Script | Purpose | Invoked by | +| --- | --- | --- | +| `backend-python.mjs` | Runs a backend Python command through `apps/backend/.venv` when present, falling back to `python`. | `npm run dev:backend`, `npm run start:backend`, `npm run test:backend` | +| `seed-e2e-fixtures.mjs` | Idempotently recreates the disposable Architecture/Review/Insights fixture repositories for one fixture account and writes a mode-0600 temporary manifest. | `npm run fixtures:e2e`, or the acceptance runner | +| `run-e2e-acceptance.mjs` | Starts isolated backend/frontend processes on free loopback ports, seeds fixtures, forwards optional Playwright CLI filters, runs the selected browser journeys, and removes temporary database/storage state. | `npm run test:e2e`, `npm run test:accessibility` | +| `start-backend.sh` | Starts the backend with uvicorn on `0.0.0.0:8000`. Same venv preference as above; override with `PYTHON=…`. | Directly, or from a process manager | +| `check-backend.sh` | Runs the backend test suite (`pytest`). Same venv preference; override with `PYTHON=…`. | Directly | +| `generate-api-contract.mjs` | Regenerates the frontend DTOs from the FastAPI OpenAPI schema using the pinned `openapi-typescript`. `--check` fails on drift instead of writing. | `npm run generate:api-contract`, CI **API Contract Drift** | +| `check-capabilities.py` | Validates the capability registry and the generated README block against `app/extraction/support_matrix.py`, so the published capability table cannot drift from the support matrices. | CI **Backend** | +| `apps/backend/scripts/rehearse_migrations.py` | Creates disposable SQLite (or explicitly confirmed PostgreSQL) targets, exercises the Alembic clean chain and representative `0004` baseline, then removes the targets. | Maintainer runbook and backend test suite | +| `dependency-audit.mjs` | Runs the frontend dependency audit against the policy below and fails on a blocking finding. | CI **Frontend** | +| `dependency-audit.test.mjs` | Node test suite for the audit policy logic itself. | `node --test scripts/dependency-audit.test.mjs`, CI **Frontend** | +| `dependency-audit-policy.json` | Data, not a script: the reviewed-exception list the audit reads. Each entry carries a checkable reason and a `reviewBy` date, and the build fails once it expires — or if the advisory stops naming the package, the accepted version changes unreviewed, or the vulnerable code becomes reachable. Removing the dependency without removing its entry fails too, so acknowledgements cannot go stale silently. | `dependency-audit.mjs` | + +The two shell scripts are standalone equivalents of the Node helpers, for environments where invoking `node` first is inconvenient. + +The generated frontend contract at `apps/frontend/src/shared/services/api/generated.ts` and the +capability block in the root README are both build outputs. Edit their sources — the FastAPI +schema and `support_matrix.py` — and regenerate; CI fails on hand edits that drift. + +`npm run test:e2e` is the one-command browser gate after dependencies are installed. It +installs Chromium if needed; CI runs the same acceptance runner and uploads its report. +`npm run test:accessibility` reuses that stack and runs only the WCAG baseline journeys. The +runner uses `apps/backend/.venv` on Windows or POSIX when present and the fixture seeder uses +Python's standard-library ZIP writer, so neither command depends on a system `zip` executable. diff --git a/scripts/check-capabilities.py b/scripts/check-capabilities.py new file mode 100644 index 00000000..f580dab5 --- /dev/null +++ b/scripts/check-capabilities.py @@ -0,0 +1,38 @@ +"""Validate the production capability registry and its checked-in README view.""" + +from __future__ import annotations + +import sys +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "apps/backend")) +sys.path.insert(0, str(ROOT / "apps/backend/tests")) + +from app.extraction.support_matrix import ( # noqa: E402 + check_readme_capabilities, + render_readme_capabilities, + validate_registry, +) +from benchmark.loader import load_support_matrix # noqa: E402 +from benchmark.paths import SUPPORT_MATRIX_PATH # noqa: E402 + + +def main() -> int: + validate_registry() + first = render_readme_capabilities() + second = render_readme_capabilities() + if first != second: + raise SystemExit("capability README rendering is not deterministic") + check_readme_capabilities(ROOT / "README.md") + matrix = load_support_matrix(SUPPORT_MATRIX_PATH) + if not matrix.constructs: + raise SystemExit("benchmark capability mapping is empty") + print(f"Capability registry valid: {len(matrix.constructs)} benchmark mappings") + print("README capability registry is current and deterministic") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/dependency-audit-policy.json b/scripts/dependency-audit-policy.json new file mode 100644 index 00000000..850c2c25 --- /dev/null +++ b/scripts/dependency-audit-policy.json @@ -0,0 +1,11 @@ +{ + "$comment": [ + "Reviewed exceptions for scripts/dependency-audit.mjs.", + "An entry needs a reason a reviewer can check and a reviewBy date.", + "The build fails if the acceptance expires, if the advisory or package identity stops", + "matching, if the accepted package version changes without review, or if the affected", + "functionality becomes reachable in our own source. Removing the vulnerable dependency", + "also requires removing its entry: a stale acknowledgement fails the build too." + ], + "acknowledged": [] +} diff --git a/scripts/dependency-audit.mjs b/scripts/dependency-audit.mjs new file mode 100644 index 00000000..647a1f3b --- /dev/null +++ b/scripts/dependency-audit.mjs @@ -0,0 +1,237 @@ +#!/usr/bin/env node +/** + * Policy-aware frontend dependency audit gate (#154). + * + * `npm audit` alone is a poor gate: it exits non-zero on advisories whose fix + * is a major migration, and it treats a build-tool transitive the same as + * something shipped to a browser. This wraps it with two ideas: + * + * 1. Runtime exposure is separated from development-only exposure, by running + * the audit a second time with `--omit=dev`. Anything present in the full + * audit but absent from the runtime audit reaches only developer machines + * and CI, never a user's browser. + * + * 2. An advisory may be acknowledged, but the acknowledgement stays valid only + * while what it described is still true. It fails the build when it + * expires, when the advisory stops naming the accepted package, when the + * lockfile moves the accepted package to a different version, when our own + * source starts touching the affected surface, or when the advisory + * disappears entirely and the exception is left behind. + * + * npm's "No fix available" means npm cannot apply a fix automatically -- often + * because the fix is a major version bump. It does NOT mean no patched release + * exists. Record the real patched version in the policy. + * + * Note on invocation: the repository root declares npm workspaces but has no + * lockfile, so `npm audit` from the root or from apps/frontend fails with + * ENOLOCK. `--prefix apps/frontend` resolves against the frontend lockfile, + * which is the same file CI installs from via `npm ci --prefix apps/frontend`. + */ + +import { execFileSync } from 'node:child_process'; +import { readdirSync, readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..'); +const policyPath = join(repoRoot, 'scripts', 'dependency-audit-policy.json'); + +/** Severities that fail the build when they reach a user's browser. */ +const RUNTIME_FAIL_AT = new Set(['moderate', 'high', 'critical']); +/** Development-only exposure tolerates moderate findings but not these. */ +const DEV_FAIL_AT = new Set(['high', 'critical']); + +function audit(extraArgs) { + const args = ['audit', '--json', '--prefix', 'apps/frontend', ...extraArgs]; + let stdout; + try { + stdout = execFileSync('npm', args, { cwd: repoRoot, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024 }); + } catch (error) { + // npm audit exits non-zero when it finds anything; the report is still on + // stdout. A genuinely broken invocation produces no parseable JSON. + stdout = error.stdout; + if (!stdout) { + console.error(`npm ${args.join(' ')} failed:\n${error.stderr || error.message}`); + process.exit(2); + } + } + return JSON.parse(stdout); +} + +/** Map advisory id -> { id, title, severity, url, packages:Set } */ +function advisories(report) { + const found = new Map(); + for (const [name, vulnerability] of Object.entries(report.vulnerabilities || {})) { + for (const via of vulnerability.via || []) { + // A string `via` is an indirection to another vulnerable package; the + // advisory object itself is recorded on that package's own entry. + if (typeof via !== 'object' || via.source === undefined) continue; + const id = via.url?.split('/').pop() || String(via.source); + const existing = found.get(id); + if (existing) { + existing.packages.add(name); + continue; + } + found.set(id, { + id, + title: via.title || '(untitled advisory)', + severity: via.severity || 'unknown', + url: via.url || '', + packages: new Set([name]), + }); + } + } + return found; +} + +/** Version of a package as pinned by the frontend lockfile, or null. */ +function lockedVersion(name) { + const lock = JSON.parse(readFileSync(join(repoRoot, 'apps', 'frontend', 'package-lock.json'), 'utf8')); + return lock.packages?.[`node_modules/${name}`]?.version ?? null; +} + +/** + * Source files under a policy root, so a reachability guard can be evaluated + * against our own code rather than against the dependency tree. + */ +function sourceFiles(root) { + const found = []; + const walk = (dir) => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue; + const full = join(dir, entry.name); + if (entry.isDirectory()) walk(full); + else if (/\.(ts|tsx|js|jsx|mjs|cjs)$/.test(entry.name)) found.push(full); + } + }; + try { + walk(join(repoRoot, root)); + } catch { + return []; + } + return found; +} + +/** + * An acknowledgement is only valid while the thing it described is still the + * thing in front of us. Anything else means a human needs to look again. + */ +function validateAcknowledgement(entry, advisory, today) { + const problems = []; + + if (entry.reviewBy < today) { + problems.push(`acknowledgement expired on ${entry.reviewBy}; re-review it`); + } + + // Identity: the advisory must still concern the package we accepted it for. + if (entry.package && !advisory.packages.has(entry.package)) { + problems.push( + `acknowledgement names package "${entry.package}" but the advisory now affects ` + + `${[...advisory.packages].join(', ')}; re-review it`, + ); + } + + // Version drift: accepting a risk for one version is not accepting it for + // whatever the lockfile drifts to next. + if (entry.acceptedVersion) { + const installed = lockedVersion(entry.package); + if (installed === null) { + problems.push(`"${entry.package}" is no longer in the lockfile; remove the acknowledgement`); + } else if (installed !== entry.acceptedVersion) { + problems.push( + `accepted for ${entry.package}@${entry.acceptedVersion} but the lockfile now pins ` + + `${installed}; re-review it`, + ); + } + } + + // Reachability: the acceptance rests on us not using the affected surface. + // If that stops being true, the acceptance stops being valid. + const reachability = entry.reachability; + if (reachability?.forbiddenPatterns?.length) { + const hits = []; + for (const root of reachability.roots || []) { + for (const file of sourceFiles(root)) { + const text = readFileSync(file, 'utf8'); + for (const pattern of reachability.forbiddenPatterns) { + if (text.includes(pattern)) { + hits.push(`${file.replace(`${repoRoot}/`, '')} uses "${pattern}"`); + } + } + } + } + if (hits.length) { + problems.push( + `the accepted advisory may now be reachable; the acceptance assumed otherwise:\n ` + + hits.slice(0, 10).join('\n '), + ); + } + } + + return problems; +} + +export { advisories, validateAcknowledgement }; + +// Importing this module (from its tests) must not run the audit. +if (process.argv[1] !== fileURLToPath(import.meta.url)) { + // eslint-disable-next-line no-empty-function +} else { + +const policy = JSON.parse(readFileSync(policyPath, 'utf8')); +const acknowledged = new Map((policy.acknowledged || []).map((entry) => [entry.id, entry])); + +const full = advisories(audit([])); +const runtime = advisories(audit(['--omit=dev'])); + +const today = new Date().toISOString().slice(0, 10); +const failures = []; +const accepted = []; + +for (const [id, advisory] of full) { + const isRuntime = runtime.has(id); + const scope = isRuntime ? 'runtime' : 'development-only'; + const failAt = isRuntime ? RUNTIME_FAIL_AT : DEV_FAIL_AT; + const acknowledgement = acknowledged.get(id); + const label = `${advisory.severity.padEnd(8)} ${scope.padEnd(16)} ${id} ${advisory.title}`; + + if (acknowledgement) { + const problems = validateAcknowledgement(acknowledgement, advisory, today); + if (problems.length) { + failures.push(`${label}\n ${problems.join('\n ')}`); + } else { + accepted.push(`${label}\n accepted until ${acknowledgement.reviewBy}: ${acknowledgement.reason}`); + } + continue; + } + + if (failAt.has(advisory.severity)) { + failures.push(`${label}\n packages: ${[...advisory.packages].join(', ')}\n ${advisory.url}`); + } else { + accepted.push(`${label} (below the ${scope} threshold)`); + } +} + +// An acknowledgement for an advisory that no longer appears is stale: the +// dependency was fixed or removed, and the exception should go with it. +for (const [id, entry] of acknowledged) { + if (!full.has(id)) { + failures.push(`stale acknowledgement ${id} no longer matches any advisory; remove it\n reason was: ${entry.reason}`); + } +} + +console.log('Frontend dependency audit'); +console.log('========================='); +if (accepted.length) { + console.log('\nAccepted:'); + for (const line of accepted) console.log(` ${line}`); +} +if (failures.length) { + console.log('\nBlocking:'); + for (const line of failures) console.log(` ${line}`); + console.log(`\n${failures.length} blocking finding(s).`); + console.log('Fix the advisory, or add a reviewed acknowledgement to scripts/dependency-audit-policy.json.'); + process.exit(1); +} +console.log(`\nNo blocking findings (${accepted.length} accepted).`); +} diff --git a/scripts/dependency-audit.test.mjs b/scripts/dependency-audit.test.mjs new file mode 100644 index 00000000..a4b84348 --- /dev/null +++ b/scripts/dependency-audit.test.mjs @@ -0,0 +1,173 @@ +/** + * Tests for the dependency-audit policy gate (#154). + * + * The gate's value is entirely in when it *refuses* to stay quiet. These cover + * each condition under which an acknowledgement must stop being accepted. + * + * The behavioural tests deliberately use *synthetic* entries rather than + * binding to whichever exception happens to be shipped today. An earlier + * version of this file used the live react-router entry as its fixture, so + * retiring that exception — the correct action once the advisory was patched + * in 7.18.2 — broke five tests at once. A gate that fails when you fix the + * vulnerability it guards is worse than no gate: it teaches you to keep the + * exception. Structural expectations about the shipped policy live in their + * own tests below and hold for any entry, including none. + * + * Run with: node --test scripts/ + */ + +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +import { advisories, validateAcknowledgement } from './dependency-audit.mjs'; + +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..'); +const policy = JSON.parse(readFileSync(join(repoRoot, 'scripts', 'dependency-audit-policy.json'), 'utf8')); + +const FUTURE = '2099-01-01'; +const TODAY = '2026-07-25'; + +/** + * A synthetic acknowledgement. `react-router` is a real lockfile entry, so the + * version-drift checks exercise the real lookup, but no assertion depends on + * which version is pinned today. + */ +const syntheticEntry = { + id: 'GHSA-test-0000-0000', + package: 'react-router', + severity: 'high', + reason: 'synthetic fixture for the policy-gate tests', + reviewBy: FUTURE, +}; + +function advisoryFor(entry, overrides = {}) { + return { + id: entry.id, + title: 'test advisory', + severity: entry.severity, + url: `https://github.com/advisories/${entry.id}`, + packages: new Set([entry.package]), + ...overrides, + }; +} + +test('a current acknowledgement with no drift is accepted', () => { + const problems = validateAcknowledgement(syntheticEntry, advisoryFor(syntheticEntry), TODAY); + assert.deepEqual(problems, []); +}); + +test('an expired acknowledgement fails', () => { + const entry = { ...syntheticEntry, reviewBy: '2020-01-01' }; + const problems = validateAcknowledgement(entry, advisoryFor(entry), TODAY); + assert.equal(problems.length, 1); + assert.match(problems[0], /expired on 2020-01-01/); +}); + +test('an acknowledgement fails when the advisory no longer names the accepted package', () => { + const advisory = advisoryFor(syntheticEntry, { packages: new Set(['some-other-package']) }); + const problems = validateAcknowledgement(syntheticEntry, advisory, TODAY); + assert.equal(problems.length, 1); + assert.match(problems[0], /but the advisory now affects some-other-package/); +}); + +test('an acknowledgement fails when the accepted package version changes', () => { + // A version the lockfile will never pin, so this stays true across bumps. + const entry = { ...syntheticEntry, acceptedVersion: '0.0.0-not-a-real-release' }; + const problems = validateAcknowledgement(entry, advisoryFor(entry), TODAY); + assert.equal(problems.length, 1); + assert.match(problems[0], /accepted for react-router@0\.0\.0-not-a-real-release but the lockfile now pins/); +}); + +test('an acknowledgement fails when the accepted package leaves the lockfile', () => { + const entry = { + ...syntheticEntry, + package: 'package-that-is-not-installed', + acceptedVersion: '1.0.0', + }; + const problems = validateAcknowledgement(entry, advisoryFor(entry), TODAY); + assert.ok(problems.some((problem) => /no longer in the lockfile/.test(problem))); +}); + +test('an acknowledgement fails when the affected surface becomes reachable', () => { + // Point the guard at a root that does contain one of its own forbidden + // patterns to prove the check actually fires. + const entry = { + ...syntheticEntry, + reachability: { + roots: ['scripts'], + forbiddenPatterns: ['createStaticHandler'], + }, + }; + const problems = validateAcknowledgement(entry, advisoryFor(entry), TODAY); + assert.equal(problems.length, 1); + assert.match(problems[0], /may now be reachable/); + assert.match(problems[0], /createStaticHandler/); +}); + +test('the frontend source uses no unstable or server-side React Router surface', () => { + // Kept independent of any shipped acknowledgement: PARTHA is a client-only + // Vite SPA, and the RSC/server React Router surface is where this class of + // advisory lives. This asserts that property directly, so it keeps holding + // after an exception is retired. + const entry = { + ...syntheticEntry, + reachability: { + roots: ['apps/frontend/src'], + forbiddenPatterns: [ + 'unstable_', + 'createStaticHandler', + 'createStaticRouter', + 'StaticRouterProvider', + 'deserializeErrors', + 'react-router/server', + '@react-router/node', + '@react-router/express', + '@react-router/serve', + ], + }, + }; + const problems = validateAcknowledgement(entry, advisoryFor(entry), TODAY); + assert.deepEqual(problems, [], 'frontend source must not use unstable or server React Router APIs'); +}); + +test('every shipped acknowledgement is well-formed and still valid today', () => { + // Holds for an empty policy, which is the healthy steady state: an + // acknowledgement should exist only while a real advisory is unpatched. + const today = new Date().toISOString().slice(0, 10); + for (const entry of policy.acknowledged || []) { + assert.ok(entry.id, 'each acknowledgement needs an advisory id'); + assert.ok(entry.package, `${entry.id} needs a package`); + assert.ok(entry.reason, `${entry.id} needs a reviewable reason`); + assert.ok(entry.reviewBy, `${entry.id} needs a reviewBy date`); + assert.doesNotMatch( + entry.reason, + /no fixed release exists/i, + `${entry.id} must not repeat npm's "No fix available" as if it meant no patch exists`, + ); + assert.deepEqual( + validateAcknowledgement(entry, advisoryFor(entry), today), + [], + `${entry.id} is no longer valid; re-review or remove it`, + ); + } +}); + +test('advisories() groups npm audit output by advisory id', () => { + const report = { + vulnerabilities: { + 'pkg-a': { + via: [ + { source: 1, url: 'https://github.com/advisories/GHSA-test-0000-0000', title: 't', severity: 'high' }, + ], + }, + // A string `via` is an indirection and must not create an advisory. + 'pkg-b': { via: ['pkg-a'] }, + }, + }; + const found = advisories(report); + assert.deepEqual([...found.keys()], ['GHSA-test-0000-0000']); + assert.deepEqual([...found.get('GHSA-test-0000-0000').packages], ['pkg-a']); +}); diff --git a/scripts/generate-api-contract.mjs b/scripts/generate-api-contract.mjs new file mode 100644 index 00000000..994cdc4b --- /dev/null +++ b/scripts/generate-api-contract.mjs @@ -0,0 +1,65 @@ +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { resolve } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const repoRoot = resolve(fileURLToPath(new URL('..', import.meta.url))); +// The generator is kept at the repository root, while its tool dependency +// belongs to the frontend workspace. Resolve from that workspace so this works +// after `npm ci --prefix apps/frontend` in a clean CI checkout as well. +const frontendRequire = createRequire(resolve(repoRoot, 'apps/frontend/package.json')); +const { default: openapiTS, COMMENT_HEADER, astToString } = frontendRequire('openapi-typescript'); +const outputPath = resolve(repoRoot, 'apps/frontend/src/shared/services/api/generated.ts'); +const checkOnly = process.argv.includes('--check'); + +function pythonExecutable() { + if (process.env.PARTHA_PYTHON) return process.env.PARTHA_PYTHON; + const venvPython = process.platform === 'win32' + ? resolve(repoRoot, 'apps/backend/.venv/Scripts/python.exe') + : resolve(repoRoot, 'apps/backend/.venv/bin/python'); + return existsSync(venvPython) ? venvPython : (process.platform === 'win32' ? 'python' : 'python3'); +} + +function loadOpenApiDocument() { + const result = spawnSync( + pythonExecutable(), + ['-c', 'import json; from app.main import app; print(json.dumps(app.openapi(), separators=(",", ":")))'], + { cwd: resolve(repoRoot, 'apps/backend'), encoding: 'utf8' }, + ); + + if (result.error) throw result.error; + if (result.status !== 0) { + throw new Error(`Backend OpenAPI generation failed:\n${result.stderr || result.stdout}`); + } + return JSON.parse(result.stdout); +} + +function assertSafeGeneratedOutput(content) { + const environmentUrl = /https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|host\.docker\.internal)(?::\d+)?/i; + const embeddedSecret = /(?:api[_-]?key|password|secret|token)\s*[:=]\s*["'`][^"'`]+["'`]/i; + if (environmentUrl.test(content)) { + throw new Error('Generated API contract contains an environment-specific URL.'); + } + if (embeddedSecret.test(content)) { + throw new Error('Generated API contract contains an embedded secret-like value.'); + } +} + +const document = loadOpenApiDocument(); +const contractBody = astToString(await openapiTS(document, { alphabetize: true })).replace(/\r?\n+$/, ''); +const generated = `${COMMENT_HEADER}// Source: apps/backend/app.main:app.openapi()\n// Generator: openapi-typescript@7.13.0\n\n${contractBody}\n`; +assertSafeGeneratedOutput(generated); + +if (checkOnly) { + if (!existsSync(outputPath)) throw new Error(`Generated contract is missing: ${outputPath}`); + const current = readFileSync(outputPath, 'utf8'); + if (current !== generated) { + const firstDifference = [...generated].findIndex((character, index) => current[index] !== character); + throw new Error(`Generated API contract is stale at character ${firstDifference}. Run \`npm run generate:api-contract\`.`); + } + console.log(`API contract is up to date: ${outputPath}`); +} else { + writeFileSync(outputPath, generated, 'utf8'); + console.log(`Generated API contract: ${outputPath}`); +} diff --git a/scripts/run-e2e-acceptance.mjs b/scripts/run-e2e-acceptance.mjs new file mode 100644 index 00000000..6ff1f64c --- /dev/null +++ b/scripts/run-e2e-acceptance.mjs @@ -0,0 +1,212 @@ +import { spawn } from 'node:child_process'; +import { access, mkdtemp, rm } from 'node:fs/promises'; +import { createServer } from 'node:net'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repositoryRoot = resolve(fileURLToPath(new URL('..', import.meta.url))); +const backendRoot = join(repositoryRoot, 'apps', 'backend'); +const frontendRoot = join(repositoryRoot, 'apps', 'frontend'); +const runtimeRoot = await mkdtemp(join(tmpdir(), 'partha-e2e-acceptance-')); +const fixtureManifest = join(runtimeRoot, 'e2e-fixtures.json'); + +function availableLoopbackPort() { + return new Promise((resolvePort, reject) => { + const server = createServer(); + server.unref(); + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + const address = server.address(); + if (!address || typeof address === 'string') { + server.close(); + reject(new Error('Could not allocate a disposable loopback port.')); + return; + } + const { port } = address; + server.close((error) => { + if (error) reject(error); + else resolvePort(port); + }); + }); + }); +} + +const apiPort = process.env.PARTHA_E2E_API_PORT ?? await availableLoopbackPort(); +const appPort = process.env.PARTHA_E2E_APP_PORT ?? await availableLoopbackPort(); +const apiUrl = `http://127.0.0.1:${apiPort}`; +const appUrl = `http://127.0.0.1:${appPort}`; +const screenshotDirectory = process.env.PARTHA_VISUAL_SCREENSHOT_DIR + ? resolve(repositoryRoot, process.env.PARTHA_VISUAL_SCREENSHOT_DIR) + : undefined; +const children = []; +let shuttingDown = false; + +async function pythonExecutable() { + const virtualenvCandidates = process.platform === 'win32' + ? [join(backendRoot, '.venv', 'Scripts', 'python.exe')] + : [join(backendRoot, '.venv', 'bin', 'python')]; + for (const virtualenvPython of virtualenvCandidates) { + try { + await access(virtualenvPython); + return virtualenvPython; + } catch { + // Try the next platform-appropriate candidate. + } + } + return process.platform === 'win32' ? 'python' : 'python3'; +} + +function start(command, args, options) { + const child = spawn(command, args, { stdio: 'inherit', ...options }); + children.push(child); + return child; +} + +function waitForExit(child, label) { + return new Promise((resolvePromise, reject) => { + child.once('error', reject); + child.once('exit', (code, signal) => { + if (code === 0) resolvePromise(); + else reject(new Error(`${label} exited with ${code ?? signal}`)); + }); + }); +} + +async function waitForUrl(url, label) { + const deadline = Date.now() + 90_000; + while (Date.now() < deadline) { + try { + const response = await fetch(url); + if (response.ok) return; + } catch { + // The process has not bound the port yet. + } + await new Promise((resolvePromise) => setTimeout(resolvePromise, 250)); + } + throw new Error(`${label} was not ready at ${url} within 90 seconds`); +} + +async function stopChildren() { + shuttingDown = true; + for (const child of children) { + if (child.exitCode === null && child.signalCode === null) child.kill('SIGTERM'); + } + await Promise.all( + children.map( + (child) => + new Promise((resolvePromise) => { + if (child.exitCode !== null || child.signalCode !== null) return resolvePromise(); + child.once('exit', resolvePromise); + setTimeout(resolvePromise, 3000); + }), + ), + ); +} + +let interrupted = false; +for (const signal of ['SIGINT', 'SIGTERM']) { + process.once(signal, async () => { + interrupted = true; + await stopChildren(); + process.exitCode = 130; + }); +} + +try { + const python = await pythonExecutable(); + const backend = start( + python, + ['-m', 'uvicorn', 'app.main:app', '--host', '127.0.0.1', '--port', String(apiPort), '--log-level', 'warning'], + { + cwd: backendRoot, + env: { + ...process.env, + APP_ENV: 'development', + LOG_LEVEL: 'WARNING', + DATABASE_URL: `sqlite:///${join(runtimeRoot, 'partha.db')}`, + STORAGE_PATH: join(runtimeRoot, 'storage'), + AUTO_CREATE_TABLES: 'true', + RATE_LIMIT_ENABLED: 'false', + ANALYSIS_JOB_POLL_INTERVAL_SECONDS: '1', + CORS_ORIGINS: `${appUrl},http://localhost:5173`, + }, + }, + ); + backend.once('exit', (code) => { + if (!interrupted && !shuttingDown && code !== null && code !== 0) { + process.stderr.write(`Backend stopped early (${code}).\n`); + } + }); + await waitForUrl(`${apiUrl}/ready`, 'backend'); + + const frontend = start( + process.execPath, + [ + join(frontendRoot, 'node_modules', 'vite', 'bin', 'vite.js'), + '--host', + '127.0.0.1', + '--port', + String(appPort), + ], + { + cwd: frontendRoot, + env: { ...process.env, VITE_API_URL: apiUrl }, + }, + ); + frontend.once('exit', (code) => { + if (!interrupted && !shuttingDown && code !== null && code !== 0) { + process.stderr.write(`Frontend stopped early (${code}).\n`); + } + }); + await waitForUrl(appUrl, 'frontend'); + + const seed = start(process.execPath, [join(repositoryRoot, 'scripts', 'seed-e2e-fixtures.mjs')], { + cwd: repositoryRoot, + env: { + ...process.env, + PARTHA_FIXTURE_API_URL: apiUrl, + PARTHA_FIXTURE_PYTHON: python, + // Registration requires an admin-approved email (#374): the seeder + // shells out to apps/backend/scripts/approve_email.py to approve one, + // which needs to resolve to the exact same database the backend above + // is running against, not whatever DATABASE_URL defaults to locally. + DATABASE_URL: `sqlite:///${join(runtimeRoot, 'partha.db')}`, + PARTHA_VISUAL_FIXTURES: fixtureManifest, + }, + }); + await waitForExit(seed, 'fixture seeder'); + + const playwright = start( + process.execPath, + [ + join(frontendRoot, 'node_modules', '@playwright', 'test', 'cli.js'), + 'test', + ...process.argv.slice(2), + ], + { + cwd: frontendRoot, + env: { + ...process.env, + PARTHA_E2E_BASE_URL: appUrl, + PARTHA_VISUAL_FIXTURES: fixtureManifest, + // surfaces.spec.ts registers a second owner directly (#374 requires + // an approved email for that too) and approves one the same way the + // fixture seeder does, against this same runtime database. + PARTHA_FIXTURE_PYTHON: python, + DATABASE_URL: `sqlite:///${join(runtimeRoot, 'partha.db')}`, + ...(screenshotDirectory + ? { PARTHA_VISUAL_SCREENSHOT_DIR: screenshotDirectory } + : {}), + }, + }, + ); + await waitForExit(playwright, 'browser acceptance'); +} finally { + await stopChildren(); + if (process.env.PARTHA_E2E_KEEP_TEMP !== '1') { + await rm(runtimeRoot, { recursive: true, force: true }); + } else { + process.stdout.write(`Kept e2e acceptance runtime: ${runtimeRoot}\n`); + } +} diff --git a/scripts/seed-e2e-fixtures.mjs b/scripts/seed-e2e-fixtures.mjs new file mode 100644 index 00000000..1e012233 --- /dev/null +++ b/scripts/seed-e2e-fixtures.mjs @@ -0,0 +1,284 @@ +import { execFile } from 'node:child_process'; +import { chmod, mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { basename, dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; + +const execFileAsync = promisify(execFile); +const apiUrl = (process.env.PARTHA_FIXTURE_API_URL ?? 'http://127.0.0.1:8000').replace(/\/$/, ''); +const outputPath = process.env.PARTHA_VISUAL_FIXTURES ?? join(tmpdir(), 'partha-e2e-fixtures.json'); +const email = 'e2e-fixture@example.com'; +const password = 'E2e-fixture-2026'; +const repositoryRoot = join(dirname(fileURLToPath(import.meta.url)), '..'); +const approveEmailScript = join(repositoryRoot, 'apps', 'backend', 'scripts', 'approve_email.py'); + +const moduleSource = (name, imports = []) => [ + ...imports.map((target) => `import { value as dependency } from '${target}';`), + `export const value = '${name}';`, + `export function ${name.replace(/[^a-zA-Z0-9]/g, '') || 'fixture'}() { return ${ + imports.length ? 'dependency' : 'value' + }; }`, + '', +].join('\n'); + +const FIXTURES = [ + { + label: 'small', + files: { + 'package.json': '{"name":"fixture-small","dependencies":{"react":"18.3.1"}}', + 'src/api/routes.ts': moduleSource('Routes', ['../services/user-service']), + 'src/services/user-service.ts': moduleSource('UserService', ['../models/user']), + 'src/models/user.ts': 'export interface User { id: string; }\nexport const value = "user";\n', + 'src/repositories/user-repository.ts': moduleSource('UserRepository', ['../models/user']), + // A standalone config module with no edges into the routes -> services -> + // domain chain: package.json itself no longer becomes a module (#396), + // so this is what keeps the default layout genuinely multi-row instead + // of a single straight dependency line. + 'src/config/settings.ts': moduleSource('Settings'), + 'README.md': '# Small snapshot-backed fixture\n', + }, + }, + { + label: 'medium', + files: { + 'package.json': '{"name":"fixture-medium","dependencies":{"react":"18.3.1","zod":"3.23.8"}}', + 'src/api/account-routes.ts': moduleSource('AccountRoutes', ['../services/account-service']), + 'src/api/billing-routes.ts': moduleSource('BillingRoutes', ['../services/billing-service']), + 'src/services/account-service.ts': moduleSource('AccountService', ['../models/account']), + 'src/services/billing-service.ts': moduleSource('BillingService', ['../repositories/billing-repository']), + 'src/models/account.ts': 'export interface Account { id: string; }\nexport const value = "account";\n', + 'src/repositories/billing-repository.ts': moduleSource('BillingRepository', ['../models/account']), + 'src/middleware/auth-middleware.ts': moduleSource('AuthMiddleware'), + 'src/config/settings.ts': moduleSource('Settings'), + 'src/lib/date.ts': moduleSource('DateUtility'), + 'README.md': '# Medium multi-layer fixture\n', + }, + }, + { + label: 'large-multi', + files: Object.fromEntries([ + ['package.json', '{"name":"fixture-large-multi","dependencies":{"react":"18.3.1","zod":"3.23.8"}}'], + ...Array.from({ length: 4 }, (_, index) => [ + `src/api/feature-${index + 1}-routes.ts`, + moduleSource(`Feature${index + 1}Routes`, [`../services/feature-${index + 1}-service`]), + ]), + ...Array.from({ length: 4 }, (_, index) => [ + `src/services/feature-${index + 1}-service.ts`, + moduleSource(`Feature${index + 1}Service`, [`../repositories/feature-${index + 1}-repository`]), + ]), + ...Array.from({ length: 4 }, (_, index) => [ + `src/repositories/feature-${index + 1}-repository.ts`, + moduleSource(`Feature${index + 1}Repository`, [`../models/feature-${index + 1}-model`]), + ]), + ...Array.from({ length: 4 }, (_, index) => [ + `src/models/feature-${index + 1}-model.ts`, + `export interface Feature${index + 1}Model { id: string; }\nexport const value = "model-${index + 1}";\n`, + ]), + ['src/middleware/request-middleware.ts', moduleSource('RequestMiddleware')], + ['src/config/runtime-settings.ts', moduleSource('RuntimeSettings')], + ['src/lib/telemetry-util.ts', moduleSource('TelemetryUtil')], + ['README.md', '# Large multi-layer fixture\n'], + ]), + }, + { + label: 'large-single', + expectedNodes: 14, + files: Object.fromEntries([ + ['package.json', '{"name":"fixture-large-single"}'], + ...Array.from({ length: 13 }, (_, index) => { + const current = String(index + 1).padStart(2, '0'); + const next = String(((index + 1) % 13) + 1).padStart(2, '0'); + return [ + `src/segment-${current}/component.ts`, + moduleSource(`Segment${current}`, [`../segment-${next}/component`]), + ]; + }), + ['README.md', '# Fourteen modules intentionally sharing one semantic layer\n'], + ]), + }, + { + label: 'long-labels', + files: { + 'package.json': '{"name":"fixture-long-labels"}', + 'src/customer-subscription-entitlement-orchestration/component.ts': moduleSource( + 'CustomerSubscriptionEntitlementOrchestration', + ['../international-payment-reconciliation-and-settlement/component'], + ), + 'src/international-payment-reconciliation-and-settlement/component.ts': moduleSource( + 'InternationalPaymentReconciliationAndSettlement', + ), + 'src/regulatory-compliance-reporting-and-audit/component.ts': moduleSource( + 'RegulatoryComplianceReportingAndAudit', + ), + 'README.md': '# Long labels remain recoverable through accessible names\n', + }, + }, + { + label: 'disconnected-unresolved', + files: { + 'package.json': '{"name":"fixture-disconnected-unresolved"}', + 'src/api/orphan-routes.ts': moduleSource('OrphanRoutes', ['../services/missing-service']), + 'src/services/connected-service.ts': moduleSource('ConnectedService', ['../models/record']), + 'src/models/record.ts': 'export interface Record { id: string; }\nexport const value = "record";\n', + 'src/isolated/reporting-component.ts': moduleSource('IsolatedReportingComponent'), + 'README.md': '# Unresolved and disconnected evidence fixture\n', + }, + }, + { + label: 'unanalysed', + analyse: false, + files: { + 'package.json': '{"name":"fixture-unanalysed"}', + 'src/component.ts': moduleSource('UnanalysedComponent'), + 'README.md': '# Deliberately not analysed\n', + }, + }, +]; + +async function api(path, { token, method = 'GET', body, expected } = {}) { + const headers = {}; + if (token) headers.Authorization = `Bearer ${token}`; + if (body !== undefined && !(body instanceof FormData)) headers['Content-Type'] = 'application/json'; + const response = await fetch(`${apiUrl}${path}`, { + method, + headers, + body: body instanceof FormData ? body : body === undefined ? undefined : JSON.stringify(body), + }); + const payload = response.status === 204 ? null : await response.json().catch(() => null); + if (expected ? !expected.includes(response.status) : !response.ok) { + throw new Error(`${method} ${path} returned ${response.status}: ${JSON.stringify(payload)}`); + } + return { status: response.status, payload }; +} + +async function approveEmail() { + // Registration requires an admin-approved email (#374); approve it the + // same way an operator would, through the real CLI, rather than reaching + // around it. Idempotent -- a rerun against the same fixture email is a + // harmless no-op, same as the script's own behavior. + const python = process.env.PARTHA_FIXTURE_PYTHON ?? (process.platform === 'win32' ? 'python' : 'python3'); + await execFileAsync(python, [approveEmailScript, '--email', email, '--note', 'e2e fixture seeder']); +} + +async function authenticate() { + await approveEmail(); + const registration = await api('/auth/register', { + method: 'POST', + body: { email, password }, + expected: [201, 409], + }); + if (registration.status === 201) return registration.payload.accessToken; + const login = await api('/auth/login', { method: 'POST', body: { email, password } }); + return login.payload.accessToken; +} + +async function archiveFixture(root, fixture) { + const sourceDir = join(root, fixture.label); + await mkdir(sourceDir, { recursive: true }); + for (const [path, content] of Object.entries(fixture.files)) { + const fullPath = join(sourceDir, path); + await mkdir(dirname(fullPath), { recursive: true }); + await writeFile(fullPath, content, 'utf8'); + } + const archivePath = join(root, `${fixture.label}.zip`); + const python = process.env.PARTHA_FIXTURE_PYTHON + ?? (process.platform === 'win32' ? 'python' : 'python3'); + await execFileAsync( + python, + [ + '-c', + [ + 'from pathlib import Path', + 'from zipfile import ZIP_DEFLATED, ZipFile', + 'import sys', + 'root = Path(sys.argv[1])', + 'with ZipFile(sys.argv[2], "w", ZIP_DEFLATED) as archive:', + ' for path in sorted(root.rglob("*")):', + ' if path.is_file():', + ' archive.write(path, path.relative_to(root).as_posix())', + ].join('\n'), + sourceDir, + archivePath, + ], + ); + return archivePath; +} + +async function upload(token, fixture, archivePath) { + const data = new FormData(); + const archive = await import('node:fs').then(({ readFileSync }) => readFileSync(archivePath)); + data.append('file', new Blob([archive], { type: 'application/zip' }), basename(archivePath)); + return (await api('/repositories/upload', { token, method: 'POST', body: data })).payload; +} + +async function waitForAnalysis(token, repositoryId) { + await api(`/analysis/${repositoryId}/start`, { token, method: 'POST' }); + const deadline = Date.now() + 120_000; + while (Date.now() < deadline) { + const { payload } = await api(`/analysis/${repositoryId}/status`, { token }); + if (payload.status === 'completed') return; + if (payload.status === 'failed' || payload.status === 'cancelled') { + throw new Error(`analysis ${repositoryId} ended as ${payload.status}: ${payload.error ?? ''}`); + } + await new Promise((resolve) => setTimeout(resolve, 250)); + } + throw new Error(`analysis ${repositoryId} did not complete within 120 seconds`); +} + +async function main() { + const token = await authenticate(); + const existing = (await api('/repositories', { token })).payload.data; + for (const repository of existing) { + await api(`/repositories/${repository.id}`, { token, method: 'DELETE' }); + } + + const workingRoot = await mkdtemp(join(tmpdir(), 'partha-e2e-fixtures-')); + const repositories = []; + try { + for (const fixture of FIXTURES) { + const archivePath = await archiveFixture(workingRoot, fixture); + const repository = await upload(token, fixture, archivePath); + if (fixture.analyse !== false) await waitForAnalysis(token, repository.id); + + let architecture = { nodes: [], edges: [], relationshipSnapshotId: null }; + let review = { findings: [], snapshotId: null }; + if (fixture.analyse !== false) { + architecture = (await api(`/analysis/${repository.id}/architecture`, { token })).payload; + review = (await api(`/analysis/${repository.id}/review`, { token })).payload; + } + if (fixture.expectedNodes !== undefined && architecture.nodes.length !== fixture.expectedNodes) { + throw new Error( + `${fixture.label} produced ${architecture.nodes.length} architecture nodes; expected ${fixture.expectedNodes}`, + ); + } + repositories.push({ + label: fixture.label, + id: repository.id, + name: repository.name, + revisionValue: repository.revision?.value ?? null, + snapshotId: architecture.relationshipSnapshotId, + nodes: architecture.nodes.length, + edges: architecture.edges.length, + reviewFindings: review.findings.length, + }); + } + + await mkdir(dirname(outputPath), { recursive: true }); + await writeFile( + outputPath, + `${JSON.stringify({ schemaVersion: 'e2e-fixtures.v1', apiUrl, email, password, repos: repositories }, null, 2)}\n`, + { encoding: 'utf8', mode: 0o600 }, + ); + await chmod(outputPath, 0o600); + } finally { + await rm(workingRoot, { recursive: true, force: true }); + } + + process.stdout.write(`Seeded ${repositories.length} disposable repositories. Fixture manifest: ${outputPath}\n`); +} + +main().catch((error) => { + process.stderr.write(`${error instanceof Error ? error.stack : String(error)}\n`); + process.exitCode = 1; +}); diff --git a/scripts/validate-compose.mjs b/scripts/validate-compose.mjs deleted file mode 100644 index 1a1f7803..00000000 --- a/scripts/validate-compose.mjs +++ /dev/null @@ -1,48 +0,0 @@ -import { spawnSync } from "node:child_process"; -import http from "node:http"; - -function run(command, args) { - const result = spawnSync(command, args, { stdio: "inherit", shell: false }); - if (result.error) throw result.error; - if (result.status !== 0) { - throw new Error(`${command} ${args.join(" ")} failed with exit code ${result.status}`); - } -} - -function sleep(ms) { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -function checkReady() { - return new Promise((resolve) => { - const request = http.get("http://127.0.0.1:8000/ready", { timeout: 5000 }, (response) => { - response.resume(); - resolve(response.statusCode === 200); - }); - request.on("timeout", () => { - request.destroy(); - resolve(false); - }); - request.on("error", () => resolve(false)); - }); -} - -async function main() { - run("docker", ["compose", "config"]); - try { - run("docker", ["compose", "up", "--build", "-d"]); - for (let attempt = 0; attempt < 30; attempt += 1) { - if (await checkReady()) return; - await sleep(2000); - } - run("docker", ["compose", "logs", "api", "postgres", "redis"]); - throw new Error("Compose API did not become ready."); - } finally { - run("docker", ["compose", "down", "-v"]); - } -} - -main().catch((error) => { - console.error(error.message); - process.exit(1); -});