From 759fbbb4c3b4d4f74bdb21ad37a25a1fa4f88c76 Mon Sep 17 00:00:00 2001 From: Shaurya K Sharma Date: Thu, 9 Jul 2026 20:26:06 +0100 Subject: [PATCH 001/347] chore(governance): add CODEOWNERS, dependabot, codeql, and definition-of-done MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Onboarding scaffolding from docs/planning/PRODUCTION_READINESS_PLAN.md, ahead of growing the team from 2 to 5-7 engineers. Docs and config only. - .github/CODEOWNERS: auto-request reviewers by area per §2.4 (ai, intelligence/parsers/graph, frontend, infra). The founders own every area today; each delegable area carries a TODO(ownership) marker showing where the new hire is slotted in. core/ and .github/ stay with the founders. - .github/dependabot.yml: weekly npm (apps/frontend) and pip (apps/backend) updates. Minor/patch bumps are grouped into one PR per ecosystem; majors stay individual. Both target `dev`, since `main` is ~158 files behind it and PRs must target `dev` per CONTRIBUTING.md. - .github/workflows/codeql.yml: javascript-typescript + python analysis on pull requests to dev/main, plus a weekly schedule. Least-privilege permissions. - docs/planning/DEFINITION_OF_DONE.md: §2.6 Definition of Ready and Definition of Done, written out as a standalone, checkable doc. - docs/planning/PRODUCTION_READINESS_PLAN.md: commit the plan itself, which was previously untracked and existed only in a working tree. The four files above all cite it, so it needs to be readable in the repo. Together these cover the config half of E2.3 and the CODEOWNERS item in §5's first-two-weeks list. Branch protection, the default-branch switch, issue-type enablement, and lifting the issue-creation restriction remain repo settings to be applied in the GitHub UI. Co-Authored-By: Claude Opus 4.8 --- .github/CODEOWNERS | 66 ++++ .github/dependabot.yml | 65 ++++ .github/workflows/codeql.yml | 68 ++++ docs/planning/DEFINITION_OF_DONE.md | 111 ++++++ docs/planning/PRODUCTION_READINESS_PLAN.md | 407 +++++++++++++++++++++ 5 files changed, 717 insertions(+) create mode 100644 .github/CODEOWNERS create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/codeql.yml create mode 100644 docs/planning/DEFINITION_OF_DONE.md create mode 100644 docs/planning/PRODUCTION_READINESS_PLAN.md diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000..2ed86c15 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,66 @@ +# CODEOWNERS — Second-Origin/PARTHA +# +# Automatically requests the right reviewer when a pull request touches an owned +# path. Derived from docs/planning/PRODUCTION_READINESS_PLAN.md §2.4 +# ("Ownership as you scale"). +# +# Ownership model: each experienced hire owns exactly ONE area and becomes the +# default reviewer and decision-maker there — ownership scales, task-assignment +# doesn't. Until those hires land, the founders own every area as the fallback. +# +# Founders / fallback owners: +# @SHAURYAKSHARMA24 (Shaurya) +# @parthrohit22 (Parth) +# +# Syntax notes: +# - The LAST matching pattern wins, so the catch-all is first and the most +# specific area rules are last. +# - Every owner must have write access to the repo, or GitHub silently skips +# the rule. Add new hires to the repo (or an org team) before listing them. +# - Prefer an org team (e.g. @Second-Origin/frontend) over an individual once +# an area has more than one owner. + +# ----------------------------------------------------------------------------- +# Fallback — anything not matched by a rule below is reviewed by the founders. +# ----------------------------------------------------------------------------- +* @SHAURYAKSHARMA24 @parthrohit22 + +# ----------------------------------------------------------------------------- +# Area: ai — AI providers, prompt construction, context retrieval, streaming. +# Label: area/ai +# +# TODO(ownership): when the AI hire starts, replace the founders on this rule +# with @ and leave the founders only as fallback via the `*` rule. +# ----------------------------------------------------------------------------- +/apps/backend/app/ai/ @SHAURYAKSHARMA24 @parthrohit22 + +# ----------------------------------------------------------------------------- +# Area: intelligence — parsers, knowledge graph, and the analysis engine. +# This is the heart of E4 (Persisted Knowledge Graph); keep it with one owner. +# Labels: area/backend, area/ai +# +# TODO(ownership): when the intelligence hire starts, replace the founders on +# these three rules with @. +# ----------------------------------------------------------------------------- +/apps/backend/app/parsers/ @SHAURYAKSHARMA24 @parthrohit22 +/apps/backend/app/graph/ @SHAURYAKSHARMA24 @parthrohit22 +/apps/backend/app/intelligence/ @SHAURYAKSHARMA24 @parthrohit22 + +# ----------------------------------------------------------------------------- +# Area: frontend — React/TS app, including the test harness stood up in E3.2. +# Label: area/frontend +# +# TODO(ownership): when the frontend hire starts, replace the founders on this +# rule with @. +# ----------------------------------------------------------------------------- +/apps/frontend/ @SHAURYAKSHARMA24 @parthrohit22 + +# ----------------------------------------------------------------------------- +# Area: infra — config, secrets handling, CI/CD, and repo governance. +# +# These stay with the founders permanently. Per §2.5, changes here (along with +# auth and migrations) warrant TWO approving reviews. No TODO: this is not +# delegated to a new hire. +# ----------------------------------------------------------------------------- +/apps/backend/app/core/ @SHAURYAKSHARMA24 @parthrohit22 +/.github/ @SHAURYAKSHARMA24 @parthrohit22 diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..1cd215e5 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,65 @@ +# Dependabot configuration for the PARTHA monorepo. +# +# Satisfies part of E2.3 ("Dependency, secret, and code scanning in CI") in +# docs/planning/PRODUCTION_READINESS_PLAN.md, and §2.5, which makes Dependabot a +# required status check once branch protection is enabled. +# +# Two ecosystems, one per app: npm for the React/TS frontend, pip for the +# FastAPI backend. Minor and patch bumps are grouped into a single PR per +# ecosystem per week to keep review load low; major bumps stay as individual +# PRs, because they need to be read one at a time. +version: 2 + +updates: + # --------------------------------------------------------------------------- + # Frontend — npm (apps/frontend) + # --------------------------------------------------------------------------- + - package-ecosystem: npm + directory: /apps/frontend + # `dev` is the real trunk; `main` is ~158 files behind it (§1 housekeeping). + # Without this, Dependabot would open PRs against the stale default branch, + # where the lockfile does not match. CONTRIBUTING.md also requires all PRs + # to target `dev`. + # + # Caveat: `target-branch` applies to version updates only. Dependabot + # *security* updates always target the default branch, so the durable fix is + # still to make `dev` the default branch (§2.2 / E3.1). + 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: + frontend-minor-and-patch: + applies-to: version-updates + update-types: + - minor + - patch + + # --------------------------------------------------------------------------- + # 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: + backend-minor-and-patch: + applies-to: version-updates + update-types: + - minor + - patch diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 00000000..7acea7d0 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,68 @@ +# CodeQL static analysis for PARTHA. +# +# Satisfies part of E2.3 ("Dependency, secret, and code scanning in CI") in +# docs/planning/PRODUCTION_READINESS_PLAN.md. Per §2.5, this should become a +# required status check on `dev` and `main` once branch protection is enabled. +name: CodeQL + +on: + pull_request: + branches: + - dev + - main + schedule: + # Weekly, Monday 03:17 UTC. Off-the-hour on purpose: GitHub drops scheduled + # runs when too many land on the same minute. + # + # Note: scheduled workflows only ever run on the DEFAULT branch. Until `dev` + # becomes the default (§2.2 / E3.1), this weekly scan analyses stale `main`. + # The pull_request trigger above is what actually guards day-to-day work. + - cron: '17 3 * * 1' + +# Least privilege by default; the analyze job widens only what it needs. +permissions: + contents: read + +# A newer push to the same PR makes an in-flight scan irrelevant. +concurrency: + group: codeql-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + runs-on: ubuntu-latest + timeout-minutes: 30 + + permissions: + # Required to upload results to the Security tab. + security-events: write + # Required by the CodeQL action to look up workflow run status. + actions: read + contents: read + + strategy: + fail-fast: false + matrix: + include: + # `build-mode: none` is correct for interpreted languages — CodeQL + # analyses the source directly and no compilation step is needed. + - language: javascript-typescript + build-mode: none + - language: python + build-mode: none + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + with: + category: "/language:${{ matrix.language }}" diff --git a/docs/planning/DEFINITION_OF_DONE.md b/docs/planning/DEFINITION_OF_DONE.md new file mode 100644 index 00000000..f08511bb --- /dev/null +++ b/docs/planning/DEFINITION_OF_DONE.md @@ -0,0 +1,111 @@ +# Definition of Ready & Definition of Done + +Two checklists that gate work into and out of the sprint. Extracted from +[`PRODUCTION_READINESS_PLAN.md`](./PRODUCTION_READINESS_PLAN.md) §2.6 so they can be +referenced directly from an issue or a pull request. + +- **Definition of Ready (DoR)** gates an issue *into* the sprint — it decides when an + issue may be labeled `ready` and assigned. +- **Definition of Done (DoD)** gates a pull request *out* — it decides when a PR may + merge to `dev`. + +These complement, and do not replace, [`CONTRIBUTING.md`](../../CONTRIBUTING.md) and the +[pull request template](../../.github/pull_request_template.md). Where the PR template +asks "did you?", this document defines what a correct answer looks like. + +--- + +## Definition of Ready + +An issue is `ready` — assignable, and pullable into a sprint — when **all** of the +following are true. Anything unchecked means the issue is still `needs-design` or +`blocked`, not `ready`. + +- [ ] **Clear acceptance criteria.** The issue states what must be true for it to be + closed, in terms someone other than the author can verify. Not "improve + parsing" but "parser emits an edge for every resolved import; test proves it." +- [ ] **Area is set.** Exactly one of `area/backend`, `area/frontend`, `area/ai`, + `area/infra`, `area/db`, `area/docs` (§2.3). +- [ ] **Priority is set.** `P0`–`P3`. +- [ ] **Milestone is set.** One of `M1 — Foundations`, `M2 — Real Intelligence`, + `M3 — Operate It`, `M4 — Launch Polish` (§2.2). +- [ ] **Dependencies are linked.** Blocking issues are referenced, and the issue + carries `blocked` if any of them are still open. +- [ ] **Design is agreed** — required if the work touches **API shape, persistence, or + security**. The decision, and the alternatives rejected, live in a comment on the + issue itself, so the trail is readable by whoever picks it up next. + +> Issue type (Epic / Feature / Task / Bug) is a native GitHub issue type, not a label +> (§2.1). Sub-issues inherit their parent's Project and Milestone automatically. + +### Ready in one line + +> Someone who did not write this issue could pick it up, know when they are finished, +> and know who to ask about the one decision that was already made. + +--- + +## Definition of Done + +A pull request may merge to `dev` when **all** of the following are true. + +### Correctness + +- [ ] **Acceptance criteria met.** Every criterion on the linked issue is satisfied. If + one was dropped or changed, the issue is updated to say so — silently shipping a + narrower scope than the issue promised is not done. +- [ ] **Tests added or updated.** New behavior gets a test; changed behavior gets its + test changed. A bug fix gets a test that fails without the fix. +- [ ] **CI is green.** The `Frontend`, `Backend`, and `Docker Compose` jobs pass. Once + branch protection lands (§2.5), CodeQL and Dependabot join them as required + checks. + +### Code quality + +- [ ] **No new `any` in TypeScript.** If a type is genuinely unknown, use `unknown` and + narrow it. An `any` that must ship carries a comment explaining why. +- [ ] **No new broad `except:` in Python.** Catch the exception you can actually handle. + A bare or `except Exception:` clause that must ship re-raises or logs with context, + and says why it is broad. + +### Documentation & safety + +- [ ] **Docs updated if behavior changed.** API shape, env vars, setup steps, and + operational runbooks. If a reader of the docs would now be wrong, the docs change + in the same PR. +- [ ] **No secrets committed.** No API keys, tokens, `.env` files, credentials, or + local databases — in the diff or anywhere in the branch's history. +- [ ] **No build artifacts committed.** No `dist/`, no `*.tsbuildinfo`, no + `__pycache__/`, no coverage output. + +### Reachability + +- [ ] **The feature is reachable through the UI**, or it is **explicitly documented as + internal**. Backend capability that no user can reach, and that no document + admits is internal-only, is not done — it is a half-finished feature that reads as + a finished one. + +### Done in one line + +> The acceptance criteria hold, CI proves it, a reader of the docs would not be misled, +> nothing secret or generated is in the diff, and a user can actually get to it. + +--- + +## Applying this + +- **Weekly planning** (§2.7) is where issues are triaged against the DoR and labeled + `ready`. An issue that fails the DoR is not "almost ready" — it goes back with the + missing piece named. +- **Review** is where the DoD is enforced. A reviewer may block on any unchecked box. +- Keep PRs under **~400 lines of diff** (§2.7). A PR too large to review against this + checklist is too large to merge; split the epic into task-sized PRs. +- PRs touching `core/`, auth, or migrations require **two** approving reviews (§2.5). + +## See also + +- [`PRODUCTION_READINESS_PLAN.md`](./PRODUCTION_READINESS_PLAN.md) — milestones (§2.2), + labels (§2.3), ownership (§2.4), branch protection (§2.5), cadence (§2.7). +- [`CONTRIBUTING.md`](../../CONTRIBUTING.md) — branch strategy, Conventional Commits, + squash merge, issue-assignment flow. +- [`.github/CODEOWNERS`](../../.github/CODEOWNERS) — who reviews which area. diff --git a/docs/planning/PRODUCTION_READINESS_PLAN.md b/docs/planning/PRODUCTION_READINESS_PLAN.md new file mode 100644 index 00000000..712f4bf6 --- /dev/null +++ b/docs/planning/PRODUCTION_READINESS_PLAN.md @@ -0,0 +1,407 @@ +# PARTHA — Path to Production & Team Working Structure + +_A plan for taking PARTHA — the **Engineering Intelligence Platform** ("transform repositories into actionable engineering intelligence; understand systems, assess change impact, and make engineering decisions with confidence") — from a fast-moving MVP to a production-ready platform, and for structuring the work as the team grows from 2 to 5–7 experienced engineers._ + +Status as of Jul 9, 2026. Owners: Shaurya, Parth. + +> **Update (this revision):** PARTHA has been repositioned from "AI-powered software architecture intelligence platform" to **"Engineering Intelligence Platform"**, with new brand assets (`docs/brand/VISUAL_IDENTITY.md`, hero/logo SVGs) and a `docs/product/PUBLIC_FACE_AUDIT.md`. A "production readiness baseline" has also merged into `dev` (PRs #30/#31). Importantly, that baseline is the **docs + observability scaffolding** layer — not the security hardening — so the P0 keystones below (E1 auth, E2 security) remain fully open. Shipped-vs-open status is now marked per epic in §3. + +--- + +## 1. Where we are, where "production-ready" is + +PARTHA today is a structurally complete MVP: import → parse → intelligence → product features works end to end. Backend is ~4.4k LOC of FastAPI, frontend ~8.3k LOC of React/TS, 66 backend tests, CI with lint/build/pytest/compose smoke, Alembic migrations, and an observability module already scaffolded. + +The honest gap between "works in a demo" and "production-ready" is four things we do **not** have yet: + +1. **No identity.** No users, no auth, no multi-tenancy. Every deploy is single-tenant and open. This is the single biggest blocker to real users. +2. **No security posture.** No rate limiting, no security headers, CORS not locked down, AI provider secrets not encrypted at rest, no dependency/secret scanning gate. +3. **Intelligence is still heuristic.** The persisted knowledge graph — the thing the whole architecture is designed around — is still "in progress." Architecture/dependency/review/docs are all self-labeled "Partial." +4. **Thin reliability & test safety net.** Frontend has **zero** tests. No error tracking, no SLOs, no staging environment, no rollback story beyond "redeploy." + +"Production-ready" for PARTHA = a user can sign in, import their repo, trust the output, and we can operate it safely (observe it, roll it back, keep their data and secrets safe). Everything below serves that definition. + +> **Housekeeping — `main` is stale, `dev` is the real trunk.** As of this revision, `dev` sits ~18 commits and 158 files ahead of `main` (the production-readiness baseline, AI providers, reports/export, intelligence engine, and ops docs all live on `dev`). The earlier "whole tree modified" mess is resolved on the remote — it landed cleanly via PRs #30/#31; any local churn is CRLF/filemode noise from the mount. The real risk now: **anyone cloning `main` gets almost none of the platform.** Before onboarding, either promote `dev` → `main` on a regular cadence or make `dev` the default branch (see §2.2/§2.5). Also: **issue creation is currently restricted on the repo** — lift that (or pre-create the issues yourselves) before contributors arrive. + +--- + +## 2. Team working structure + +You already have a strong `CONTRIBUTING.md` (dev/main/feature branches, Conventional Commits, squash merge, issue-assignment flow, PR template). Keep all of it. The additions below are what a 5–7 person team needs that a 2-person team can skip. + +### 2.1 Plan in a hierarchy, track in one board + +Adopt GitHub's now-GA **issue types + sub-issues** instead of the `[AI 1]` / `[AI 1.3]` naming convention. It gives you the same hierarchy natively, and sub-issues inherit the parent's Project and Milestone automatically. + +``` +Epic (issue type: Epic) e.g. "Authentication & Multi-Tenancy" + └─ Feature (issue type: Feature) "Email/password + session auth" + └─ Task (issue type: Task) "Add User model + Alembic migration" + └─ Task "Add JWT issue/verify + refresh rotation" + └─ Bug (issue type: Bug) +``` + +Run **one GitHub Project (v2)** for the whole team with these views: +- **Board** (Todo / In Progress / In Review / Done) — daily driver. +- **Table** grouped by Milestone — release planning. +- **Roadmap** — the timeline for stakeholders / new contributors. + +Custom fields to add to the Project: `Priority` (P0–P3, you already use this), `Area`, `Estimate` (S/M/L), `Sprint`. + +### 2.2 Milestones = releases + +Move from continuous merging to **milestone-based delivery** so a growing team pulls in the same direction. Proposed milestones (details in §3): + +| Milestone | Theme | Definition of "shipped" | +| --- | --- | --- | +| `M1 — Foundations` | Auth, security baseline, clean repo, frontend test harness | A user can sign in; the app is not open to the internet | +| `M2 — Real Intelligence` | Persisted knowledge graph, dependency edges, evidence-backed review | Outputs are trustworthy, not heuristic guesses | +| `M3 — Operate It` | Observability, error tracking, staging, deploy + rollback | We can run it safely and see when it breaks | +| `M4 — Launch Polish` | Finish "Partial" features, AI workspace epic, E2E coverage | End-to-end story is demo-perfect and covered by tests | + +Timebox each to ~3–4 weeks. Don't start M2 area-work before M1's auth boundary exists — features built without auth get re-plumbed later. + +### 2.3 Labels (consolidated taxonomy) + +Keep it small and orthogonal. One label per axis: + +- **Area:** `area/backend` `area/frontend` `area/ai` `area/infra` `area/db` `area/docs` +- **Type:** use native issue types (Epic/Feature/Task/Bug) instead of type labels. +- **Priority:** `P0`–`P3` (already in your template). +- **Status/flow:** `blocked` `needs-design` `ready` (`ready` = spec'd, assignable). +- **Contributor-facing** (for when the 3–5 arrive): `good-first-issue` `help-wanted`. + +### 2.4 Ownership as you scale + +Add a `CODEOWNERS` file so PRs auto-request the right reviewer. Assign areas to people, not everything to you two: + +``` +# .github/CODEOWNERS +/apps/backend/app/ai/ @ai-owner +/apps/backend/app/parsers/ @intelligence-owner +/apps/backend/app/graph/ @intelligence-owner +/apps/frontend/ @frontend-owner +/apps/backend/app/core/ @shaurya @parth # infra/config stays with founders +/.github/ @shaurya @parth +``` + +Rule of thumb for experienced hires: give each new person **one Area to own** (they become the default reviewer and decision-maker there), not a stream of disconnected tickets. Ownership scales; task-assignment doesn't. + +### 2.5 Branch protection & required checks + +Now that non-founders will push, enforce in GitHub settings what `CONTRIBUTING.md` currently only asks politely: +- Protect `main` and `dev`: no direct pushes, require PR. +- Require the `Frontend`, `Backend`, and `Docker Compose` CI jobs to pass before merge. +- Require **1 approving review** (2 for anything touching `core/`, auth, or migrations). +- Require branches up to date with `dev` before merge; require linear history (you already squash). +- Add **Dependabot** + **CodeQL** (or GitHub Advanced Security) as required status checks — this is part of the security baseline anyway. + +### 2.6 Definition of Ready / Definition of Done + +Put these in the repo (e.g. `docs/planning/DEFINITION_OF_DONE.md`) and gate on them. + +**Definition of Ready** (before an issue is assignable / labeled `ready`): +- Clear acceptance criteria; area + priority + milestone set; dependencies linked; design agreed if it touches API/persistence/security. + +**Definition of Done** (before a PR merges): +- Acceptance criteria met; tests added/updated and CI green; no new `any` / broad excepts; docs updated if behavior changed; no secrets/build artifacts committed; feature reachable through the UI or documented as internal. + +### 2.7 Cadence & communication + +- **Weekly planning** (30–45 min): triage new issues → `ready`, pull the next slice into the sprint, confirm milestone burn-down. +- **Async standups** in the Project (or a `#standup` channel): what moved, what's blocked. No daily meeting needed for experienced devs. +- **PR SLA:** first review within one working day; keep PRs < ~400 lines of diff — split epics into task-sized PRs. +- Keep all design discussion **on the issue**, per your existing philosophy, so new contributors can read the decision trail. + +--- + +## 3. Production-readiness roadmap (the epics) + +These are the "solid new issues" to create, grouped by milestone. Each is an **Epic**; the ready-to-paste child issues are in §4. PARTHA's existing open AI epic (#14 and its 1.3–1.8 subtasks) folds into **M4**. + +**Status legend:** 🔴 not started · 🟡 partially shipped · 🟢 done. + +### M1 — Foundations +- 🔴 **E1. Authentication & Multi-Tenancy** — user model, sign-up/sign-in, sessions/JWT with refresh rotation, per-user data scoping. _The keystone; almost everything else assumes it._ **Confirmed absent on `dev` — no auth/user modules exist yet.** +- 🔴 **E2. Security Baseline** — rate limiting (slowapi + Redis), security-headers middleware, CORS lockdown, encrypted-at-rest storage for AI provider keys, Dependabot + CodeQL + secret scanning in CI. **Not started; the "readiness baseline" that shipped did not include any of this.** +- 🟡 **E3. Repo Hygiene & Frontend Test Harness** — build-artifact hygiene is **done** on `dev` (stopped tracking `dist/`/`tsbuildinfo`, improved monorepo `.gitignore`). **Still open:** promote `dev`→`main` cadence, and the frontend test harness (Vitest + RTL) — frontend still has zero tests. + +### M2 — Real Intelligence +- 🔴 **E4. Persisted Knowledge Graph** — the core in-progress work: a real graph model (nodes/edges/artifacts) persisted in Postgres, replacing per-feature heuristics as the single source of truth. _An `intelligence/engine.py` exists but outputs are still heuristic/in-memory — the persisted graph is not built yet._ +- 🔴 **E5. Dependency Graph Depth** — resolve real edges between manifests, add outdated + vulnerability signals, surface a true dependency graph (not just an inventory). +- 🔴 **E6. Evidence-Backed Engineering Review** — expand rule depth and make every finding cite concrete files/lines from the graph. _(A `test_review_evidence.py` now exists — evidence scaffolding is beginning.)_ + +### M3 — Operate It _(partially underway via the readiness baseline)_ +- 🟡 **E7. Observability & Error Tracking** — `apps/backend/app/core/observability.py` and `docs/operations/observability.md` have **shipped** (scaffolding). **Still open:** wire it to real metrics + traces (OpenTelemetry), add error tracking (Sentry), and define 2–3 user-facing SLOs + burn-rate alerts. +- 🟡 **E8. Deploy Pipeline & Environments** — a `.github/workflows/release.yml`, `docs/operations/production-deployment.md`, and `release-management.md` have **shipped**, and Compose already uses Postgres. **Still open:** an actual staging environment, container registry, automated deploy, verified rollback, and DB backups. +- 🔴 **E9. Background Work Robustness** — make ingestion of large repos async and resilient (timeouts, retries, idempotency, progress) so the API stays responsive. + +### M4 — Launch Polish +- **E10. AI Workspace Completion** — absorb existing issues #14, #32–#37 (provider config/secrets, context retrieval, streaming, persistence, citations, hardening). +- **E11. Finish the "Partial" Surfaces** — Insights backend endpoint, real Settings/account, deep-link search, documentation HTML/export quality. +- **E12. End-to-End Test Coverage** — Playwright E2E for the golden path (import → explore → review → AI), plus API contract tests. + +--- + +## 4. Ready-to-paste issues + +Formatted for your `engineering_task.yml` template (Task / Rationale / References / Implementation Notes / Acceptance Criteria / Priority). Create the Epics first, then attach the Tasks as **sub-issues**. This is a starter set for **M1 + the start of M2** — the highest-leverage work. Repeat the pattern for later milestones. + +--- + +### EPIC E1 — Authentication & Multi-Tenancy +**Type:** Epic · **Area:** backend, frontend, db · **Priority:** P0 · **Milestone:** M1 + +**Goal.** Introduce identity so PARTHA can support real, isolated users. Every repository, analysis, and provider secret becomes owned by a user (or workspace). No feature should read or write data outside the current user's scope. + +**Why now.** The platform is currently open and single-tenant. Auth is a prerequisite for security hardening (E2), per-user provider keys, conversation persistence (#35), and any real deployment. Building more features before this means re-plumbing every table and route later. + +**Child issues:** E1.1–E1.5 below. + +> Optional accelerator: the workspace has the Auth0 skill set installed (`auth0-fastapi-api`, `auth0-react`). If you'd rather not own auth infrastructure, Auth0 can provide login + JWT issuance and we only validate tokens + scope data. Decide build-vs-buy in this epic before starting E1.2. + +--- + +#### E1.1 — Add User model and auth tables + migration +**Type:** Task · **Area:** backend, db · **Priority:** P0 + +**Task.** Add `User` (and, if we choose workspaces, `Workspace` / `Membership`) SQLAlchemy models and an Alembic migration. Add `owner_id` foreign keys to `Repository` and any other user-scoped tables. + +**Rationale.** Everything in this epic depends on a persisted identity and an ownership column to scope queries by. + +**References.** `apps/backend/app/models/`, `apps/backend/alembic/versions/`, `apps/backend/app/repositories/`. + +**Implementation Notes.** Use UUID primary keys for users. Hash passwords with `argon2` or `bcrypt` (never store plaintext). Decide single-user-ownership vs. workspaces up front — changing later is a painful migration. Backfill existing rows to a system/seed user. + +**Acceptance Criteria.** +- `User` model + migration merged; `upgrade`/`downgrade` both run clean. +- `Repository` (and other user data) carry an `owner_id` FK. +- Repository queries filter by owner; a repo request test proves cross-user access returns 404/403. +- Tests added; CI green. + +--- + +#### E1.2 — Implement JWT issue/verify with refresh-token rotation +**Type:** Task · **Area:** backend · **Priority:** P0 + +**Task.** Add auth endpoints (`/auth/register`, `/auth/login`, `/auth/refresh`, `/auth/logout`) issuing short-lived access tokens and rotating refresh tokens. Add a `get_current_user` dependency. + +**Rationale.** Provides the session mechanism the frontend and all protected routes need. + +**References.** `apps/backend/app/api/routes/`, `apps/backend/app/api/deps.py`, `apps/backend/app/core/config.py`. + +**Implementation Notes.** Short access-token TTL (~15 min) + rotating refresh token stored hashed and revocable. Sign with a secret from config/env (never hardcoded). Consider httpOnly cookies for the web client to avoid XSS token theft. If we chose Auth0 in E1, this task becomes "validate Auth0 JWTs + map to local user" instead. + +**Acceptance Criteria.** +- Register/login/refresh/logout work end to end with tests. +- `get_current_user` rejects missing/expired/invalid tokens with 401. +- Refresh rotation invalidates the used refresh token; reuse is detected and rejected. +- No secrets committed; CI green. + +--- + +#### E1.3 — Protect existing routes and scope data to the current user +**Type:** Task · **Area:** backend · **Priority:** P0 + +**Task.** Require authentication on all repository/analysis/ai/documentation/reports routes and filter every query by `owner_id`. + +**Rationale.** Auth is worthless if existing endpoints still serve everyone's data. + +**References.** `apps/backend/app/api/routes/*.py`, `apps/backend/app/services/*.py`. + +**Implementation Notes.** Add the `get_current_user` dependency at the router level where possible. Push ownership filtering into the repository/service layer, not individual handlers, so it can't be forgotten. Return 404 (not 403) for other users' resources to avoid leaking existence. + +**Acceptance Criteria.** +- All non-public routes return 401 without a valid token. +- Tests prove user A cannot read/mutate user B's repositories, analyses, docs, or reports. +- CI green. + +--- + +#### E1.4 — Frontend auth flow (login/register, token handling, guarded routes) +**Type:** Task · **Area:** frontend · **Priority:** P0 + +**Task.** Add login/register pages, token/session handling in the API client, an auth store, and route guards that redirect unauthenticated users. + +**Rationale.** Users need a way to actually sign in; protected pages must not render for anonymous users. + +**References.** `apps/frontend/src/app/routes/router.tsx`, `apps/frontend/src/app/store/useAppStore.ts`, `apps/frontend/src/shared/services/api/client.ts`. + +**Implementation Notes.** Centralize auth in the API client (attach token, refresh on 401, redirect on refresh failure). Prefer httpOnly cookies if E1.2 uses them. Wire the existing Settings "account" placeholders to real user data. + +**Acceptance Criteria.** +- Unauthenticated users are redirected to login from guarded routes. +- Login persists a session across refresh; logout clears it. +- 401 triggers a silent refresh, then redirect if that fails. +- A component/integration test covers the guard. + +--- + +#### E1.5 — Encrypt and store AI provider keys per user +**Type:** Task · **Area:** backend, ai · **Priority:** P1 + +**Task.** Persist each user's AI provider API keys encrypted at rest and inject them per-request instead of relying on a single global env key. + +**Rationale.** Multi-tenant AI requires per-user keys; storing them in plaintext or sharing one global key is a security and billing problem. Directly unblocks AI epic #32 (Provider Configuration & Secret Management). + +**References.** `apps/backend/app/ai/providers/`, `apps/backend/app/core/config.py`, issue #32. + +**Implementation Notes.** Encrypt with a KMS or a Fernet key sourced from env; never return keys in API responses (write-only field, show last-4 only). Scope key lookup by `owner_id`. + +**Acceptance Criteria.** +- Provider keys are stored encrypted and never serialized back to the client in full. +- AI requests use the current user's key; missing key returns a clear, actionable error. +- Tests cover encrypt/decrypt round-trip and the no-leak contract; CI green. + +--- + +### EPIC E2 — Security Baseline +**Type:** Epic · **Area:** backend, infra · **Priority:** P0 · **Milestone:** M1 + +**Goal.** Establish the minimum security posture before exposing PARTHA to real traffic: rate limiting, security headers, locked-down CORS, and automated dependency/secret scanning. + +**Why now.** "Security is one of the fastest ways to lose production trust." These are cheap to add now and expensive to retrofit after an incident. + +**Child issues:** E2.1–E2.3. + +--- + +#### E2.1 — Add rate limiting (Redis-backed) +**Type:** Task · **Area:** backend, infra · **Priority:** P0 + +**Task.** Add per-IP and per-user rate limiting, with tighter budgets on expensive routes (ingestion, AI). + +**Rationale.** Prevents abuse and runaway AI cost; a baseline production requirement. + +**References.** `apps/backend/app/main.py`, `apps/backend/app/core/redis.py` (Redis already present). + +**Implementation Notes.** `slowapi` (or an ASGI middleware) backed by the existing Redis. Sensible defaults globally, stricter on `/analyze`, `/ai`, and archive upload. Return `429` with `Retry-After`. + +**Acceptance Criteria.** +- Exceeding the limit returns 429 with `Retry-After`; a test proves it. +- AI and ingestion endpoints have stricter, separately-configured budgets. +- Limits are configurable via env; CI green. + +--- + +#### E2.2 — Security headers + CORS lockdown +**Type:** Task · **Area:** backend · **Priority:** P1 + +**Task.** Add a security-headers middleware and replace permissive CORS with an explicit allowlist from config. + +**Rationale.** Missing headers and open CORS are common, easily-scanned production failures. + +**References.** `apps/backend/app/main.py`, `apps/backend/app/core/config.py`. + +**Implementation Notes.** Set `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`, `Content-Security-Policy`, and `Strict-Transport-Security`. CORS origins come from an env allowlist; no `*` with credentials. + +**Acceptance Criteria.** +- Responses carry the security headers (asserted in a test). +- CORS rejects unlisted origins; allowed origins come from config. +- CI green. + +--- + +#### E2.3 — Dependency, secret, and code scanning in CI +**Type:** Task · **Area:** infra · **Priority:** P1 + +**Task.** Enable Dependabot, CodeQL, and secret scanning; make them required checks on `main`/`dev`. + +**Rationale.** Automated supply-chain and secret detection is table stakes and part of the merge gate. + +**References.** `.github/workflows/ci.yml`, `.github/` config. + +**Implementation Notes.** Add `dependabot.yml` for npm + pip. Add a CodeQL workflow for JS/TS + Python. Enable push-protection secret scanning in repo settings. Triage the initial findings backlog as follow-up issues. + +**Acceptance Criteria.** +- Dependabot opens update PRs; CodeQL runs on PRs; secret scanning is on. +- These checks are required before merge (branch protection). +- Initial critical/high findings are triaged into issues. + +--- + +### EPIC E3 — Repo Hygiene & Frontend Test Harness +**Type:** Epic · **Area:** frontend, infra · **Priority:** P1 · **Milestone:** M1 + +**Goal.** Make `main` clean and trustworthy for new contributors, and stand up the missing frontend test capability. + +**Why now.** The frontend has zero tests and the working tree/build artifacts are messy. Both undermine confidence exactly when new people join. + +**Child issues:** E3.1–E3.2. + +--- + +#### E3.1 — Establish `dev`→`main` release cadence + green fresh clone +**Type:** Task · **Area:** infra · **Priority:** P1 · _(build-artifact hygiene already done on `dev`)_ + +**Task.** `dist/`/`tsbuildinfo` are no longer tracked and the monorepo `.gitignore` is improved — that part is **done**. Remaining work: fix that `main` is ~158 files behind `dev`, and verify a fresh clone of the trunk builds and tests green with documented steps. + +**Rationale.** New contributors judge a project by whether the default branch runs on day one. Right now cloning `main` gets almost none of the platform. + +**References.** `.gitignore`, `README.md` Getting Started, `.github/workflows/release.yml`. + +**Implementation Notes.** Decide: either make `dev` the default branch, or open a `dev`→`main` promotion PR now and repeat it every milestone. Then confirm README setup steps work from a clean clone on a fresh machine/container. + +**Acceptance Criteria.** +- Default/trunk branch reflects the current platform (no large unexplained gap between it and `dev`). +- A documented `dev`→`main` promotion ritual exists (or `dev` is the default branch). +- Fresh clone → documented setup → frontend build + backend tests pass. + +--- + +#### E3.2 — Stand up Vitest + React Testing Library with coverage gate +**Type:** Task · **Area:** frontend · **Priority:** P1 + +**Task.** Add Vitest + RTL, write first real tests (an API client behavior + a guarded route or a core hook), and add the frontend test job to CI with a coverage floor. + +**Rationale.** The frontend currently has no safety net; as more people touch it, regressions will ship silently. + +**References.** `apps/frontend/`, `.github/workflows/ci.yml`. + +**Implementation Notes.** Start the coverage floor low (e.g. 20%) and ratchet up per milestone. Prioritize testing the API client, auth guard, and feature hooks over presentational components. + +**Acceptance Criteria.** +- `npm --prefix apps/frontend run test` runs in CI and is required. +- At least 3 meaningful tests exist (client/hook/guard). +- Coverage threshold enforced and documented. + +--- + +### EPIC E4 — Persisted Knowledge Graph _(M2 — start of "real intelligence")_ +**Type:** Epic · **Area:** backend, db, ai · **Priority:** P0 · **Milestone:** M2 + +**Goal.** Deliver the persisted knowledge graph that `CONTRIBUTING.md` and the README describe as the single source of truth — a real model of nodes (modules, files, services), edges (imports, calls, dependencies), and artifacts, persisted and queryable, that architecture/dependency/review/docs/AI/search all read from. + +**Why now.** It's the core in-progress item and the reason the current outputs are "heuristic." Every "Partial" feature upgrades to "reliable" once it reads from a shared graph instead of re-deriving structure. This is what actually takes PARTHA to the next level technically — do it right after the M1 auth/security boundary exists so the graph is user-scoped from day one. + +**Suggested child issues.** (spec these in the epic before starting) +- E4.1 Define the graph schema + persistence (Postgres tables or a graph store) and migration. +- E4.2 Populate the graph from the existing parser/intelligence engine during ingestion. +- E4.3 Migrate the architecture view to read modules/edges from the graph. +- E4.4 Migrate engineering review to cite graph-backed evidence (feeds E6 and issue #36 citations). +- E4.5 Expose a graph query API the AI context retrieval (#33) consumes. + +**Acceptance Criteria (epic-level).** +- A persisted, user-scoped graph is produced on ingestion and survives restarts. +- At least two product surfaces (architecture + review) read from the graph rather than recomputing. +- Documented schema + query API; tests cover graph build and a cross-feature read. + +--- + +## 5. Suggested first two weeks + +1. **Day 1–2:** E3.1 (fix `main`↔`dev`: promote or switch default) + set up branch protection, CODEOWNERS, the Project board, issue types, and milestones. Lift the issue-creation restriction. Onboarding surface ready before anyone joins. +2. **Decide build-vs-buy on auth** (E1 note) — this unblocks the whole M1 critical path. +3. **Parallelize M1:** one owner on E1 (auth), one on E2 (security), a new hire on E3.2 (frontend tests) as a scoped, self-contained on-ramp. +4. Only after the auth boundary lands, open E4 (knowledge graph) design discussion on its epic issue. + +_Progress already banked (don't re-scope): build-artifact hygiene, observability + deployment/release **docs and scaffolding**, the AI provider architecture, and reports/export. The gap to production is the security boundary (E1/E2) and turning the shipped ops scaffolding into live metrics/alerts/staging (E7/E8)._ + +--- + +## Sources + +- [Evolving GitHub Issues and Projects (GA)](https://github.blog/changelog/2025-04-09-evolving-github-issues-and-projects/) · [Adding sub-issues](https://docs.github.com/en/issues/tracking-your-work-with-issues/using-issues/adding-sub-issues) · [Best practices for Projects](https://docs.github.com/en/issues/planning-and-tracking-with-projects/learning-about-projects/best-practices-for-projects) +- [A Practical Guide to FastAPI Security](https://davidmuraya.com/blog/fastapi-security-guide/) · [FastAPI production deployment best practices (Render)](https://render.com/articles/fastapi-production-deployment-best-practices) · [API Security Best Practices for Production (OneUptime)](https://oneuptime.com/blog/post/2026-02-20-api-security-best-practices/view) +- [Production Readiness Checklist for Web Applications — 2026 (Rootcode)](https://www.rootcode.in/blog/production-readiness-checklist-for-web-applications-the-2026-guide-mr33mw4l) · [Production readiness checklist (getDX)](https://getdx.com/blog/production-readiness-checklist/) · [The Ultimate SRE Reliability Checklist (OneUptime)](https://oneuptime.com/blog/post/2025-09-10-sre-checklist/view) +- Internal: `README.md`, `CONTRIBUTING.md`, `docs/audit/`, repository issues #14, #32–#37. From db1d27a5fa438a7b24a154c8f2d9df3b56e3ed2e Mon Sep 17 00:00:00 2001 From: PARTH J ROHIT Date: Fri, 10 Jul 2026 15:57:30 +0100 Subject: [PATCH 002/347] Revise SECURITY.md for clarity on support and reporting Updated the security policy to clarify supported versions and reporting process for vulnerabilities. --- SECURITY.md | 57 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 SECURITY.md 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. From 144a76015be6d4a8a6cd2694a24054248c9357be Mon Sep 17 00:00:00 2001 From: Parth Rohit Date: Fri, 10 Jul 2026 23:09:02 +0100 Subject: [PATCH 003/347] chore(ci): add repository hygiene guard --- .github/workflows/ci.yml | 33 +++++++++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c4ce5df8..8f62eb05 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,9 +9,30 @@ on: - dev jobs: + repository-hygiene: + name: Repository Hygiene + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Verify tracked generated files + run: | + matches=$(git ls-files | grep -E '(^|/)dist/|(^|/)\.env($|\.)' || true) + if [ -n "$matches" ]; then + echo "Tracked generated or environment files detected:" + echo "$matches" + exit 1 + fi + + echo "Repository hygiene checks passed." + frontend: name: Frontend + needs: repository-hygiene runs-on: ubuntu-latest + steps: - name: Checkout uses: actions/checkout@v4 @@ -34,7 +55,9 @@ jobs: backend: name: Backend + needs: repository-hygiene runs-on: ubuntu-latest + steps: - name: Checkout uses: actions/checkout@v4 @@ -42,9 +65,12 @@ 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 @@ -54,7 +80,9 @@ jobs: docker-compose: name: Docker Compose + needs: repository-hygiene runs-on: ubuntu-latest + steps: - name: Checkout uses: actions/checkout@v4 @@ -77,9 +105,10 @@ jobs: docker compose ps sleep 2 done + docker compose logs api postgres redis exit 1 - name: Stop Compose stack if: always() - run: docker compose down -v + run: docker compose down -v \ No newline at end of file From 7ff4b73c13d631b5b4de9fd6141e272b738de4c6 Mon Sep 17 00:00:00 2001 From: Parth Rohit Date: Fri, 10 Jul 2026 23:56:47 +0100 Subject: [PATCH 004/347] fix(ci): allow tracked .env.example files --- .github/workflows/ci.yml | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8f62eb05..49fafead 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,20 +10,31 @@ on: jobs: repository-hygiene: + name: Repository Hygiene + runs-on: ubuntu-latest steps: + - name: Checkout + uses: actions/checkout@v4 - name: Verify tracked generated files + run: | - matches=$(git ls-files | grep -E '(^|/)dist/|(^|/)\.env($|\.)' || true) + + 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." From 8cf875dbeb22abe80437d85ec964a0d137ead55e Mon Sep 17 00:00:00 2001 From: Parth Rohit Date: Fri, 10 Jul 2026 23:22:54 +0100 Subject: [PATCH 005/347] fix(ai): remove synthetic repository citations (#48) --- apps/backend/app/ai/orchestrator.py | 2 +- apps/backend/app/ai/prompt_builder.py | 8 ++++++-- apps/backend/app/ai/repository_context.py | 17 ++++++----------- apps/backend/tests/test_ai_api_contract.py | 12 +++--------- apps/backend/tests/test_ai_architecture.py | 7 +++++-- 5 files changed, 21 insertions(+), 25 deletions(-) diff --git a/apps/backend/app/ai/orchestrator.py b/apps/backend/app/ai/orchestrator.py index 46181596..bc979d9e 100644 --- a/apps/backend/app/ai/orchestrator.py +++ b/apps/backend/app/ai/orchestrator.py @@ -114,7 +114,7 @@ async def query(self, request: AiQueryRequest) -> AiQueryResponse: role="assistant", content=provider_response.content, timestamp=datetime.now(UTC), - citations=[citation.to_schema() for citation in repository_context.citations], + citations=[citation.to_schema() for citation in repository_context.citations] or None, ), suggestions=[ "Explain the main architecture boundaries.", diff --git a/apps/backend/app/ai/prompt_builder.py b/apps/backend/app/ai/prompt_builder.py index a6c2af0f..04ede750 100644 --- a/apps/backend/app/ai/prompt_builder.py +++ b/apps/backend/app/ai/prompt_builder.py @@ -8,8 +8,12 @@ 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). 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, diff --git a/apps/backend/app/ai/repository_context.py b/apps/backend/app/ai/repository_context.py index 77a49591..2893d6fc 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, @@ -34,15 +33,11 @@ def build(self, record: RepositoryRecord, selected_file: str | None = None) -> R DependencyContext(name=dependency.name, version=dependency.version) for dependency in repository_intelligence.dependencies[:20] ) - citations = tuple( - Citation( - file=path, - start_line=1, - end_line=1, - content="Repository file path included in analysis context.", - ) - for path in highlighted[:5] - ) + # No citations are emitted today: the context is built from repository + # structure/metadata only, not from source lines, so there is nothing to + # ground a real file:line citation against. Fabricating 1:1 placeholder + # citations would misrepresent the answer as evidence-backed. Real + # citations will come from the persisted knowledge graph (M2). return RepositoryContext( repository=RepositoryIdentity(id=record.id, name=record.name), architecture=ArchitectureContext( @@ -55,5 +50,5 @@ def build(self, record: RepositoryRecord, selected_file: str | None = None) -> R 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/tests/test_ai_api_contract.py b/apps/backend/tests/test_ai_api_contract.py index f7a2be70..3d44fa44 100644 --- a/apps/backend/tests/test_ai_api_contract.py +++ b/apps/backend/tests/test_ai_api_contract.py @@ -76,14 +76,8 @@ def test_ai_query_endpoint_preserves_public_response_contract(client): 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) diff --git a/apps/backend/tests/test_ai_architecture.py b/apps/backend/tests/test_ai_architecture.py index 228e0a8a..dc7c008e 100644 --- a/apps/backend/tests/test_ai_architecture.py +++ b/apps/backend/tests/test_ai_architecture.py @@ -78,7 +78,9 @@ def test_repository_context_builder_uses_repository_intelligence(tmp_path: Path) 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): @@ -111,7 +113,8 @@ def test_ai_orchestrator_preserves_query_response_shape(tmp_path: Path): assert response.message.role == "assistant" assert response.message.content == "answer from openai" - assert response.message.citations + # No fabricated citations are returned (F4/F5); real ones await the graph (M2). + assert response.message.citations is None assert response.suggestions == [ "Explain the main architecture boundaries.", "What files should I read first?", From a62ccd4952f1f125f695bdfb26a6eb2ed8349b62 Mon Sep 17 00:00:00 2001 From: Parth Rohit Date: Fri, 10 Jul 2026 23:31:14 +0100 Subject: [PATCH 006/347] feat(repository): harden repository import pipeline (#50) --- apps/backend/Dockerfile | 4 +- apps/backend/app/core/config.py | 17 +++++-- apps/backend/app/github/client.py | 45 ++++++++++++++++++- apps/backend/app/schemas/repository.py | 1 + .../app/services/repository_service.py | 21 +++++++-- apps/backend/tests/test_ingestion_pipeline.py | 26 +++++++++++ docker-compose.yml | 2 +- docs/operations/production-deployment.md | 1 + 8 files changed, 108 insertions(+), 9 deletions(-) diff --git a/apps/backend/Dockerfile b/apps/backend/Dockerfile index e8faf5f7..77ec69f4 100644 --- a/apps/backend/Dockerfile +++ b/apps/backend/Dockerfile @@ -19,4 +19,6 @@ RUN pip install --no-cache-dir --upgrade pip \ EXPOSE 8000 -CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] +# Run database migrations before serving so Alembic is authoritative for schema +# (AUTO_CREATE_TABLES should be false in non-dev; see docker-compose.yml). +CMD ["sh", "-c", "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000"] diff --git a/apps/backend/app/core/config.py b/apps/backend/app/core/config.py index 360d9e85..67dd87b3 100644 --- a/apps/backend/app/core/config.py +++ b/apps/backend/app/core/config.py @@ -4,7 +4,7 @@ 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 SUPPORTED_LOG_FORMATS = {"text", "json"} @@ -21,9 +21,14 @@ class Settings(BaseSettings): 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 clone_timeout_seconds: int = 120 max_upload_size_bytes: int = 100 * 1024 * 1024 + max_clone_size_bytes: int = 500 * 1024 * 1024 model_config = SettingsConfigDict( env_file=".env", @@ -91,13 +96,19 @@ 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("clone_timeout_seconds", "max_upload_size_bytes", "max_clone_size_bytes") @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 + @lru_cache def get_settings() -> Settings: diff --git a/apps/backend/app/github/client.py b/apps/backend/app/github/client.py index c0f574ee..a8ea6957 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,23 @@ 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 clone at %s: %s", repo_dir, exc) + return None + sha = result.stdout.strip() + return sha or 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() @@ -64,7 +85,29 @@ def clone_public_repository(self, url: str, destination: Path, branch: str | Non except (subprocess.CalledProcessError, OSError) as exc: shutil.rmtree(destination, ignore_errors=True) stderr = exc.stderr if isinstance(exc, subprocess.CalledProcessError) else str(exc) + # Keep raw git stderr (may contain local paths/URLs) server-side only. + logger.warning("git clone failed for %s: %s", url, stderr) 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 child in path.rglob("*"): + try: + if child.is_file() and not child.is_symlink(): + total += child.stat().st_size + except OSError: + continue + return total diff --git a/apps/backend/app/schemas/repository.py b/apps/backend/app/schemas/repository.py index 448c4c41..e832a634 100644 --- a/apps/backend/app/schemas/repository.py +++ b/apps/backend/app/schemas/repository.py @@ -62,6 +62,7 @@ class RepositoryResponse(CamelModel): uploaded_at: datetime analysed_at: datetime | None = None error_message: str | None = None + commit_sha: str | None = None meta: RepositoryMeta | None = None file_tree: list[FileTreeNode] = Field(default_factory=list) diff --git a/apps/backend/app/services/repository_service.py b/apps/backend/app/services/repository_service.py index f6257f76..f360b151 100644 --- a/apps/backend/app/services/repository_service.py +++ b/apps/backend/app/services/repository_service.py @@ -1,4 +1,5 @@ import base64 +import hashlib from datetime import UTC, datetime from pathlib import Path from typing import NoReturn @@ -79,6 +80,7 @@ def import_github_repository(self, request: GitHubImportRequest) -> RepositoryRe try: self.github.clone_public_repository(url, destination, branch) root = self._resolve_repository_root(destination) + commit_sha = self.github.read_head_commit(destination) tree, meta, total_size = self.parser.parse(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) @@ -103,7 +105,7 @@ def import_github_repository(self, request: GitHubImportRequest) -> RepositoryRe analysis_progress=70, uploaded_at=now, analysed_at=None, - repo_metadata=self._metadata_with_intelligence(meta, repository_intelligence), + repo_metadata=self._metadata_with_intelligence(meta, repository_intelligence, commit_sha), 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)) @@ -120,6 +122,7 @@ async def import_uploaded_repository(self, file: UploadFile) -> RepositoryRespon archive_path = await self.storage.save_upload(repository_id, file, self.settings.max_upload_size_bytes) try: + content_hash = self._content_hash_for_upload(archive_path) root = self.storage.extract_archive(archive_path, repository_id) tree, meta, total_size = self.parser.parse(root) self._validate_parsed_repository(meta.total_files) @@ -147,7 +150,7 @@ async def import_uploaded_repository(self, file: UploadFile) -> RepositoryRespon analysis_progress=70, uploaded_at=now, analysed_at=None, - repo_metadata=self._metadata_with_intelligence(meta, repository_intelligence), + repo_metadata=self._metadata_with_intelligence(meta, repository_intelligence, content_hash), 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)) @@ -235,6 +238,7 @@ def to_response(self, record: RepositoryRecord) -> RepositoryResponse: uploaded_at=record.uploaded_at, analysed_at=record.analysed_at, error_message=record.error_message, + commit_sha=(record.repo_metadata or {}).get("commitSha"), meta=record.repo_metadata, file_tree=record.file_tree, ) @@ -261,7 +265,18 @@ def _repository_name_from_archive(self, filename: str) -> str: return filename[: -len(suffix)] return Path(filename).stem - def _metadata_with_intelligence(self, meta, intelligence) -> dict: + def _metadata_with_intelligence(self, meta, intelligence, commit_sha: str | None = None) -> dict: metadata = meta.model_dump(mode="json", by_alias=True) metadata["intelligence"] = intelligence.model_dump(mode="json", by_alias=True) + # Commit-addressability seed: the git HEAD SHA for GitHub imports, or a + # stable content hash (sha256:...) for uploads that have no git history. + # Stored in metadata for now; promotion to a first-class column is M2 work. + metadata["commitSha"] = commit_sha return metadata + + 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/tests/test_ingestion_pipeline.py b/apps/backend/tests/test_ingestion_pipeline.py index 7a203c6b..bca15214 100644 --- a/apps/backend/tests/test_ingestion_pipeline.py +++ b/apps/backend/tests/test_ingestion_pipeline.py @@ -55,6 +55,9 @@ 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" + # Uploads have no git history, so a stable content hash stands in as the + # commit-addressability identifier (T9 / F2). + assert repository["commitSha"].startswith("sha256:") start_response = client.post(f"/analysis/{repository['id']}/start") assert start_response.status_code == 200 @@ -162,3 +165,26 @@ def fake_run(*args, **kwargs): client = GitHubClient(Settings(clone_timeout_seconds=1)) with pytest.raises(TimeoutServiceError): client.clone_public_repository("https://github.com/example/demo", tmp_path / "demo") + + +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) + + client = GitHubClient(Settings(max_clone_size_bytes=1024)) + with pytest.raises(ValidationServiceError): + 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/docker-compose.yml b/docker-compose.yml index b7dca0aa..4598f566 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,7 +13,7 @@ services: 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} + AUTO_CREATE_TABLES: ${AUTO_CREATE_TABLES:-false} volumes: - partha_storage:/data/partha depends_on: diff --git a/docs/operations/production-deployment.md b/docs/operations/production-deployment.md index 9708111e..808b119c 100644 --- a/docs/operations/production-deployment.md +++ b/docs/operations/production-deployment.md @@ -29,6 +29,7 @@ PARTHA does not yet include authentication, authorization, tenant isolation, or | `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. | +| `MAX_CLONE_SIZE_BYTES` | Maximum on-disk size of a cloned GitHub repository (default 500 MiB). Over-limit clones are aborted and cleaned up. | 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. From 96a8972ed42e00779cffa4e0e0279a41599b34ae Mon Sep 17 00:00:00 2001 From: Parth Rohit Date: Fri, 10 Jul 2026 23:33:21 +0100 Subject: [PATCH 007/347] docs: clarify parser implementation status (#51) --- README.md | 4 ++-- apps/backend/app/parsers/tree_sitter_parser.py | 10 +++++++++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index b3a704ce..c14f686b 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,7 @@ PARTHA is organised around engineering capabilities rather than individual scree | 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 | +| AI Workspace | Lets configured AI providers answer repository-aware questions from structured repository context (languages, frameworks, modules, dependencies, and file paths). Providers do not parse repositories directly. Answers are grounded in repository structure/metadata only — source-line citations are not produced yet and arrive with the persisted knowledge graph. | Partial | | 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 | @@ -351,7 +351,7 @@ PARTHA uses pragmatic, inspectable tools rather than opaque infrastructure. | 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. | +| Tree-sitter (planned) | Symbol extraction today is regex-based; tree-sitter is a planned foundation for deeper, line-accurate language-aware extraction and is not yet wired for parsing. | | 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. | diff --git a/apps/backend/app/parsers/tree_sitter_parser.py b/apps/backend/app/parsers/tree_sitter_parser.py index 29120aa3..9fbde65d 100644 --- a/apps/backend/app/parsers/tree_sitter_parser.py +++ b/apps/backend/app/parsers/tree_sitter_parser.py @@ -8,9 +8,17 @@ class SyntaxParseResult: class TreeSitterParser: - """Thin integration point for future language-specific tree-sitter grammars.""" + """PLACEHOLDER — tree-sitter extraction is NOT implemented yet. + + This class only maps a file extension to a language name. It never parses + source or produces symbols: ``parse_symbols`` always returns an empty + ``symbols`` list. Real tree-sitter TS/Python extraction (with line spans) is + planned for the graph work (M2). Symbol extraction today is regex-based in + ``app.intelligence.engine``. Do not treat this as functional syntax parsing. + """ def parse_symbols(self, content: bytes, extension: str | None) -> SyntaxParseResult: + # Always returns symbols=[]: this is a not-yet-implemented placeholder. if not extension or not content: return SyntaxParseResult(language=None, symbols=[]) return SyntaxParseResult(language=self._language_from_extension(extension), symbols=[]) From 0ce3e5a54d49837c5e0df3735e65fde8274861a5 Mon Sep 17 00:00:00 2001 From: Parth Rohit Date: Fri, 10 Jul 2026 23:34:18 +0100 Subject: [PATCH 008/347] chore(deps): remove unused backend dependencies (#52) --- apps/backend/pyproject.toml | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/backend/pyproject.toml b/apps/backend/pyproject.toml index 9390c1f6..2e5c6e63 100644 --- a/apps/backend/pyproject.toml +++ b/apps/backend/pyproject.toml @@ -16,9 +16,7 @@ dependencies = [ "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", "python-multipart>=0.0.9", "httpx>=0.27.0", "xhtml2pdf>=0.2.16", From a4238e6f4c1e6fbae87a3eaef7822497d3fedbe4 Mon Sep 17 00:00:00 2001 From: Parth Rohit Date: Fri, 10 Jul 2026 23:39:48 +0100 Subject: [PATCH 009/347] fix(frontend): remove misleading data source badge (#53) --- .../src/shared/components/ui/DataSourceBadge.tsx | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/apps/frontend/src/shared/components/ui/DataSourceBadge.tsx b/apps/frontend/src/shared/components/ui/DataSourceBadge.tsx index fb7d183f..0d9bf990 100644 --- a/apps/frontend/src/shared/components/ui/DataSourceBadge.tsx +++ b/apps/frontend/src/shared/components/ui/DataSourceBadge.tsx @@ -1,13 +1,16 @@ import type { DataSource } from '@/shared/types'; -import { Badge } from './Badge'; interface DataSourceBadgeProps { source: DataSource | null | undefined; } +// This badge previously always rendered "Real data" regardless of its input. +// The `DataSource` type has a single value ('real') and the backend hard-codes +// data_source to "real", so the badge conveyed no information (audit F20). It now +// renders nothing. Call sites are intentionally left as harmless no-ops, and the +// data_source field/column is retained — dropping the NOT NULL column is a schema +// migration that is out of scope for this low-risk change. export function DataSourceBadge({ source }: DataSourceBadgeProps) { - if (!source) return null; - return ( - Real data - ); + void source; + return null; } From 00adf6df5024ba5154cc189b2640320935790647 Mon Sep 17 00:00:00 2001 From: Parth Rohit Date: Sat, 11 Jul 2026 00:04:54 +0100 Subject: [PATCH 010/347] chore(ci): stop tracking generated build artifacts --- dist/assets/AIWorkspacePage-Ddm0QeRu.js | 1 - dist/assets/AnalysisPipelinePage-Zvy8DKxE.js | 1 - dist/assets/ArchitecturePage-7B6CwUGU.js | 9 --- dist/assets/ArchitecturePage-DLioOiRN.css | 1 - dist/assets/DashboardPage-B7P5IANl.js | 1 - dist/assets/DataSourceBadge-D7dhrb9n.js | 1 - dist/assets/DependenciesPage-L38XGsJu.js | 2 - dist/assets/DocumentationPage-CP6VAto-.js | 1 - dist/assets/EmptyState-BeMA8Ikt.js | 1 - dist/assets/EngineeringReviewPage-BpcXtZzj.js | 2 - dist/assets/InsightsPage-BlJ2erOS.js | 1 - dist/assets/PageHeader-DsNWbEI1.js | 1 - dist/assets/RepositoriesPage-GMDpbLMw.js | 1 - dist/assets/RepositoryDetailPage-DG0b2Pf1.js | 80 ------------------- dist/assets/SettingsPage-vKuDSF0h.js | 1 - dist/assets/UploadPage-04HmGrk-.js | 5 -- dist/assets/activity-BtAw6juF.js | 1 - dist/assets/ai-DpySo4Gb.js | 2 - dist/assets/arrow-left-BeIPd3Ya.js | 1 - dist/assets/arrow-right-DpUoK8Nu.js | 1 - dist/assets/circle-Ds7OrsG4.js | 1 - dist/assets/circle-alert-BUYmyF7c.js | 1 - dist/assets/client-S4ekmpXx.js | 1 - dist/assets/clock-qFz1Yxfz.js | 1 - dist/assets/download-BM44DUO2.js | 1 - dist/assets/file-BFCfRogd.js | 1 - dist/assets/file-code-o79iDh0J.js | 1 - dist/assets/hard-drive-B0dUssIC.js | 1 - dist/assets/index-BtBRIalZ.css | 1 - dist/assets/index-QB2QUwKm.js | 19 ----- dist/assets/package-Dhq59A-j.js | 1 - dist/assets/panel-left-open-DLIMLp_X.js | 1 - dist/assets/status-5ylMtKLG.js | 1 - .../useRepositoryFeatureStatus-BAj4lrBf.js | 1 - dist/assets/x-Bf4UPE8b.js | 1 - dist/assets/zap-8B5FG3kN.js | 1 - dist/index.html | 18 ----- 37 files changed, 166 deletions(-) delete mode 100644 dist/assets/AIWorkspacePage-Ddm0QeRu.js delete mode 100644 dist/assets/AnalysisPipelinePage-Zvy8DKxE.js delete mode 100644 dist/assets/ArchitecturePage-7B6CwUGU.js delete mode 100644 dist/assets/ArchitecturePage-DLioOiRN.css delete mode 100644 dist/assets/DashboardPage-B7P5IANl.js delete mode 100644 dist/assets/DataSourceBadge-D7dhrb9n.js delete mode 100644 dist/assets/DependenciesPage-L38XGsJu.js delete mode 100644 dist/assets/DocumentationPage-CP6VAto-.js delete mode 100644 dist/assets/EmptyState-BeMA8Ikt.js delete mode 100644 dist/assets/EngineeringReviewPage-BpcXtZzj.js delete mode 100644 dist/assets/InsightsPage-BlJ2erOS.js delete mode 100644 dist/assets/PageHeader-DsNWbEI1.js delete mode 100644 dist/assets/RepositoriesPage-GMDpbLMw.js delete mode 100644 dist/assets/RepositoryDetailPage-DG0b2Pf1.js delete mode 100644 dist/assets/SettingsPage-vKuDSF0h.js delete mode 100644 dist/assets/UploadPage-04HmGrk-.js delete mode 100644 dist/assets/activity-BtAw6juF.js delete mode 100644 dist/assets/ai-DpySo4Gb.js delete mode 100644 dist/assets/arrow-left-BeIPd3Ya.js delete mode 100644 dist/assets/arrow-right-DpUoK8Nu.js delete mode 100644 dist/assets/circle-Ds7OrsG4.js delete mode 100644 dist/assets/circle-alert-BUYmyF7c.js delete mode 100644 dist/assets/client-S4ekmpXx.js delete mode 100644 dist/assets/clock-qFz1Yxfz.js delete mode 100644 dist/assets/download-BM44DUO2.js delete mode 100644 dist/assets/file-BFCfRogd.js delete mode 100644 dist/assets/file-code-o79iDh0J.js delete mode 100644 dist/assets/hard-drive-B0dUssIC.js delete mode 100644 dist/assets/index-BtBRIalZ.css delete mode 100644 dist/assets/index-QB2QUwKm.js delete mode 100644 dist/assets/package-Dhq59A-j.js delete mode 100644 dist/assets/panel-left-open-DLIMLp_X.js delete mode 100644 dist/assets/status-5ylMtKLG.js delete mode 100644 dist/assets/useRepositoryFeatureStatus-BAj4lrBf.js delete mode 100644 dist/assets/x-Bf4UPE8b.js delete mode 100644 dist/assets/zap-8B5FG3kN.js delete mode 100644 dist/index.html 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 - - - - - -
- - From 53bd09c63b7e8429dbeceb5794812408b0ee507d Mon Sep 17 00:00:00 2001 From: Parth Rohit Date: Fri, 10 Jul 2026 23:22:54 +0100 Subject: [PATCH 011/347] fix(ai): remove synthetic repository citations (#48) --- apps/backend/app/ai/orchestrator.py | 2 +- apps/backend/app/ai/prompt_builder.py | 8 ++++++-- apps/backend/app/ai/repository_context.py | 17 ++++++----------- apps/backend/tests/test_ai_api_contract.py | 12 +++--------- apps/backend/tests/test_ai_architecture.py | 7 +++++-- 5 files changed, 21 insertions(+), 25 deletions(-) diff --git a/apps/backend/app/ai/orchestrator.py b/apps/backend/app/ai/orchestrator.py index 46181596..bc979d9e 100644 --- a/apps/backend/app/ai/orchestrator.py +++ b/apps/backend/app/ai/orchestrator.py @@ -114,7 +114,7 @@ async def query(self, request: AiQueryRequest) -> AiQueryResponse: role="assistant", content=provider_response.content, timestamp=datetime.now(UTC), - citations=[citation.to_schema() for citation in repository_context.citations], + citations=[citation.to_schema() for citation in repository_context.citations] or None, ), suggestions=[ "Explain the main architecture boundaries.", diff --git a/apps/backend/app/ai/prompt_builder.py b/apps/backend/app/ai/prompt_builder.py index a6c2af0f..04ede750 100644 --- a/apps/backend/app/ai/prompt_builder.py +++ b/apps/backend/app/ai/prompt_builder.py @@ -8,8 +8,12 @@ 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). 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, diff --git a/apps/backend/app/ai/repository_context.py b/apps/backend/app/ai/repository_context.py index 77a49591..2893d6fc 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, @@ -34,15 +33,11 @@ def build(self, record: RepositoryRecord, selected_file: str | None = None) -> R DependencyContext(name=dependency.name, version=dependency.version) for dependency in repository_intelligence.dependencies[:20] ) - citations = tuple( - Citation( - file=path, - start_line=1, - end_line=1, - content="Repository file path included in analysis context.", - ) - for path in highlighted[:5] - ) + # No citations are emitted today: the context is built from repository + # structure/metadata only, not from source lines, so there is nothing to + # ground a real file:line citation against. Fabricating 1:1 placeholder + # citations would misrepresent the answer as evidence-backed. Real + # citations will come from the persisted knowledge graph (M2). return RepositoryContext( repository=RepositoryIdentity(id=record.id, name=record.name), architecture=ArchitectureContext( @@ -55,5 +50,5 @@ def build(self, record: RepositoryRecord, selected_file: str | None = None) -> R 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/tests/test_ai_api_contract.py b/apps/backend/tests/test_ai_api_contract.py index f7a2be70..3d44fa44 100644 --- a/apps/backend/tests/test_ai_api_contract.py +++ b/apps/backend/tests/test_ai_api_contract.py @@ -76,14 +76,8 @@ def test_ai_query_endpoint_preserves_public_response_contract(client): 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) diff --git a/apps/backend/tests/test_ai_architecture.py b/apps/backend/tests/test_ai_architecture.py index 228e0a8a..dc7c008e 100644 --- a/apps/backend/tests/test_ai_architecture.py +++ b/apps/backend/tests/test_ai_architecture.py @@ -78,7 +78,9 @@ def test_repository_context_builder_uses_repository_intelligence(tmp_path: Path) 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): @@ -111,7 +113,8 @@ def test_ai_orchestrator_preserves_query_response_shape(tmp_path: Path): assert response.message.role == "assistant" assert response.message.content == "answer from openai" - assert response.message.citations + # No fabricated citations are returned (F4/F5); real ones await the graph (M2). + assert response.message.citations is None assert response.suggestions == [ "Explain the main architecture boundaries.", "What files should I read first?", From 2152da93973245916115f6ae60f2cf0cb0de8a08 Mon Sep 17 00:00:00 2001 From: Parth Rohit Date: Fri, 10 Jul 2026 23:31:14 +0100 Subject: [PATCH 012/347] feat(repository): harden repository import pipeline (#50) --- apps/backend/Dockerfile | 4 +- apps/backend/app/core/config.py | 17 +++++-- apps/backend/app/github/client.py | 45 ++++++++++++++++++- apps/backend/app/schemas/repository.py | 1 + .../app/services/repository_service.py | 21 +++++++-- apps/backend/tests/test_ingestion_pipeline.py | 26 +++++++++++ docker-compose.yml | 2 +- docs/operations/production-deployment.md | 1 + 8 files changed, 108 insertions(+), 9 deletions(-) diff --git a/apps/backend/Dockerfile b/apps/backend/Dockerfile index e8faf5f7..77ec69f4 100644 --- a/apps/backend/Dockerfile +++ b/apps/backend/Dockerfile @@ -19,4 +19,6 @@ RUN pip install --no-cache-dir --upgrade pip \ EXPOSE 8000 -CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] +# Run database migrations before serving so Alembic is authoritative for schema +# (AUTO_CREATE_TABLES should be false in non-dev; see docker-compose.yml). +CMD ["sh", "-c", "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000"] diff --git a/apps/backend/app/core/config.py b/apps/backend/app/core/config.py index 360d9e85..67dd87b3 100644 --- a/apps/backend/app/core/config.py +++ b/apps/backend/app/core/config.py @@ -4,7 +4,7 @@ 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 SUPPORTED_LOG_FORMATS = {"text", "json"} @@ -21,9 +21,14 @@ class Settings(BaseSettings): 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 clone_timeout_seconds: int = 120 max_upload_size_bytes: int = 100 * 1024 * 1024 + max_clone_size_bytes: int = 500 * 1024 * 1024 model_config = SettingsConfigDict( env_file=".env", @@ -91,13 +96,19 @@ 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("clone_timeout_seconds", "max_upload_size_bytes", "max_clone_size_bytes") @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 + @lru_cache def get_settings() -> Settings: diff --git a/apps/backend/app/github/client.py b/apps/backend/app/github/client.py index c0f574ee..a8ea6957 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,23 @@ 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 clone at %s: %s", repo_dir, exc) + return None + sha = result.stdout.strip() + return sha or 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() @@ -64,7 +85,29 @@ def clone_public_repository(self, url: str, destination: Path, branch: str | Non except (subprocess.CalledProcessError, OSError) as exc: shutil.rmtree(destination, ignore_errors=True) stderr = exc.stderr if isinstance(exc, subprocess.CalledProcessError) else str(exc) + # Keep raw git stderr (may contain local paths/URLs) server-side only. + logger.warning("git clone failed for %s: %s", url, stderr) 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 child in path.rglob("*"): + try: + if child.is_file() and not child.is_symlink(): + total += child.stat().st_size + except OSError: + continue + return total diff --git a/apps/backend/app/schemas/repository.py b/apps/backend/app/schemas/repository.py index 448c4c41..e832a634 100644 --- a/apps/backend/app/schemas/repository.py +++ b/apps/backend/app/schemas/repository.py @@ -62,6 +62,7 @@ class RepositoryResponse(CamelModel): uploaded_at: datetime analysed_at: datetime | None = None error_message: str | None = None + commit_sha: str | None = None meta: RepositoryMeta | None = None file_tree: list[FileTreeNode] = Field(default_factory=list) diff --git a/apps/backend/app/services/repository_service.py b/apps/backend/app/services/repository_service.py index f6257f76..f360b151 100644 --- a/apps/backend/app/services/repository_service.py +++ b/apps/backend/app/services/repository_service.py @@ -1,4 +1,5 @@ import base64 +import hashlib from datetime import UTC, datetime from pathlib import Path from typing import NoReturn @@ -79,6 +80,7 @@ def import_github_repository(self, request: GitHubImportRequest) -> RepositoryRe try: self.github.clone_public_repository(url, destination, branch) root = self._resolve_repository_root(destination) + commit_sha = self.github.read_head_commit(destination) tree, meta, total_size = self.parser.parse(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) @@ -103,7 +105,7 @@ def import_github_repository(self, request: GitHubImportRequest) -> RepositoryRe analysis_progress=70, uploaded_at=now, analysed_at=None, - repo_metadata=self._metadata_with_intelligence(meta, repository_intelligence), + repo_metadata=self._metadata_with_intelligence(meta, repository_intelligence, commit_sha), 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)) @@ -120,6 +122,7 @@ async def import_uploaded_repository(self, file: UploadFile) -> RepositoryRespon archive_path = await self.storage.save_upload(repository_id, file, self.settings.max_upload_size_bytes) try: + content_hash = self._content_hash_for_upload(archive_path) root = self.storage.extract_archive(archive_path, repository_id) tree, meta, total_size = self.parser.parse(root) self._validate_parsed_repository(meta.total_files) @@ -147,7 +150,7 @@ async def import_uploaded_repository(self, file: UploadFile) -> RepositoryRespon analysis_progress=70, uploaded_at=now, analysed_at=None, - repo_metadata=self._metadata_with_intelligence(meta, repository_intelligence), + repo_metadata=self._metadata_with_intelligence(meta, repository_intelligence, content_hash), 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)) @@ -235,6 +238,7 @@ def to_response(self, record: RepositoryRecord) -> RepositoryResponse: uploaded_at=record.uploaded_at, analysed_at=record.analysed_at, error_message=record.error_message, + commit_sha=(record.repo_metadata or {}).get("commitSha"), meta=record.repo_metadata, file_tree=record.file_tree, ) @@ -261,7 +265,18 @@ def _repository_name_from_archive(self, filename: str) -> str: return filename[: -len(suffix)] return Path(filename).stem - def _metadata_with_intelligence(self, meta, intelligence) -> dict: + def _metadata_with_intelligence(self, meta, intelligence, commit_sha: str | None = None) -> dict: metadata = meta.model_dump(mode="json", by_alias=True) metadata["intelligence"] = intelligence.model_dump(mode="json", by_alias=True) + # Commit-addressability seed: the git HEAD SHA for GitHub imports, or a + # stable content hash (sha256:...) for uploads that have no git history. + # Stored in metadata for now; promotion to a first-class column is M2 work. + metadata["commitSha"] = commit_sha return metadata + + 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/tests/test_ingestion_pipeline.py b/apps/backend/tests/test_ingestion_pipeline.py index 7a203c6b..bca15214 100644 --- a/apps/backend/tests/test_ingestion_pipeline.py +++ b/apps/backend/tests/test_ingestion_pipeline.py @@ -55,6 +55,9 @@ 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" + # Uploads have no git history, so a stable content hash stands in as the + # commit-addressability identifier (T9 / F2). + assert repository["commitSha"].startswith("sha256:") start_response = client.post(f"/analysis/{repository['id']}/start") assert start_response.status_code == 200 @@ -162,3 +165,26 @@ def fake_run(*args, **kwargs): client = GitHubClient(Settings(clone_timeout_seconds=1)) with pytest.raises(TimeoutServiceError): client.clone_public_repository("https://github.com/example/demo", tmp_path / "demo") + + +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) + + client = GitHubClient(Settings(max_clone_size_bytes=1024)) + with pytest.raises(ValidationServiceError): + 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/docker-compose.yml b/docker-compose.yml index b7dca0aa..4598f566 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,7 +13,7 @@ services: 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} + AUTO_CREATE_TABLES: ${AUTO_CREATE_TABLES:-false} volumes: - partha_storage:/data/partha depends_on: diff --git a/docs/operations/production-deployment.md b/docs/operations/production-deployment.md index 9708111e..808b119c 100644 --- a/docs/operations/production-deployment.md +++ b/docs/operations/production-deployment.md @@ -29,6 +29,7 @@ PARTHA does not yet include authentication, authorization, tenant isolation, or | `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. | +| `MAX_CLONE_SIZE_BYTES` | Maximum on-disk size of a cloned GitHub repository (default 500 MiB). Over-limit clones are aborted and cleaned up. | 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. From 800f1797bdeaf0b0a84bc097d8371bcd21260eb8 Mon Sep 17 00:00:00 2001 From: Parth Rohit Date: Fri, 10 Jul 2026 23:33:21 +0100 Subject: [PATCH 013/347] docs: clarify parser implementation status (#51) --- README.md | 4 ++-- apps/backend/app/parsers/tree_sitter_parser.py | 10 +++++++++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index b3a704ce..c14f686b 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,7 @@ PARTHA is organised around engineering capabilities rather than individual scree | 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 | +| AI Workspace | Lets configured AI providers answer repository-aware questions from structured repository context (languages, frameworks, modules, dependencies, and file paths). Providers do not parse repositories directly. Answers are grounded in repository structure/metadata only — source-line citations are not produced yet and arrive with the persisted knowledge graph. | Partial | | 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 | @@ -351,7 +351,7 @@ PARTHA uses pragmatic, inspectable tools rather than opaque infrastructure. | 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. | +| Tree-sitter (planned) | Symbol extraction today is regex-based; tree-sitter is a planned foundation for deeper, line-accurate language-aware extraction and is not yet wired for parsing. | | 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. | diff --git a/apps/backend/app/parsers/tree_sitter_parser.py b/apps/backend/app/parsers/tree_sitter_parser.py index 29120aa3..9fbde65d 100644 --- a/apps/backend/app/parsers/tree_sitter_parser.py +++ b/apps/backend/app/parsers/tree_sitter_parser.py @@ -8,9 +8,17 @@ class SyntaxParseResult: class TreeSitterParser: - """Thin integration point for future language-specific tree-sitter grammars.""" + """PLACEHOLDER — tree-sitter extraction is NOT implemented yet. + + This class only maps a file extension to a language name. It never parses + source or produces symbols: ``parse_symbols`` always returns an empty + ``symbols`` list. Real tree-sitter TS/Python extraction (with line spans) is + planned for the graph work (M2). Symbol extraction today is regex-based in + ``app.intelligence.engine``. Do not treat this as functional syntax parsing. + """ def parse_symbols(self, content: bytes, extension: str | None) -> SyntaxParseResult: + # Always returns symbols=[]: this is a not-yet-implemented placeholder. if not extension or not content: return SyntaxParseResult(language=None, symbols=[]) return SyntaxParseResult(language=self._language_from_extension(extension), symbols=[]) From ca437be5d3cadac8f5f276a7c64cf31ab9315298 Mon Sep 17 00:00:00 2001 From: Parth Rohit Date: Fri, 10 Jul 2026 23:34:18 +0100 Subject: [PATCH 014/347] chore(deps): remove unused backend dependencies (#52) --- apps/backend/pyproject.toml | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/backend/pyproject.toml b/apps/backend/pyproject.toml index 9390c1f6..2e5c6e63 100644 --- a/apps/backend/pyproject.toml +++ b/apps/backend/pyproject.toml @@ -16,9 +16,7 @@ dependencies = [ "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", "python-multipart>=0.0.9", "httpx>=0.27.0", "xhtml2pdf>=0.2.16", From 32d56a8bf564fe6e8295e128301ea2f928528572 Mon Sep 17 00:00:00 2001 From: Parth Rohit Date: Fri, 10 Jul 2026 23:22:54 +0100 Subject: [PATCH 015/347] fix(ai): remove synthetic repository citations (#48) --- apps/backend/app/ai/orchestrator.py | 2 +- apps/backend/app/ai/prompt_builder.py | 8 ++++++-- apps/backend/app/ai/repository_context.py | 17 ++++++----------- apps/backend/tests/test_ai_api_contract.py | 12 +++--------- apps/backend/tests/test_ai_architecture.py | 7 +++++-- 5 files changed, 21 insertions(+), 25 deletions(-) diff --git a/apps/backend/app/ai/orchestrator.py b/apps/backend/app/ai/orchestrator.py index 46181596..bc979d9e 100644 --- a/apps/backend/app/ai/orchestrator.py +++ b/apps/backend/app/ai/orchestrator.py @@ -114,7 +114,7 @@ async def query(self, request: AiQueryRequest) -> AiQueryResponse: role="assistant", content=provider_response.content, timestamp=datetime.now(UTC), - citations=[citation.to_schema() for citation in repository_context.citations], + citations=[citation.to_schema() for citation in repository_context.citations] or None, ), suggestions=[ "Explain the main architecture boundaries.", diff --git a/apps/backend/app/ai/prompt_builder.py b/apps/backend/app/ai/prompt_builder.py index a6c2af0f..04ede750 100644 --- a/apps/backend/app/ai/prompt_builder.py +++ b/apps/backend/app/ai/prompt_builder.py @@ -8,8 +8,12 @@ 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). 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, diff --git a/apps/backend/app/ai/repository_context.py b/apps/backend/app/ai/repository_context.py index 77a49591..2893d6fc 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, @@ -34,15 +33,11 @@ def build(self, record: RepositoryRecord, selected_file: str | None = None) -> R DependencyContext(name=dependency.name, version=dependency.version) for dependency in repository_intelligence.dependencies[:20] ) - citations = tuple( - Citation( - file=path, - start_line=1, - end_line=1, - content="Repository file path included in analysis context.", - ) - for path in highlighted[:5] - ) + # No citations are emitted today: the context is built from repository + # structure/metadata only, not from source lines, so there is nothing to + # ground a real file:line citation against. Fabricating 1:1 placeholder + # citations would misrepresent the answer as evidence-backed. Real + # citations will come from the persisted knowledge graph (M2). return RepositoryContext( repository=RepositoryIdentity(id=record.id, name=record.name), architecture=ArchitectureContext( @@ -55,5 +50,5 @@ def build(self, record: RepositoryRecord, selected_file: str | None = None) -> R 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/tests/test_ai_api_contract.py b/apps/backend/tests/test_ai_api_contract.py index f7a2be70..3d44fa44 100644 --- a/apps/backend/tests/test_ai_api_contract.py +++ b/apps/backend/tests/test_ai_api_contract.py @@ -76,14 +76,8 @@ def test_ai_query_endpoint_preserves_public_response_contract(client): 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) diff --git a/apps/backend/tests/test_ai_architecture.py b/apps/backend/tests/test_ai_architecture.py index 228e0a8a..dc7c008e 100644 --- a/apps/backend/tests/test_ai_architecture.py +++ b/apps/backend/tests/test_ai_architecture.py @@ -78,7 +78,9 @@ def test_repository_context_builder_uses_repository_intelligence(tmp_path: Path) 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): @@ -111,7 +113,8 @@ def test_ai_orchestrator_preserves_query_response_shape(tmp_path: Path): assert response.message.role == "assistant" assert response.message.content == "answer from openai" - assert response.message.citations + # No fabricated citations are returned (F4/F5); real ones await the graph (M2). + assert response.message.citations is None assert response.suggestions == [ "Explain the main architecture boundaries.", "What files should I read first?", From 1f6652471cb2264fa1f6ca87173223da11e9f909 Mon Sep 17 00:00:00 2001 From: Parth Rohit Date: Fri, 10 Jul 2026 23:31:14 +0100 Subject: [PATCH 016/347] feat(repository): harden repository import pipeline (#50) --- apps/backend/Dockerfile | 4 +- apps/backend/app/core/config.py | 17 +++++-- apps/backend/app/github/client.py | 45 ++++++++++++++++++- apps/backend/app/schemas/repository.py | 1 + .../app/services/repository_service.py | 21 +++++++-- apps/backend/tests/test_ingestion_pipeline.py | 26 +++++++++++ docker-compose.yml | 2 +- docs/operations/production-deployment.md | 1 + 8 files changed, 108 insertions(+), 9 deletions(-) diff --git a/apps/backend/Dockerfile b/apps/backend/Dockerfile index e8faf5f7..77ec69f4 100644 --- a/apps/backend/Dockerfile +++ b/apps/backend/Dockerfile @@ -19,4 +19,6 @@ RUN pip install --no-cache-dir --upgrade pip \ EXPOSE 8000 -CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] +# Run database migrations before serving so Alembic is authoritative for schema +# (AUTO_CREATE_TABLES should be false in non-dev; see docker-compose.yml). +CMD ["sh", "-c", "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000"] diff --git a/apps/backend/app/core/config.py b/apps/backend/app/core/config.py index 360d9e85..67dd87b3 100644 --- a/apps/backend/app/core/config.py +++ b/apps/backend/app/core/config.py @@ -4,7 +4,7 @@ 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 SUPPORTED_LOG_FORMATS = {"text", "json"} @@ -21,9 +21,14 @@ class Settings(BaseSettings): 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 clone_timeout_seconds: int = 120 max_upload_size_bytes: int = 100 * 1024 * 1024 + max_clone_size_bytes: int = 500 * 1024 * 1024 model_config = SettingsConfigDict( env_file=".env", @@ -91,13 +96,19 @@ 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("clone_timeout_seconds", "max_upload_size_bytes", "max_clone_size_bytes") @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 + @lru_cache def get_settings() -> Settings: diff --git a/apps/backend/app/github/client.py b/apps/backend/app/github/client.py index c0f574ee..a8ea6957 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,23 @@ 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 clone at %s: %s", repo_dir, exc) + return None + sha = result.stdout.strip() + return sha or 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() @@ -64,7 +85,29 @@ def clone_public_repository(self, url: str, destination: Path, branch: str | Non except (subprocess.CalledProcessError, OSError) as exc: shutil.rmtree(destination, ignore_errors=True) stderr = exc.stderr if isinstance(exc, subprocess.CalledProcessError) else str(exc) + # Keep raw git stderr (may contain local paths/URLs) server-side only. + logger.warning("git clone failed for %s: %s", url, stderr) 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 child in path.rglob("*"): + try: + if child.is_file() and not child.is_symlink(): + total += child.stat().st_size + except OSError: + continue + return total diff --git a/apps/backend/app/schemas/repository.py b/apps/backend/app/schemas/repository.py index 448c4c41..e832a634 100644 --- a/apps/backend/app/schemas/repository.py +++ b/apps/backend/app/schemas/repository.py @@ -62,6 +62,7 @@ class RepositoryResponse(CamelModel): uploaded_at: datetime analysed_at: datetime | None = None error_message: str | None = None + commit_sha: str | None = None meta: RepositoryMeta | None = None file_tree: list[FileTreeNode] = Field(default_factory=list) diff --git a/apps/backend/app/services/repository_service.py b/apps/backend/app/services/repository_service.py index f6257f76..f360b151 100644 --- a/apps/backend/app/services/repository_service.py +++ b/apps/backend/app/services/repository_service.py @@ -1,4 +1,5 @@ import base64 +import hashlib from datetime import UTC, datetime from pathlib import Path from typing import NoReturn @@ -79,6 +80,7 @@ def import_github_repository(self, request: GitHubImportRequest) -> RepositoryRe try: self.github.clone_public_repository(url, destination, branch) root = self._resolve_repository_root(destination) + commit_sha = self.github.read_head_commit(destination) tree, meta, total_size = self.parser.parse(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) @@ -103,7 +105,7 @@ def import_github_repository(self, request: GitHubImportRequest) -> RepositoryRe analysis_progress=70, uploaded_at=now, analysed_at=None, - repo_metadata=self._metadata_with_intelligence(meta, repository_intelligence), + repo_metadata=self._metadata_with_intelligence(meta, repository_intelligence, commit_sha), 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)) @@ -120,6 +122,7 @@ async def import_uploaded_repository(self, file: UploadFile) -> RepositoryRespon archive_path = await self.storage.save_upload(repository_id, file, self.settings.max_upload_size_bytes) try: + content_hash = self._content_hash_for_upload(archive_path) root = self.storage.extract_archive(archive_path, repository_id) tree, meta, total_size = self.parser.parse(root) self._validate_parsed_repository(meta.total_files) @@ -147,7 +150,7 @@ async def import_uploaded_repository(self, file: UploadFile) -> RepositoryRespon analysis_progress=70, uploaded_at=now, analysed_at=None, - repo_metadata=self._metadata_with_intelligence(meta, repository_intelligence), + repo_metadata=self._metadata_with_intelligence(meta, repository_intelligence, content_hash), 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)) @@ -235,6 +238,7 @@ def to_response(self, record: RepositoryRecord) -> RepositoryResponse: uploaded_at=record.uploaded_at, analysed_at=record.analysed_at, error_message=record.error_message, + commit_sha=(record.repo_metadata or {}).get("commitSha"), meta=record.repo_metadata, file_tree=record.file_tree, ) @@ -261,7 +265,18 @@ def _repository_name_from_archive(self, filename: str) -> str: return filename[: -len(suffix)] return Path(filename).stem - def _metadata_with_intelligence(self, meta, intelligence) -> dict: + def _metadata_with_intelligence(self, meta, intelligence, commit_sha: str | None = None) -> dict: metadata = meta.model_dump(mode="json", by_alias=True) metadata["intelligence"] = intelligence.model_dump(mode="json", by_alias=True) + # Commit-addressability seed: the git HEAD SHA for GitHub imports, or a + # stable content hash (sha256:...) for uploads that have no git history. + # Stored in metadata for now; promotion to a first-class column is M2 work. + metadata["commitSha"] = commit_sha return metadata + + 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/tests/test_ingestion_pipeline.py b/apps/backend/tests/test_ingestion_pipeline.py index 7a203c6b..bca15214 100644 --- a/apps/backend/tests/test_ingestion_pipeline.py +++ b/apps/backend/tests/test_ingestion_pipeline.py @@ -55,6 +55,9 @@ 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" + # Uploads have no git history, so a stable content hash stands in as the + # commit-addressability identifier (T9 / F2). + assert repository["commitSha"].startswith("sha256:") start_response = client.post(f"/analysis/{repository['id']}/start") assert start_response.status_code == 200 @@ -162,3 +165,26 @@ def fake_run(*args, **kwargs): client = GitHubClient(Settings(clone_timeout_seconds=1)) with pytest.raises(TimeoutServiceError): client.clone_public_repository("https://github.com/example/demo", tmp_path / "demo") + + +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) + + client = GitHubClient(Settings(max_clone_size_bytes=1024)) + with pytest.raises(ValidationServiceError): + 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/docker-compose.yml b/docker-compose.yml index b7dca0aa..4598f566 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,7 +13,7 @@ services: 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} + AUTO_CREATE_TABLES: ${AUTO_CREATE_TABLES:-false} volumes: - partha_storage:/data/partha depends_on: diff --git a/docs/operations/production-deployment.md b/docs/operations/production-deployment.md index 9708111e..808b119c 100644 --- a/docs/operations/production-deployment.md +++ b/docs/operations/production-deployment.md @@ -29,6 +29,7 @@ PARTHA does not yet include authentication, authorization, tenant isolation, or | `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. | +| `MAX_CLONE_SIZE_BYTES` | Maximum on-disk size of a cloned GitHub repository (default 500 MiB). Over-limit clones are aborted and cleaned up. | 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. From 721d0c30a1e10248814f433dac6dca04ae46f86b Mon Sep 17 00:00:00 2001 From: Parth Rohit Date: Fri, 10 Jul 2026 23:22:54 +0100 Subject: [PATCH 017/347] fix(ai): remove synthetic repository citations (#48) --- apps/backend/app/ai/orchestrator.py | 2 +- apps/backend/app/ai/prompt_builder.py | 8 ++++++-- apps/backend/app/ai/repository_context.py | 17 ++++++----------- apps/backend/tests/test_ai_api_contract.py | 12 +++--------- apps/backend/tests/test_ai_architecture.py | 7 +++++-- 5 files changed, 21 insertions(+), 25 deletions(-) diff --git a/apps/backend/app/ai/orchestrator.py b/apps/backend/app/ai/orchestrator.py index 46181596..bc979d9e 100644 --- a/apps/backend/app/ai/orchestrator.py +++ b/apps/backend/app/ai/orchestrator.py @@ -114,7 +114,7 @@ async def query(self, request: AiQueryRequest) -> AiQueryResponse: role="assistant", content=provider_response.content, timestamp=datetime.now(UTC), - citations=[citation.to_schema() for citation in repository_context.citations], + citations=[citation.to_schema() for citation in repository_context.citations] or None, ), suggestions=[ "Explain the main architecture boundaries.", diff --git a/apps/backend/app/ai/prompt_builder.py b/apps/backend/app/ai/prompt_builder.py index a6c2af0f..04ede750 100644 --- a/apps/backend/app/ai/prompt_builder.py +++ b/apps/backend/app/ai/prompt_builder.py @@ -8,8 +8,12 @@ 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). 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, diff --git a/apps/backend/app/ai/repository_context.py b/apps/backend/app/ai/repository_context.py index 77a49591..2893d6fc 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, @@ -34,15 +33,11 @@ def build(self, record: RepositoryRecord, selected_file: str | None = None) -> R DependencyContext(name=dependency.name, version=dependency.version) for dependency in repository_intelligence.dependencies[:20] ) - citations = tuple( - Citation( - file=path, - start_line=1, - end_line=1, - content="Repository file path included in analysis context.", - ) - for path in highlighted[:5] - ) + # No citations are emitted today: the context is built from repository + # structure/metadata only, not from source lines, so there is nothing to + # ground a real file:line citation against. Fabricating 1:1 placeholder + # citations would misrepresent the answer as evidence-backed. Real + # citations will come from the persisted knowledge graph (M2). return RepositoryContext( repository=RepositoryIdentity(id=record.id, name=record.name), architecture=ArchitectureContext( @@ -55,5 +50,5 @@ def build(self, record: RepositoryRecord, selected_file: str | None = None) -> R 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/tests/test_ai_api_contract.py b/apps/backend/tests/test_ai_api_contract.py index f7a2be70..3d44fa44 100644 --- a/apps/backend/tests/test_ai_api_contract.py +++ b/apps/backend/tests/test_ai_api_contract.py @@ -76,14 +76,8 @@ def test_ai_query_endpoint_preserves_public_response_contract(client): 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) diff --git a/apps/backend/tests/test_ai_architecture.py b/apps/backend/tests/test_ai_architecture.py index 228e0a8a..dc7c008e 100644 --- a/apps/backend/tests/test_ai_architecture.py +++ b/apps/backend/tests/test_ai_architecture.py @@ -78,7 +78,9 @@ def test_repository_context_builder_uses_repository_intelligence(tmp_path: Path) 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): @@ -111,7 +113,8 @@ def test_ai_orchestrator_preserves_query_response_shape(tmp_path: Path): assert response.message.role == "assistant" assert response.message.content == "answer from openai" - assert response.message.citations + # No fabricated citations are returned (F4/F5); real ones await the graph (M2). + assert response.message.citations is None assert response.suggestions == [ "Explain the main architecture boundaries.", "What files should I read first?", From c949e5d1a9b8b324cbc393a09a38d6b2c112e700 Mon Sep 17 00:00:00 2001 From: Parth Rohit Date: Fri, 10 Jul 2026 22:55:27 +0100 Subject: [PATCH 018/347] fix(storage): prevent path traversal in uploaded filenames (#47) --- apps/backend/app/storage/local.py | 21 +++++- apps/backend/tests/test_upload_path_safety.py | 64 +++++++++++++++++++ 2 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 apps/backend/tests/test_upload_path_safety.py diff --git a/apps/backend/app/storage/local.py b/apps/backend/app/storage/local.py index fb6ed7e9..ebf94a47 100644 --- a/apps/backend/app/storage/local.py +++ b/apps/backend/app/storage/local.py @@ -41,7 +41,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,6 +54,23 @@ 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: 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..819e1f94 --- /dev/null +++ b/apps/backend/tests/test_upload_path_safety.py @@ -0,0 +1,64 @@ +import asyncio +import io +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" + + 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" From 2c5515ba53e5bec2f396b91471088b438ae95755 Mon Sep 17 00:00:00 2001 From: Parth Rohit Date: Fri, 10 Jul 2026 23:33:21 +0100 Subject: [PATCH 019/347] docs: clarify parser implementation status (#51) --- README.md | 4 ++-- apps/backend/app/parsers/tree_sitter_parser.py | 10 +++++++++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index b3a704ce..c14f686b 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,7 @@ PARTHA is organised around engineering capabilities rather than individual scree | 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 | +| AI Workspace | Lets configured AI providers answer repository-aware questions from structured repository context (languages, frameworks, modules, dependencies, and file paths). Providers do not parse repositories directly. Answers are grounded in repository structure/metadata only — source-line citations are not produced yet and arrive with the persisted knowledge graph. | Partial | | 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 | @@ -351,7 +351,7 @@ PARTHA uses pragmatic, inspectable tools rather than opaque infrastructure. | 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. | +| Tree-sitter (planned) | Symbol extraction today is regex-based; tree-sitter is a planned foundation for deeper, line-accurate language-aware extraction and is not yet wired for parsing. | | 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. | diff --git a/apps/backend/app/parsers/tree_sitter_parser.py b/apps/backend/app/parsers/tree_sitter_parser.py index 29120aa3..9fbde65d 100644 --- a/apps/backend/app/parsers/tree_sitter_parser.py +++ b/apps/backend/app/parsers/tree_sitter_parser.py @@ -8,9 +8,17 @@ class SyntaxParseResult: class TreeSitterParser: - """Thin integration point for future language-specific tree-sitter grammars.""" + """PLACEHOLDER — tree-sitter extraction is NOT implemented yet. + + This class only maps a file extension to a language name. It never parses + source or produces symbols: ``parse_symbols`` always returns an empty + ``symbols`` list. Real tree-sitter TS/Python extraction (with line spans) is + planned for the graph work (M2). Symbol extraction today is regex-based in + ``app.intelligence.engine``. Do not treat this as functional syntax parsing. + """ def parse_symbols(self, content: bytes, extension: str | None) -> SyntaxParseResult: + # Always returns symbols=[]: this is a not-yet-implemented placeholder. if not extension or not content: return SyntaxParseResult(language=None, symbols=[]) return SyntaxParseResult(language=self._language_from_extension(extension), symbols=[]) From 23b9dad9e4d3b60e81e009bbd70e1c465c8ca8e4 Mon Sep 17 00:00:00 2001 From: Shaurya K Sharma Date: Sat, 11 Jul 2026 15:12:19 +0100 Subject: [PATCH 020/347] feat(security): add security headers middleware and lock down CORS (E2.2) Establish the security-headers baseline and remove the wildcard CORS config before exposing PARTHA to real traffic. - SecurityHeadersMiddleware sets X-Content-Type-Options, X-Frame-Options, Referrer-Policy, and Strict-Transport-Security on every response, plus a strict default-src 'none' Content-Security-Policy on all routes except the Swagger/ReDoc docs, which render HTML and would otherwise be blanked. - CORS: replace allow_methods=["*"] / allow_headers=["*"] (a wildcard is invalid alongside allow_credentials=True) with explicit method and header allowlists covering what the frontend actually sends (Authorization, Content-Type). Origins still come from config; X-Request-ID is exposed so the client can read it. Tests assert the headers are present, that CSP is skipped on /docs, and that CORS allows the configured origin while withholding the allow-origin header from an unlisted one. --- apps/backend/app/core/security_headers.py | 39 +++++++++++++++ apps/backend/app/main.py | 9 +++- apps/backend/tests/test_security_headers.py | 53 +++++++++++++++++++++ 3 files changed, 99 insertions(+), 2 deletions(-) create mode 100644 apps/backend/app/core/security_headers.py create mode 100644 apps/backend/tests/test_security_headers.py diff --git a/apps/backend/app/core/security_headers.py b/apps/backend/app/core/security_headers.py new file mode 100644 index 00000000..2295c87e --- /dev/null +++ b/apps/backend/app/core/security_headers.py @@ -0,0 +1,39 @@ +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/main.py b/apps/backend/app/main.py index a7856d0f..18b87b3e 100644 --- a/apps/backend/app/main.py +++ b/apps/backend/app/main.py @@ -16,6 +16,7 @@ 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.core.security_headers import SecurityHeadersMiddleware from app.models import RepositoryRecord # noqa: F401 - imported so metadata includes model from app.models.base import Base @@ -63,12 +64,16 @@ def create_app() -> FastAPI: lifespan=lifespan, ) + 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) 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 From 1fe9fd74a0ecb3bb69a4426df7b928e2c1773249 Mon Sep 17 00:00:00 2001 From: Shaurya K Sharma Date: Sat, 11 Jul 2026 15:35:03 +0100 Subject: [PATCH 021/347] chore(backend): add pinned pip lockfile for reproducible installs (#59) The backend declared only lower-bound version ranges in pyproject.toml and had no lockfile, so installs were non-reproducible and Dependabot's pip ecosystem never opened a PR (any newer release already satisfied the >= constraints). - Add apps/backend/requirements.txt: the full dependency closure pinned to the versions resolved from pyproject. Generated from a clean install, so the networkx/GitPython deps removed earlier are not silently reintroduced. - Install from the lockfile in CI and in the Docker image, then the app itself with --no-deps, so CI, the container, and Dependabot all agree on versions. - pyproject.toml keeps the abstract >= ranges; the lockfile pins the concrete set. Dependabot already scans apps/backend for pip and will now have a pinned file to open update PRs against. Verified: the full backend suite passes in a fresh venv installed purely from the lockfile (87 passed, 1 skipped). --- .github/workflows/ci.yml | 4 +- apps/backend/Dockerfile | 6 ++- apps/backend/requirements.txt | 71 +++++++++++++++++++++++++++++++++++ 3 files changed, 79 insertions(+), 2 deletions(-) create mode 100644 apps/backend/requirements.txt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 49fafead..1cb28bb0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -83,7 +83,9 @@ jobs: 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.txt + python -m pip install -e apps/backend --no-deps - name: Test working-directory: apps/backend diff --git a/apps/backend/Dockerfile b/apps/backend/Dockerfile index 77ec69f4..35229f5d 100644 --- a/apps/backend/Dockerfile +++ b/apps/backend/Dockerfile @@ -10,12 +10,16 @@ RUN apt-get update \ && rm -rf /var/lib/apt/lists/* COPY pyproject.toml ./ +COPY requirements.txt ./ COPY app ./app COPY alembic.ini ./ COPY alembic ./alembic +# Install the pinned lockfile first, then the app itself without re-resolving +# dependencies, so the image matches requirements.txt exactly. RUN pip install --no-cache-dir --upgrade pip \ - && pip install --no-cache-dir -e . + && pip install --no-cache-dir -r requirements.txt \ + && pip install --no-cache-dir -e . --no-deps EXPOSE 8000 diff --git a/apps/backend/requirements.txt b/apps/backend/requirements.txt new file mode 100644 index 00000000..3abc4ca8 --- /dev/null +++ b/apps/backend/requirements.txt @@ -0,0 +1,71 @@ +# 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, Docker, and Dependabot all 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.18.5 +annotated-doc==0.0.4 +annotated-types==0.7.0 +anyio==4.14.1 +arabic-reshaper==3.0.1 +asn1crypto==1.5.1 +certifi==2026.6.17 +cffi==2.1.0 +charset-normalizer==3.4.9 +click==8.4.2 +colorama==0.4.6 +cryptography==49.0.0 +cssselect2==0.9.0 +fastapi==0.139.0 +greenlet==3.5.3 +h11==0.16.0 +html5lib==1.1 +httpcore==1.0.9 +httptools==0.8.0 +httpx==0.28.1 +idna==3.18 +iniconfig==2.3.0 +lxml==6.1.1 +Mako==1.3.12 +MarkupSafe==3.0.3 +oscrypto==1.3.0 +packaging==26.2 +pillow==12.3.0 +pluggy==1.6.0 +psycopg-binary==3.3.4 +psycopg==3.3.4 +pycparser==3.0 +pydantic-settings==2.14.2 +pydantic==2.13.4 +pydantic_core==2.46.4 +Pygments==2.20.0 +pyhanko-certvalidator==0.31.1 +pyHanko==0.35.2 +pypdf==6.14.2 +pytest==9.1.1 +python-bidi==0.6.11 +python-dotenv==1.2.2 +python-multipart==0.0.32 +PyYAML==6.0.3 +redis==8.0.1 +reportlab==4.5.1 +requests==2.34.2 +six==1.17.0 +SQLAlchemy==2.0.51 +starlette==1.3.1 +svglib==2.0.2 +tinycss2==1.5.1 +tree-sitter==0.26.0 +typing-inspection==0.4.2 +typing_extensions==4.16.0 +tzdata==2026.3 +tzlocal==5.4.4 +uritools==6.1.2 +urllib3==2.7.0 +uvicorn==0.51.0 +watchfiles==1.2.0 +webencodings==0.5.1 +websockets==16.1 +xhtml2pdf==0.2.17 From 2d900cd47dd323cb448d208a3eb8f178c3442114 Mon Sep 17 00:00:00 2001 From: Shaurya K Sharma Date: Sat, 11 Jul 2026 20:33:02 +0100 Subject: [PATCH 022/347] test(frontend): stand up Vitest + React Testing Library harness (E3.2) The frontend had no test runner at all. This adds the harness the E1.4 auth flow needs for its guard tests, wired into CI so it gates merges from day one. - Vitest + jsdom + RTL + jest-dom, reusing the existing Vite config (and the @ alias) via mergeConfig. Explicit vitest imports, no test globals, so tsconfig is untouched and tsc -b stays green. - npm run test = vitest run --coverage; test:watch for development. - Coverage floor is a ratchet baseline pinned just under what the seed tests honestly measure today (1.69% statements over the whole src tree), not an aspirational number. Raise it as coverage grows; never lower it. - Seed tests prove each layer of the harness: pure utils (cn tailwind-conflict resolution, formatFileSize), API error semantics the UI branches on (ApiError status classification + type guards), and a rendered component with an interaction (EmptyState via RTL + fireEvent). - CI Frontend job runs the tests between Lint and Build. 12 tests across 3 files; lint and build verified green alongside. --- .github/workflows/ci.yml | 3 ++ apps/frontend/package.json | 11 +++- .../shared/components/ui/EmptyState.test.tsx | 34 ++++++++++++ .../src/shared/services/api/errors.test.ts | 53 +++++++++++++++++++ apps/frontend/src/shared/utils/cn.test.ts | 29 ++++++++++ apps/frontend/src/test/setup.ts | 1 + apps/frontend/vitest.config.ts | 29 ++++++++++ 7 files changed, 158 insertions(+), 2 deletions(-) create mode 100644 apps/frontend/src/shared/components/ui/EmptyState.test.tsx create mode 100644 apps/frontend/src/shared/services/api/errors.test.ts create mode 100644 apps/frontend/src/shared/utils/cn.test.ts create mode 100644 apps/frontend/src/test/setup.ts create mode 100644 apps/frontend/vitest.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1cb28bb0..8220b96c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,6 +61,9 @@ jobs: - name: Lint run: npm --prefix apps/frontend run lint + - name: Test + run: npm --prefix apps/frontend run test + - name: Build run: npm --prefix apps/frontend run build diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 6021d4e2..490c8113 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -7,7 +7,9 @@ "dev": "vite", "build": "tsc -b && vite build", "preview": "vite preview", - "lint": "eslint ." + "lint": "eslint .", + "test": "vitest run --coverage", + "test:watch": "vitest" }, "dependencies": { "@dagrejs/dagre": "^3.0.0", @@ -28,19 +30,24 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", "@types/react": "^18.3.5", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^6.0.3", + "@vitest/coverage-v8": "^4.1.10", "autoprefixer": "^10.4.20", "eslint": "^10.6.0", "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.3", "globals": "^17.7.0", + "jsdom": "^29.1.1", "postcss": "^8.4.45", "tailwindcss": "^3.4.10", "typescript": "^5.5.4", "typescript-eslint": "^8.63.0", - "vite": "^8.1.3" + "vite": "^8.1.3", + "vitest": "^4.1.10" }, "overrides": { "dompurify": "3.4.11" diff --git a/apps/frontend/src/shared/components/ui/EmptyState.test.tsx b/apps/frontend/src/shared/components/ui/EmptyState.test.tsx new file mode 100644 index 00000000..9055b28e --- /dev/null +++ b/apps/frontend/src/shared/components/ui/EmptyState.test.tsx @@ -0,0 +1,34 @@ +import { describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { Inbox } from 'lucide-react'; +import { EmptyState } from './EmptyState'; + +describe('EmptyState', () => { + it('renders the title and description', () => { + render(); + + expect(screen.getByText('No repositories')).toBeInTheDocument(); + expect(screen.getByText('Import one to get started.')).toBeInTheDocument(); + }); + + it('renders no button when no action is given', () => { + render(); + + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); + + it('fires the action callback from the button', () => { + const onClick = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: 'Import repository' })); + expect(onClick).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/frontend/src/shared/services/api/errors.test.ts b/apps/frontend/src/shared/services/api/errors.test.ts new file mode 100644 index 00000000..bc09a2da --- /dev/null +++ b/apps/frontend/src/shared/services/api/errors.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; +import { + ApiError, + CancelledError, + NetworkError, + TimeoutError, + isApiError, + isNetworkError, + isTimeoutError, +} from './errors'; + +describe('ApiError', () => { + it('carries status, endpoint, and a readable message', () => { + const error = new ApiError(404, 'Not Found', { code: 'not_found' }, '/repositories/x'); + + expect(error.name).toBe('ApiError'); + expect(error.status).toBe(404); + expect(error.message).toContain('404'); + expect(error.message).toContain('/repositories/x'); + }); + + it('classifies status families the UI branches on', () => { + expect(new ApiError(401, '', null, '/x').isUnauthorized).toBe(true); + expect(new ApiError(404, '', null, '/x').isNotFound).toBe(true); + expect(new ApiError(422, '', null, '/x').isValidation).toBe(true); + expect(new ApiError(429, '', null, '/x').isRateLimited).toBe(true); + expect(new ApiError(503, '', null, '/x').isUnavailable).toBe(true); + expect(new ApiError(500, '', null, '/x').isServerError).toBe(true); + expect(new ApiError(200, '', null, '/x').isServerError).toBe(false); + }); +}); + +describe('type guards', () => { + it('narrow each error class and reject the others', () => { + const api = new ApiError(500, 'boom', null, '/x'); + const network = new NetworkError('/x'); + const timeout = new TimeoutError('/x', 30_000); + const cancelled = new CancelledError('/x'); + + expect(isApiError(api)).toBe(true); + expect(isApiError(network)).toBe(false); + expect(isNetworkError(network)).toBe(true); + expect(isNetworkError(timeout)).toBe(false); + expect(isTimeoutError(timeout)).toBe(true); + expect(isTimeoutError(cancelled)).toBe(false); + }); + + it('reject plain errors and non-errors', () => { + expect(isApiError(new Error('plain'))).toBe(false); + expect(isApiError(undefined)).toBe(false); + expect(isNetworkError('nope')).toBe(false); + }); +}); diff --git a/apps/frontend/src/shared/utils/cn.test.ts b/apps/frontend/src/shared/utils/cn.test.ts new file mode 100644 index 00000000..1ed6b050 --- /dev/null +++ b/apps/frontend/src/shared/utils/cn.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; +import { cn, formatFileSize } from './cn'; + +describe('cn', () => { + it('joins conditional classes and drops falsy values', () => { + const hidden = [].length > 0; + expect(cn('base', hidden && 'hidden', 'extra')).toBe('base extra'); + }); + + it('resolves tailwind conflicts in favour of the last class', () => { + expect(cn('p-2', 'p-4')).toBe('p-4'); + expect(cn('text-sm', 'text-lg')).toBe('text-lg'); + }); +}); + +describe('formatFileSize', () => { + it('formats zero', () => { + expect(formatFileSize(0)).toBe('0 B'); + }); + + it('formats whole units', () => { + expect(formatFileSize(1024)).toBe('1 KB'); + expect(formatFileSize(1024 * 1024)).toBe('1 MB'); + }); + + it('rounds to one decimal', () => { + expect(formatFileSize(1536)).toBe('1.5 KB'); + }); +}); diff --git a/apps/frontend/src/test/setup.ts b/apps/frontend/src/test/setup.ts new file mode 100644 index 00000000..bb02c60c --- /dev/null +++ b/apps/frontend/src/test/setup.ts @@ -0,0 +1 @@ +import '@testing-library/jest-dom/vitest'; diff --git a/apps/frontend/vitest.config.ts b/apps/frontend/vitest.config.ts new file mode 100644 index 00000000..9d8ecf14 --- /dev/null +++ b/apps/frontend/vitest.config.ts @@ -0,0 +1,29 @@ +import { defineConfig, mergeConfig } from 'vitest/config'; +import viteConfig from './vite.config'; + +export default mergeConfig( + viteConfig, + defineConfig({ + test: { + environment: 'jsdom', + setupFiles: ['./src/test/setup.ts'], + include: ['src/**/*.test.{ts,tsx}'], + coverage: { + provider: 'v8', + all: true, + include: ['src/**'], + exclude: ['src/test/**', 'src/**/*.test.{ts,tsx}', 'src/vite-env.d.ts'], + // Ratchet baseline, not an aspiration: pinned just under what the + // seed tests measure today (1.69% stmts / 0.28% branches / 2.52% + // funcs / 1.87% lines over the whole src tree). Raise it as coverage + // grows; never lower it. + thresholds: { + statements: 1.5, + branches: 0.25, + functions: 2, + lines: 1.5, + }, + }, + }, + }), +); From 32bced78be3ac40fa933ee029e68f2926abd8cb7 Mon Sep 17 00:00:00 2001 From: Shaurya K Sharma Date: Sun, 12 Jul 2026 01:19:35 +0100 Subject: [PATCH 023/347] test(frontend): commit workspace lockfile and register RTL cleanup (E3.2) Follow-up to the harness commit, fixing two gaps found before review: - apps/frontend/package-lock.json now records the Vitest/RTL/jsdom dev dependencies. The root package-lock.json is gitignored and the repo is an npm workspace, so a plain `npm install` updates only the ignored root lock; the nested lockfile CI's `npm ci` reads must be regenerated with `npm install --workspaces=false`. Verified in sync via `npm ci --dry-run`. - Register an explicit afterEach(cleanup) in the test setup. Vitest runs without `globals`, so React Testing Library never auto-registers its cleanup and mounted components would otherwise leak between tests. --- apps/frontend/package-lock.json | 1426 +++++++++++++++++++++++++++++-- apps/frontend/src/test/setup.ts | 10 + 2 files changed, 1385 insertions(+), 51 deletions(-) diff --git a/apps/frontend/package-lock.json b/apps/frontend/package-lock.json index bfaac01c..8ed34620 100644 --- a/apps/frontend/package-lock.json +++ b/apps/frontend/package-lock.json @@ -26,21 +26,33 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", "@types/react": "^18.3.5", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^6.0.3", + "@vitest/coverage-v8": "^4.1.10", "autoprefixer": "^10.4.20", "eslint": "^10.6.0", "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.3", "globals": "^17.7.0", + "jsdom": "^29.1.1", "postcss": "^8.4.45", "tailwindcss": "^3.4.10", "typescript": "^5.5.4", "typescript-eslint": "^8.63.0", - "vite": "^8.1.3" + "vite": "^8.1.3", + "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 +65,57 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "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 +308,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,6 +366,169 @@ "node": ">=6.9.0" } }, + "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": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "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/@csstools/css-calc": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", + "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", + "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-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.9.tgz", + "integrity": "sha512-paQcIaOO53Rk5+YrBaBjm/SgrV4INImjo2BT1DtQRYr+XeTRbeAYlS+jxXp9drqvKmtFnWRJKIalDLhZZDu42A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.0", + "@csstools/css-calc": "^3.2.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "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.6", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.6.tgz", + "integrity": "sha512-TcJCWFbXLPpJYq6z7bfOyjWYJDiDg2/I4gyUC9pqPNqHFRIey0EB0q0L5cSnQDfWJg8Jd6VadakxdIez/3zkqQ==", + "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.0.0", "resolved": "https://registry.npmjs.org/@dagrejs/dagre/-/dagre-3.0.0.tgz", @@ -470,6 +706,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", @@ -942,6 +1196,89 @@ "dev": true, "license": "MIT" }, + "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", + "peer": true, + "dependencies": { + "@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/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "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": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "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.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "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/@tybys/wasm-util": { "version": "0.10.3", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", @@ -953,6 +1290,25 @@ "tslib": "^2.4.0" } }, + "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", @@ -1002,6 +1358,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", @@ -1328,65 +1691,209 @@ } } }, - "node_modules/@xyflow/react": { - "version": "12.11.2", - "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.11.2.tgz", - "integrity": "sha512-eLAlDWJfWnQEhJwGMjlWdAXO9eYllKpliUmPQlAmOLxz6mExXuzMVDUKLMquixgkrtmMFFtug3jGKmYYld12cA==", + "node_modules/@vitest/coverage-v8": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.10.tgz", + "integrity": "sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==", + "dev": true, "license": "MIT", "dependencies": { - "@xyflow/system": "0.0.79", - "classcat": "^5.0.3", - "zustand": "^4.4.0" + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.10", + "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": { - "@types/react": ">=17", - "@types/react-dom": ">=17", - "react": ">=17", - "react-dom": ">=17" + "@vitest/browser": "4.1.10", + "vitest": "4.1.10" }, "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { + "@vitest/browser": { "optional": true } } }, - "node_modules/@xyflow/system": { - "version": "0.0.79", - "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.79.tgz", - "integrity": "sha512-czLyOh91NF0hIzbNzwi8I6GlqG23BHh2435OddfI6uiaLH3xdrdygO93gqgH1Bv9mhy8XPFQJOBn1FTq4LvEWA==", - "license": "MIT", - "dependencies": { - "@types/d3-drag": "^3.0.7", - "@types/d3-interpolate": "^3.0.4", - "@types/d3-selection": "^3.0.10", - "@types/d3-transition": "^3.0.8", - "@types/d3-zoom": "^3.0.8", - "d3-drag": "^3.0.0", - "d3-interpolate": "^3.0.1", - "d3-selection": "^3.0.0", - "d3-zoom": "^3.0.0" - } - }, - "node_modules/acorn": { - "version": "8.17.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", - "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", "dev": true, "license": "MIT", - "bin": { - "acorn": "bin/acorn" + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" }, - "engines": { - "node": ">=0.4.0" + "funding": { + "url": "https://opencollective.com/vitest" } }, - "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==", + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "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.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "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==", + "license": "MIT", + "dependencies": { + "@xyflow/system": "0.0.79", + "classcat": "^5.0.3", + "zustand": "^4.4.0" + }, + "peerDependencies": { + "@types/react": ">=17", + "@types/react-dom": ">=17", + "react": ">=17", + "react-dom": ">=17" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@xyflow/system": { + "version": "0.0.79", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.79.tgz", + "integrity": "sha512-czLyOh91NF0hIzbNzwi8I6GlqG23BHh2435OddfI6uiaLH3xdrdygO93gqgH1Bv9mhy8XPFQJOBn1FTq4LvEWA==", + "license": "MIT", + "dependencies": { + "@types/d3-drag": "^3.0.7", + "@types/d3-interpolate": "^3.0.4", + "@types/d3-selection": "^3.0.10", + "@types/d3-transition": "^3.0.8", + "@types/d3-zoom": "^3.0.8", + "d3-drag": "^3.0.0", + "d3-interpolate": "^3.0.1", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + } + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "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": { @@ -1410,6 +1917,31 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "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,6 +1967,45 @@ "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", "license": "MIT" }, + "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", @@ -1504,6 +2075,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", @@ -1605,6 +2186,16 @@ ], "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/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -1687,6 +2278,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,6 +2423,20 @@ "node": ">=12" } }, + "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", @@ -1829,6 +2455,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 +2469,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,6 +2501,14 @@ "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", @@ -1875,6 +2526,19 @@ "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 +2548,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", @@ -2082,6 +2753,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 +2773,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", @@ -2324,6 +3015,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,6 +3054,26 @@ "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", @@ -2379,6 +3100,16 @@ "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/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -2436,6 +3167,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 +3181,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", @@ -2458,6 +3235,57 @@ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "license": "MIT" }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "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.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "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/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -2846,13 +3674,75 @@ "yallist": "^3.0.2" } }, - "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==", + "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==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" + } + }, + "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", - "peerDependencies": { - "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" } }, "node_modules/marked": { @@ -2868,6 +3758,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 +3787,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", @@ -3012,6 +3919,20 @@ "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/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -3062,6 +3983,19 @@ "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", @@ -3088,6 +4022,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", @@ -3290,6 +4231,30 @@ "node": ">= 0.8.0" } }, + "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": { + "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/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -3432,6 +4397,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", @@ -3520,6 +4509,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", @@ -3562,6 +4564,13 @@ "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", @@ -3581,12 +4590,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 +4645,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 +4670,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 +4754,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 +4816,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 +4858,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", @@ -3831,6 +4960,16 @@ "typescript": ">=4.8.4 <6.1.0" } }, + "node_modules/undici": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "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", @@ -3978,6 +5117,157 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "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.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "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 +5284,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 +5311,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", diff --git a/apps/frontend/src/test/setup.ts b/apps/frontend/src/test/setup.ts index bb02c60c..706a562d 100644 --- a/apps/frontend/src/test/setup.ts +++ b/apps/frontend/src/test/setup.ts @@ -1 +1,11 @@ +import { afterEach } from 'vitest'; +import { cleanup } from '@testing-library/react'; import '@testing-library/jest-dom/vitest'; + +// This project runs Vitest without `globals`, so React Testing Library cannot +// auto-register its afterEach cleanup (it only does so when `afterEach` is a +// global). Register it explicitly, or mounted components leak into the next +// test's document. +afterEach(() => { + cleanup(); +}); From 4b1f77a6b75fb3c1a583675701e56bb0dddc6bf6 Mon Sep 17 00:00:00 2001 From: Shaurya K Sharma Date: Sat, 11 Jul 2026 14:24:53 +0100 Subject: [PATCH 024/347] feat(auth): add User model and per-user repository ownership (E1.1) Introduce identity persistence ahead of authentication. Adds a User table and scopes repositories to an owner so a request can only ever see its own data. This is the foundation E1.2+ build on, and it is independent of the native-JWT-vs-Auth0 decision still open on the E1 epic. - User model: UUID id, unique email, is_active, timestamps. No credential column yet; that belongs to E1.2. - owner_id FK on repositories, indexed. Alembic 0002 creates the users table, seeds a system user, and backfills existing repositories to it so the upgrade is non-destructive. upgrade/downgrade/upgrade all verified. - Owner-scoped data access: RepositoryService filters every read by the current user and returns 404 (not 403) for another user's repository, so existence never leaks. Import de-duplication is now per-owner. - Temporary current-user seam (get_current_user) attributes requests to the seed user, with an X-Dev-User override honoured only in development/test so multi-tenant behaviour is testable before sign-in exists. E1.2 replaces the body of this dependency with token verification; its callers do not change. The unscoped repository queries remain for internal callers (analysis, ai, documentation) and move onto the current user in E1.3. Tests: cross-user read/list/delete denial and a migration round-trip. Full backend suite green (92 passed, 1 skipped). --- apps/backend/alembic/env.py | 1 + .../0002_add_users_and_repository_owner.py | 72 ++++++++++++++++ apps/backend/app/api/deps.py | 49 ++++++++++- apps/backend/app/main.py | 2 +- apps/backend/app/models/__init__.py | 3 +- apps/backend/app/models/repository.py | 3 +- apps/backend/app/models/user.py | 28 +++++++ .../app/repositories/repository_repository.py | 33 ++++++++ .../app/services/repository_service.py | 15 +++- apps/backend/tests/test_migrations.py | 31 +++++++ .../tests/test_repository_ownership.py | 82 +++++++++++++++++++ 11 files changed, 311 insertions(+), 8 deletions(-) create mode 100644 apps/backend/alembic/versions/0002_add_users_and_repository_owner.py create mode 100644 apps/backend/app/models/user.py create mode 100644 apps/backend/tests/test_migrations.py create mode 100644 apps/backend/tests/test_repository_ownership.py diff --git a/apps/backend/alembic/env.py b/apps/backend/alembic/env.py index c4c78841..4db6e231 100644 --- a/apps/backend/alembic/env.py +++ b/apps/backend/alembic/env.py @@ -7,6 +7,7 @@ from app.core.config import get_settings from app.models.base import Base from app.models.repository import RepositoryRecord +from app.models.user import User config = context.config settings = get_settings() diff --git a/apps/backend/alembic/versions/0002_add_users_and_repository_owner.py b/apps/backend/alembic/versions/0002_add_users_and_repository_owner.py new file mode 100644 index 00000000..8fe5adf9 --- /dev/null +++ b/apps/backend/alembic/versions/0002_add_users_and_repository_owner.py @@ -0,0 +1,72 @@ +"""add users table and repository owner + +Revision ID: 0002_add_users_and_repository_owner +Revises: 0001_initial +Create Date: 2026-07-11 +""" + +from datetime import UTC, datetime + +from alembic import op +import sqlalchemy as sa + +revision = "0002_add_users_and_repository_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/app/api/deps.py b/apps/backend/app/api/deps.py index 20353e7a..8a4ed904 100644 --- a/apps/backend/app/api/deps.py +++ b/apps/backend/app/api/deps.py @@ -1,4 +1,7 @@ -from fastapi import Depends +from uuid import uuid4 + +from fastapi import Depends, Header +from sqlalchemy import select from sqlalchemy.orm import Session from app.ai.orchestrator import AiOrchestrator, AiProviderConfigStore @@ -17,6 +20,7 @@ from app.github.client import GitHubClient from app.graph.dependency_graph import DependencyGraphBuilder from app.intelligence.engine import RepositoryIntelligenceEngine +from app.models.user import SEED_USER_EMAIL, SEED_USER_ID, User from app.parsers.repository_parser import RepositoryParser from app.repositories.repository_repository import RepositoryRepository from app.reports.export_service import ExportService @@ -32,6 +36,47 @@ def get_repository_repository(db: Session = Depends(get_db)) -> RepositoryReposi return RepositoryRepository(db) +def _ensure_seed_user(db: Session) -> User: + user = db.get(User, SEED_USER_ID) + if user is None: + user = User(id=SEED_USER_ID, email=SEED_USER_EMAIL) + db.add(user) + db.commit() + db.refresh(user) + return user + + +def _resolve_dev_user(db: Session, email: str) -> User: + normalized = email.strip().lower() + user = db.scalars(select(User).where(User.email == normalized)).first() + if user is None: + user = User(id=str(uuid4()), email=normalized) + db.add(user) + db.commit() + db.refresh(user) + return user + + +def get_current_user( + db: Session = Depends(get_db), + settings: Settings = Depends(get_settings), + x_dev_user: str | None = Header(default=None, alias="X-Dev-User"), +) -> User: + """Resolve the user that owns the data for this request. + + Temporary E1.1 seam: there is no authentication yet, so requests are + attributed to a single seed user. In development/test an ``X-Dev-User`` + header (an email) selects or provisions a distinct user, so multi-tenant + behaviour can be exercised before E1.2 lands real sign-in. The header is + ignored outside development/test, so it cannot be used to spoof identity in + a real deployment. E1.2 replaces the body of this function with token + verification; callers depending on it do not change. + """ + if x_dev_user and settings.app_env in {"development", "test"}: + return _resolve_dev_user(db, x_dev_user) + return _ensure_seed_user(db) + + def get_local_storage(settings: Settings = Depends(get_settings)) -> LocalStorage: return LocalStorage(settings) @@ -55,6 +100,7 @@ def get_repository_service( 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, @@ -63,6 +109,7 @@ def get_repository_service( parser=parser, intelligence=intelligence, settings=settings, + owner_id=current_user.id, ) diff --git a/apps/backend/app/main.py b/apps/backend/app/main.py index 18b87b3e..5cfd523a 100644 --- a/apps/backend/app/main.py +++ b/apps/backend/app/main.py @@ -17,7 +17,7 @@ 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.core.security_headers import SecurityHeadersMiddleware -from app.models import RepositoryRecord # noqa: F401 - imported so metadata includes model +from app.models import RepositoryRecord, User # noqa: F401 - imported so metadata includes models from app.models.base import Base logger = logging.getLogger(__name__) diff --git a/apps/backend/app/models/__init__.py b/apps/backend/app/models/__init__.py index bb96463c..af1a2322 100644 --- a/apps/backend/app/models/__init__.py +++ b/apps/backend/app/models/__init__.py @@ -1,3 +1,4 @@ from app.models.repository import RepositoryRecord +from app.models.user import User -__all__ = ["RepositoryRecord"] +__all__ = ["RepositoryRecord", "User"] diff --git a/apps/backend/app/models/repository.py b/apps/backend/app/models/repository.py index 749d9568..8c96005d 100644 --- a/apps/backend/app/models/repository.py +++ b/apps/backend/app/models/repository.py @@ -1,6 +1,6 @@ from datetime import UTC, datetime -from sqlalchemy import BigInteger, DateTime, Integer, JSON, String, Text +from sqlalchemy import BigInteger, DateTime, ForeignKey, Integer, JSON, String, Text from sqlalchemy.orm import Mapped, mapped_column from app.models.base import Base @@ -10,6 +10,7 @@ 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"), 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) diff --git a/apps/backend/app/models/user.py b/apps/backend/app/models/user.py new file mode 100644 index 00000000..fa923d81 --- /dev/null +++ b/apps/backend/app/models/user.py @@ -0,0 +1,28 @@ +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 all data created before authentication existed. +# Requests without an authenticated identity fall back to this owner until E1.2 +# introduces real sign-in. The id is fixed so the 0002 migration backfill and the +# current-user seam agree on it; keep both in sync with these constants. +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) + 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/repositories/repository_repository.py b/apps/backend/app/repositories/repository_repository.py index e12d156e..b2af6298 100644 --- a/apps/backend/app/repositories/repository_repository.py +++ b/apps/backend/app/repositories/repository_repository.py @@ -8,6 +8,39 @@ class RepositoryRepository: def __init__(self, db: Session) -> None: self.db = db + # Owner-scoped access. These are the methods user-facing routes must use so a + # request can only ever see its own repositories. The unscoped methods below + # remain for internal callers (analysis/ai/documentation) until E1.3 moves + # them onto the current user, at which point the unscoped variants go away. + 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_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_for_owner(self, name: str, owner_id: str) -> RepositoryRecord | None: + statement = select(RepositoryRecord).where( + RepositoryRecord.name == name, + RepositoryRecord.owner_id == owner_id, + ) + return self.db.scalars(statement).first() + + def find_by_source_for_owner(self, source_url: str, branch: str | None, owner_id: str) -> RepositoryRecord | None: + statement = select(RepositoryRecord).where( + RepositoryRecord.source_url == source_url, + RepositoryRecord.branch == branch, + RepositoryRecord.owner_id == owner_id, + ) + return self.db.scalars(statement).first() + def list(self) -> list[RepositoryRecord]: statement = select(RepositoryRecord).order_by(RepositoryRecord.uploaded_at.desc()) return list(self.db.scalars(statement).all()) diff --git a/apps/backend/app/services/repository_service.py b/apps/backend/app/services/repository_service.py index f360b151..72225515 100644 --- a/apps/backend/app/services/repository_service.py +++ b/apps/backend/app/services/repository_service.py @@ -45,6 +45,7 @@ def __init__( parser: RepositoryParser, intelligence: RepositoryIntelligenceEngine, settings: Settings, + owner_id: str, ) -> None: self.repository = repository self.storage = storage @@ -52,9 +53,10 @@ def __init__( 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: @@ -69,7 +71,7 @@ def import_github_repository(self, request: GitHubImportRequest) -> RepositoryRe 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) + existing = self.repository.find_by_source_for_owner(url, branch, self.owner_id) if existing: raise ConflictServiceError( "Repository has already been imported.", @@ -91,6 +93,7 @@ def import_github_repository(self, request: GitHubImportRequest) -> RepositoryRe now = datetime.now(UTC) record = RepositoryRecord( id=repository_id, + owner_id=self.owner_id, name=self.github.repository_name(url), description=None, source="github", @@ -113,7 +116,7 @@ def import_github_repository(self, request: GitHubImportRequest) -> RepositoryRe 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) + existing = self.repository.find_by_name_for_owner(repository_name, self.owner_id) if existing: raise ConflictServiceError( "Repository has already been imported.", @@ -136,6 +139,7 @@ async def import_uploaded_repository(self, file: UploadFile) -> RepositoryRespon now = datetime.now(UTC) record = RepositoryRecord( id=repository_id, + owner_id=self.owner_id, name=repository_name, description=None, source="upload", @@ -244,7 +248,10 @@ def to_response(self, record: RepositoryRecord) -> RepositoryResponse: ) 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 diff --git a/apps/backend/tests/test_migrations.py b/apps/backend/tests/test_migrations.py new file mode 100644 index 00000000..e687be5e --- /dev/null +++ b/apps/backend/tests/test_migrations.py @@ -0,0 +1,31 @@ +from pathlib import Path + +from alembic import command +from alembic.config import Config + +BACKEND_ROOT = Path(__file__).resolve().parents[1] + + +def test_migrations_upgrade_and_downgrade_run_clean(tmp_path, monkeypatch): + """The full revision chain applies and reverses on a fresh database. + + Alembic's env.py reads the URL from settings, so point it at a throwaway + SQLite file. Running up -> down -> up proves both directions and that the + down does not leave state that blocks a re-apply. + """ + database_path = tmp_path / "migration-roundtrip.db" + monkeypatch.setenv("DATABASE_URL", f"sqlite:///{database_path}") + monkeypatch.setenv("CORS_ORIGINS", "http://testserver") + + from app.core import config + + config.get_settings.cache_clear() + try: + cfg = Config(str(BACKEND_ROOT / "alembic.ini")) + cfg.set_main_option("script_location", str(BACKEND_ROOT / "alembic")) + + command.upgrade(cfg, "head") + command.downgrade(cfg, "base") + command.upgrade(cfg, "head") + finally: + config.get_settings.cache_clear() diff --git a/apps/backend/tests/test_repository_ownership.py b/apps/backend/tests/test_repository_ownership.py new file mode 100644 index 00000000..59faf1db --- /dev/null +++ b/apps/backend/tests/test_repository_ownership.py @@ -0,0 +1,82 @@ +import uuid + +from sqlalchemy import select + +ALICE = "alice@example.com" +BOB = "bob@example.com" + + +def _seed_repository(owner_email: str, name: str = "sample-repo") -> str: + """Insert a repository owned by ``owner_email`` directly, bypassing the + import pipeline, and return its id. The current-user seam resolves the same + user by email, so requests carrying that X-Dev-User header own this row.""" + from app.core.database import SessionLocal + from app.models.repository import RepositoryRecord + from app.models.user import User + + db = SessionLocal() + try: + owner = db.scalars(select(User).where(User.email == owner_email)).first() + if owner is None: + owner = User(id=str(uuid.uuid4()), email=owner_email) + db.add(owner) + db.commit() + db.refresh(owner) + 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): + repository_id = _seed_repository(ALICE) + + denied = client.get(f"/repositories/{repository_id}", headers={"X-Dev-User": BOB}) + assert denied.status_code == 404 + + allowed = client.get(f"/repositories/{repository_id}", headers={"X-Dev-User": ALICE}) + assert allowed.status_code == 200 + assert allowed.json()["id"] == repository_id + + +def test_list_only_returns_the_current_users_repositories(client): + _seed_repository(ALICE) + + alice_view = client.get("/repositories", headers={"X-Dev-User": ALICE}) + assert alice_view.status_code == 200 + assert alice_view.json()["total"] == 1 + + bob_view = client.get("/repositories", headers={"X-Dev-User": BOB}) + assert bob_view.status_code == 200 + assert bob_view.json() == {"data": [], "total": 0} + + +def test_delete_returns_404_for_another_users_repository(client): + repository_id = _seed_repository(ALICE) + + denied = client.delete(f"/repositories/{repository_id}", headers={"X-Dev-User": BOB}) + 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={"X-Dev-User": ALICE}) + assert still_there.status_code == 200 + + +def test_default_seed_user_does_not_see_dev_users_repositories(client): + _seed_repository(ALICE) + + # No X-Dev-User header -> the seed user, which owns none of Alice's data. + seed_view = client.get("/repositories") + assert seed_view.status_code == 200 + assert seed_view.json() == {"data": [], "total": 0} From 0383c19f0a703f7c0d47f0e367eebb33c9328465 Mon Sep 17 00:00:00 2001 From: Shaurya K Sharma Date: Sat, 11 Jul 2026 14:30:06 +0100 Subject: [PATCH 025/347] fix(auth): shorten migration revision id to fit Postgres alembic_version(32) The 0002 revision id was 35 characters. Alembic's default alembic_version.version_num column is VARCHAR(32); SQLite ignores the length (so local tests passed) but PostgreSQL enforces it and the Docker Compose CI job failed writing the version row. Rename the revision (and its file) to 0002_users_and_repo_owner (25 chars). No schema or data change. --- ...nd_repository_owner.py => 0002_users_and_repo_owner.py} | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) rename apps/backend/alembic/versions/{0002_add_users_and_repository_owner.py => 0002_users_and_repo_owner.py} (92%) diff --git a/apps/backend/alembic/versions/0002_add_users_and_repository_owner.py b/apps/backend/alembic/versions/0002_users_and_repo_owner.py similarity index 92% rename from apps/backend/alembic/versions/0002_add_users_and_repository_owner.py rename to apps/backend/alembic/versions/0002_users_and_repo_owner.py index 8fe5adf9..078219d2 100644 --- a/apps/backend/alembic/versions/0002_add_users_and_repository_owner.py +++ b/apps/backend/alembic/versions/0002_users_and_repo_owner.py @@ -1,8 +1,11 @@ """add users table and repository owner -Revision ID: 0002_add_users_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 @@ -10,7 +13,7 @@ from alembic import op import sqlalchemy as sa -revision = "0002_add_users_and_repository_owner" +revision = "0002_users_and_repo_owner" down_revision = "0001_initial" branch_labels = None depends_on = None From 8192435155a877ff5533adccffd76841920ee6b4 Mon Sep 17 00:00:00 2001 From: Shaurya K Sharma Date: Sun, 12 Jul 2026 15:33:41 +0100 Subject: [PATCH 026/347] fix(auth): use side-effect module import for models metadata registration Addresses CodeQL bot findings on PR #70 (unused RepositoryRecord/User names in main.py and env.py) without changing runtime behavior. --- apps/backend/alembic/env.py | 3 +-- apps/backend/app/main.py | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/backend/alembic/env.py b/apps/backend/alembic/env.py index 4db6e231..ecaeb17c 100644 --- a/apps/backend/alembic/env.py +++ b/apps/backend/alembic/env.py @@ -6,8 +6,7 @@ from app.core.config import get_settings from app.models.base import Base -from app.models.repository import RepositoryRecord -from app.models.user import User +import app.models # noqa: F401 - imported so metadata includes all models config = context.config settings = get_settings() diff --git a/apps/backend/app/main.py b/apps/backend/app/main.py index 5cfd523a..46636e83 100644 --- a/apps/backend/app/main.py +++ b/apps/backend/app/main.py @@ -17,7 +17,7 @@ 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.core.security_headers import SecurityHeadersMiddleware -from app.models import RepositoryRecord, User # noqa: F401 - imported so metadata includes models +import app.models # noqa: F401 - imported so metadata includes models from app.models.base import Base logger = logging.getLogger(__name__) From cb120c015bcc079579f42d5fab04f892ec231cdf Mon Sep 17 00:00:00 2001 From: Shaurya K Sharma Date: Sun, 12 Jul 2026 15:43:51 +0100 Subject: [PATCH 027/347] fix(auth): drop redundant models import flagged as unused by code-quality bot 'from app.models.base import Base' already initializes the app.models package (Python runs a package's __init__.py before any of its submodules), which is what registers RepositoryRecord/User on Base.metadata. The extra 'import app.models' line was dead weight that a naive unused-import check keeps flagging regardless of noqa. Verified empirically: Base.metadata.tables has both tables with just the base import, and the full suite (incl. the migration up/down/up chain) passes. --- apps/backend/alembic/env.py | 1 - apps/backend/app/main.py | 1 - 2 files changed, 2 deletions(-) diff --git a/apps/backend/alembic/env.py b/apps/backend/alembic/env.py index ecaeb17c..8eb71f48 100644 --- a/apps/backend/alembic/env.py +++ b/apps/backend/alembic/env.py @@ -6,7 +6,6 @@ from app.core.config import get_settings from app.models.base import Base -import app.models # noqa: F401 - imported so metadata includes all models config = context.config settings = get_settings() diff --git a/apps/backend/app/main.py b/apps/backend/app/main.py index 46636e83..da713af3 100644 --- a/apps/backend/app/main.py +++ b/apps/backend/app/main.py @@ -17,7 +17,6 @@ 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.core.security_headers import SecurityHeadersMiddleware -import app.models # noqa: F401 - imported so metadata includes models from app.models.base import Base logger = logging.getLogger(__name__) From 89698f4991daef24e8333508289ef55bf14fcba8 Mon Sep 17 00:00:00 2001 From: Shaurya K Sharma Date: Sat, 11 Jul 2026 20:15:46 +0100 Subject: [PATCH 028/347] feat(auth): add JWT access tokens with rotating refresh sessions (E1.2) Native token authentication on top of the E1.1 identity model. Purely additive: existing routes keep their current behaviour, so nothing breaks before the frontend can sign in (E1.4), and the single enforcement flip stays E1.3's job. - /auth/register, /auth/login, /auth/refresh, /auth/logout, /auth/me. Access tokens are HS256 JWTs (~15 min TTL) signed with AUTH_SECRET_KEY, which staging/production refuse to start without. - Refresh tokens are opaque 256-bit secrets delivered in an httpOnly SameSite=Lax cookie scoped to /auth; only their sha256 is stored. Each refresh rotates the token within a family; presenting an already-used token is treated as theft and revokes the whole family, logging the event. - Passwords are hashed with argon2id. Unknown emails and credential-less accounts (seed user) burn the same dummy verification as a real attempt, and every credential failure returns an identical 401 body, so responses cannot enumerate accounts. Registration enforces a 10-char minimum. - get_current_user now strictly requires a Bearer token (first consumer: /auth/me). Repository routes move to get_current_user_or_default, which validates a presented token strictly (bad token = 401, never a silent fallback) and otherwise keeps the temporary pre-auth attribution that E1.3 will delete. - Migration 0003_auth_credentials adds users.password_hash and the refresh_tokens table; upgrade/downgrade/upgrade verified on SQLite, and the revision id stays under the 32-char alembic_version limit. - New deps: PyJWT, argon2-cffi, pydantic[email]. Tests: 17 new covering rotation, family revocation on reuse, logout idempotency, expired/garbage/missing tokens, indistinguishable credential failures, storage hygiene (argon2id hashes, no raw tokens in the database), and that anonymous access to existing routes still works. Full suite: 109 passed, 1 skipped. --- apps/backend/.env.example | 4 + .../alembic/versions/0003_auth_credentials.py | 49 ++++ apps/backend/app/api/deps.py | 55 +++- apps/backend/app/api/router.py | 3 +- apps/backend/app/api/routes/auth.py | 79 ++++++ apps/backend/app/auth/__init__.py | 0 apps/backend/app/auth/security.py | 75 ++++++ apps/backend/app/auth/service.py | 132 ++++++++++ apps/backend/app/core/config.py | 22 +- apps/backend/app/core/exceptions.py | 5 + apps/backend/app/models/__init__.py | 3 +- apps/backend/app/models/refresh_token.py | 26 ++ apps/backend/app/models/user.py | 3 + apps/backend/app/schemas/auth.py | 35 +++ apps/backend/pyproject.toml | 4 +- apps/backend/tests/test_auth.py | 241 ++++++++++++++++++ 16 files changed, 722 insertions(+), 14 deletions(-) create mode 100644 apps/backend/alembic/versions/0003_auth_credentials.py create mode 100644 apps/backend/app/api/routes/auth.py create mode 100644 apps/backend/app/auth/__init__.py create mode 100644 apps/backend/app/auth/security.py create mode 100644 apps/backend/app/auth/service.py create mode 100644 apps/backend/app/models/refresh_token.py create mode 100644 apps/backend/app/schemas/auth.py create mode 100644 apps/backend/tests/test_auth.py diff --git a/apps/backend/.env.example b/apps/backend/.env.example index a87ba508..a2b52ab3 100644 --- a/apps/backend/.env.example +++ b/apps/backend/.env.example @@ -6,3 +6,7 @@ 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 +# 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 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/app/api/deps.py b/apps/backend/app/api/deps.py index 8a4ed904..cdb1ebe7 100644 --- a/apps/backend/app/api/deps.py +++ b/apps/backend/app/api/deps.py @@ -1,6 +1,7 @@ from uuid import uuid4 from fastapi import Depends, Header +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from sqlalchemy import select from sqlalchemy.orm import Session @@ -15,8 +16,11 @@ from app.ai.providers.registry import ProviderRegistry from app.ai.repository_context import RepositoryContextBuilder from app.analysis.architecture import ArchitectureAnalyzer +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.database import get_db +from app.core.exceptions import UnauthorizedError from app.github.client import GitHubClient from app.graph.dependency_graph import DependencyGraphBuilder from app.intelligence.engine import RepositoryIntelligenceEngine @@ -57,21 +61,52 @@ def _resolve_dev_user(db: Session, email: str) -> User: return user +_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_current_user_or_default( + credentials: HTTPAuthorizationCredentials | None = Depends(_bearer_scheme), db: Session = Depends(get_db), settings: Settings = Depends(get_settings), x_dev_user: str | None = Header(default=None, alias="X-Dev-User"), ) -> User: - """Resolve the user that owns the data for this request. - - Temporary E1.1 seam: there is no authentication yet, so requests are - attributed to a single seed user. In development/test an ``X-Dev-User`` - header (an email) selects or provisions a distinct user, so multi-tenant - behaviour can be exercised before E1.2 lands real sign-in. The header is - ignored outside development/test, so it cannot be used to spoof identity in - a real deployment. E1.2 replaces the body of this function with token - verification; callers depending on it do not change. + """Resolve the user for routes that still tolerate anonymous access. + + A presented Bearer token is always validated strictly — sending a bad + token is an authentication attempt and gets a 401, never a silent + fallback. Without a token, the temporary pre-auth behaviour applies: the + ``X-Dev-User`` header selects a user in development/test, and everything + else is attributed to the seed user. E1.3 deletes this fallback (and the + header) once the frontend can sign in, leaving only ``get_current_user``. """ + if credentials is not None: + return _user_from_bearer(credentials.credentials, db, settings) if x_dev_user and settings.app_env in {"development", "test"}: return _resolve_dev_user(db, x_dev_user) return _ensure_seed_user(db) @@ -100,7 +135,7 @@ def get_repository_service( 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), + current_user: User = Depends(get_current_user_or_default), ) -> RepositoryService: return RepositoryService( repository=repository, diff --git a/apps/backend/app/api/router.py b/apps/backend/app/api/router.py index e01040e6..d0e2b1fb 100644 --- a/apps/backend/app/api/router.py +++ b/apps/backend/app/api/router.py @@ -1,8 +1,9 @@ from fastapi import APIRouter -from app.api.routes import ai, analysis, documentation, reports, repositories +from app.api.routes import ai, analysis, auth, documentation, reports, repositories api_router = APIRouter() +api_router.include_router(auth.router) api_router.include_router(repositories.router) api_router.include_router(analysis.router) api_router.include_router(ai.router) diff --git a/apps/backend/app/api/routes/auth.py b/apps/backend/app/api/routes/auth.py new file mode 100644 index 00000000..38651474 --- /dev/null +++ b/apps/backend/app/api/routes/auth.py @@ -0,0 +1,79 @@ +from fastapi import APIRouter, Cookie, Depends, Response, status + +from app.api.deps import get_auth_service, get_current_user +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 AuthResponse, LoginRequest, RegisterRequest, UserResponse + +router = APIRouter(prefix="/auth", tags=["auth"]) + +REFRESH_COOKIE = "partha_refresh" + + +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) +def register( + request: RegisterRequest, + 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) +def login( + request: LoginRequest, + 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) +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) +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) +def me(current_user: User = Depends(get_current_user)) -> UserResponse: + return UserResponse.model_validate(current_user) 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/security.py b/apps/backend/app/auth/security.py new file mode 100644 index 00000000..f13c86d7 --- /dev/null +++ b/apps/backend/app/auth/security.py @@ -0,0 +1,75 @@ +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.""" + try: + _hasher.verify(_DUMMY_HASH, password) + except (VerifyMismatchError, VerificationError): + pass + + +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() diff --git a/apps/backend/app/auth/service.py b/apps/backend/app/auth/service.py new file mode 100644 index 00000000..0f51cec5 --- /dev/null +++ b/apps/backend/app/auth/service.py @@ -0,0 +1,132 @@ +import logging +from datetime import UTC, datetime, timedelta +from uuid import uuid4 + +from sqlalchemy import select +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 +from app.models.refresh_token import RefreshToken +from app.models.user import 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." + + +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() + existing = self.db.scalars(select(User).where(User.email == normalized)).first() + if existing: + raise ConflictServiceError("An account with this email already exists.") + + user = User(id=str(uuid4()), email=normalized, password_hash=hash_password(password)) + self.db.add(user) + self.db.commit() + 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) + + record.used_at = now + 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 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]: + 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/config.py b/apps/backend/app/core/config.py index 67dd87b3..57ef2592 100644 --- a/apps/backend/app/core/config.py +++ b/apps/backend/app/core/config.py @@ -26,6 +26,12 @@ class Settings(BaseSettings): # 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 clone_timeout_seconds: int = 120 max_upload_size_bytes: int = 100 * 1024 * 1024 max_clone_size_bytes: int = 500 * 1024 * 1024 @@ -96,7 +102,13 @@ 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", "max_clone_size_bytes") + @field_validator( + "clone_timeout_seconds", + "max_upload_size_bytes", + "max_clone_size_bytes", + "access_token_ttl_seconds", + "refresh_token_ttl_seconds", + ) @classmethod def validate_positive_int(cls, value: int) -> int: if value <= 0: @@ -109,6 +121,14 @@ def resolve_auto_create_tables(self) -> "Settings": self.auto_create_tables = self.app_env in {"development", "test"} return self + @model_validator(mode="after") + def resolve_auth_secret_key(self) -> "Settings": + if not self.auth_secret_key: + if self.app_env not in {"development", "test"}: + raise ValueError("AUTH_SECRET_KEY must be set outside development/test environments.") + self.auth_secret_key = "insecure-dev-secret-do-not-use-in-production" + return self + @lru_cache def get_settings() -> Settings: diff --git a/apps/backend/app/core/exceptions.py b/apps/backend/app/core/exceptions.py index 5c3bbab1..418915cb 100644 --- a/apps/backend/app/core/exceptions.py +++ b/apps/backend/app/core/exceptions.py @@ -38,6 +38,11 @@ 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" diff --git a/apps/backend/app/models/__init__.py b/apps/backend/app/models/__init__.py index af1a2322..de8f4342 100644 --- a/apps/backend/app/models/__init__.py +++ b/apps/backend/app/models/__init__.py @@ -1,4 +1,5 @@ +from app.models.refresh_token import RefreshToken from app.models.repository import RepositoryRecord from app.models.user import User -__all__ = ["RepositoryRecord", "User"] +__all__ = ["RefreshToken", "RepositoryRecord", "User"] diff --git a/apps/backend/app/models/refresh_token.py b/apps/backend/app/models/refresh_token.py new file mode 100644 index 00000000..5a6be1f0 --- /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"), 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/user.py b/apps/backend/app/models/user.py index fa923d81..7fb5b968 100644 --- a/apps/backend/app/models/user.py +++ b/apps/backend/app/models/user.py @@ -19,6 +19,9 @@ class User(Base): 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( diff --git a/apps/backend/app/schemas/auth.py b/apps/backend/app/schemas/auth.py new file mode 100644 index 00000000..922b927e --- /dev/null +++ b/apps/backend/app/schemas/auth.py @@ -0,0 +1,35 @@ +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 diff --git a/apps/backend/pyproject.toml b/apps/backend/pyproject.toml index 2e5c6e63..de2fd6b6 100644 --- a/apps/backend/pyproject.toml +++ b/apps/backend/pyproject.toml @@ -10,8 +10,10 @@ 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", "SQLAlchemy>=2.0.0", "alembic>=1.13.0", "psycopg[binary]>=3.2.0", diff --git a/apps/backend/tests/test_auth.py b/apps/backend/tests/test_auth.py new file mode 100644 index 00000000..d319757f --- /dev/null +++ b/apps/backend/tests/test_auth.py @@ -0,0 +1,241 @@ +import uuid + +REGISTER = {"email": "alice@example.com", "password": "correct-horse-battery"} +COOKIE = "partha_refresh" + + +def _register(client, email="alice@example.com", password="correct-horse-battery"): + 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 duplicate.status_code == 409 + assert duplicate.json()["code"] == "conflict_error" + + +def test_register_rejects_short_password(client): + response = _register(client, password="short") + assert response.status_code == 422 + + +# --- 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"}) + + assert wrong_password.status_code == unknown_email.status_code == 401 + assert wrong_password.json() == unknown_email.json() | {"request_id": wrong_password.json()["request_id"]} + assert wrong_password.json()["code"] == "unauthorized" + + +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 client.get("/auth/me").status_code == 401 + + +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 client.post("/auth/refresh").status_code == 401 + + +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 + + +# --- 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() + + +# --- interaction with pre-auth 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 + + anonymous = client.get("/repositories") + assert anonymous.status_code == 200 + assert anonymous.json()["total"] == 0 # seed user owns nothing of alice's + + +def test_invalid_bearer_on_tolerant_routes_is_rejected_not_ignored(client): + # Presenting a bad token is an authentication attempt; it must never fall + # back silently to the seed user. + response = client.get("/repositories", headers={"Authorization": "Bearer garbage"}) + assert response.status_code == 401 + + +def test_anonymous_repository_access_still_works(client): + response = client.get("/repositories") + assert response.status_code == 200 + assert response.json() == {"data": [], "total": 0} From 8a882abb015b08b2079c04859c30f5dd9b23bef1 Mon Sep 17 00:00:00 2001 From: Shaurya K Sharma Date: Sun, 12 Jul 2026 01:33:44 +0100 Subject: [PATCH 029/347] harden(auth): concurrency-safe refresh rotation + secret-strength floor (E1.2) Pre-review hardening for the auth PR. - Refresh rotation is now atomic. The old read-then-write let two requests presenting the same token both pass the used_at check and each mint a successor. Rotation now claims the token with a single UPDATE ... WHERE used_at IS NULL: the row lock serializes racers and only the one whose update affects a row proceeds; the loser is treated as a concurrent replay and its family is revoked. - Enforce AUTH_SECRET_KEY strength: outside development/test the signing key must be at least 32 characters, so a weak HS256 secret fails fast at startup instead of silently weakening every token. - Replace the burn-check's empty except with contextlib.suppress plus a comment explaining the discarded result is intentional (anti-enumeration timing). Tests: - Deterministic single-use claim test (runs on any engine) proving a second claim of the same token affects zero rows. - A real-Postgres threaded test: two concurrent refreshes of one token, exactly one succeeds. Gated on PARTHA_TEST_PG_URL and wired into the Backend CI job, which now runs a postgres:16 service so the race is exercised for real (SQLite serializes writes and cannot). Skips locally without the env var. - Secret-strength validation test. Full suite: 111 passed, 2 skipped (Postgres-gated + pre-existing). --- .github/workflows/ci.yml | 20 ++++ apps/backend/app/auth/security.py | 12 +- apps/backend/app/auth/service.py | 30 ++++- apps/backend/app/core/config.py | 13 ++- apps/backend/tests/test_auth.py | 23 ++++ apps/backend/tests/test_auth_concurrency.py | 123 ++++++++++++++++++++ 6 files changed, 214 insertions(+), 7 deletions(-) create mode 100644 apps/backend/tests/test_auth_concurrency.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8220b96c..ba89ed1d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -72,6 +72,24 @@ jobs: needs: repository-hygiene runs-on: ubuntu-latest + # A Postgres sidecar so the gated refresh-token concurrency test runs against + # a real database (SQLite serializes writes and cannot exercise the row-lock + # race). Every other test still uses per-test SQLite via the fixture. + 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 + steps: - name: Checkout uses: actions/checkout@v4 @@ -92,6 +110,8 @@ jobs: - name: Test working-directory: apps/backend + env: + PARTHA_TEST_PG_URL: postgresql+psycopg://partha:partha@localhost:5432/partha_test run: python -m pytest docker-compose: diff --git a/apps/backend/app/auth/security.py b/apps/backend/app/auth/security.py index f13c86d7..f06b412b 100644 --- a/apps/backend/app/auth/security.py +++ b/apps/backend/app/auth/security.py @@ -1,3 +1,4 @@ +import contextlib import hashlib import secrets from datetime import UTC, datetime, timedelta @@ -32,11 +33,14 @@ def verify_password(password_hash: str, password: str) -> bool: def burn_password_check(password: str) -> None: - """Spend one argon2 verification without authenticating anyone.""" - try: + """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) - except (VerifyMismatchError, VerificationError): - pass def create_access_token(user_id: str, settings: Settings, ttl_seconds: int | None = None) -> str: diff --git a/apps/backend/app/auth/service.py b/apps/backend/app/auth/service.py index 0f51cec5..28da23ce 100644 --- a/apps/backend/app/auth/service.py +++ b/apps/backend/app/auth/service.py @@ -2,7 +2,7 @@ from datetime import UTC, datetime, timedelta from uuid import uuid4 -from sqlalchemy import select +from sqlalchemy import select, update from sqlalchemy.orm import Session from app.auth.security import ( @@ -85,11 +85,37 @@ def refresh(self, raw_token: str) -> tuple[User, str, str]: self._revoke_family(record.family_id, now) raise UnauthorizedError(INVALID_REFRESH) - record.used_at = now + # 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. diff --git a/apps/backend/app/core/config.py b/apps/backend/app/core/config.py index 57ef2592..fdc32c04 100644 --- a/apps/backend/app/core/config.py +++ b/apps/backend/app/core/config.py @@ -9,6 +9,11 @@ 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 + class Settings(BaseSettings): app_name: str = "PARTHA Backend" @@ -123,10 +128,16 @@ def resolve_auto_create_tables(self) -> "Settings": @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 self.app_env not in {"development", "test"}: + 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 diff --git a/apps/backend/tests/test_auth.py b/apps/backend/tests/test_auth.py index d319757f..4d6d84d0 100644 --- a/apps/backend/tests/test_auth.py +++ b/apps/backend/tests/test_auth.py @@ -1,5 +1,7 @@ import uuid +import pytest + REGISTER = {"email": "alice@example.com", "password": "correct-horse-battery"} COOKIE = "partha_refresh" @@ -193,6 +195,27 @@ def test_no_plaintext_credentials_or_raw_tokens_in_database(client): 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 + + # Non-dev refuses to start without a secret... + with pytest.raises(ValidationError): + Settings(app_env="production") + # ...and with a weak one. + with pytest.raises(ValidationError): + Settings(app_env="production", auth_secret_key="too-short") + # A sufficiently long secret is accepted. + strong = "s" * 32 + assert Settings(app_env="production", auth_secret_key=strong).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 pre-auth routes ----------------------------------------- diff --git a/apps/backend/tests/test_auth_concurrency.py b/apps/backend/tests/test_auth_concurrency.py new file mode 100644 index 00000000..ac29df7a --- /dev/null +++ b/apps/backend/tests/test_auth_concurrency.py @@ -0,0 +1,123 @@ +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() + raw = _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() + + +@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.""" + from sqlalchemy import create_engine + 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.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 + finally: + setup.close() + + results: list[str] = [] + results_lock = threading.Lock() + start = threading.Barrier(2) + + def worker() -> None: + session = Session() + outcome = "error" + try: + start.wait(timeout=10) + AuthService(session, settings).refresh(raw) + outcome = "ok" + 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) + + try: + assert sorted(results) == ["ok", "rejected"], results + finally: + cleanup = Session() + try: + from app.models.refresh_token import RefreshToken + + cleanup.query(RefreshToken).filter(RefreshToken.user_id == user_id).delete() + cleanup.query(User).filter(User.id == user_id).delete() + cleanup.commit() + finally: + cleanup.close() From 6a5981a3a87153563bb96e607c8c8618b54d0447 Mon Sep 17 00:00:00 2001 From: Shaurya K Sharma Date: Sun, 12 Jul 2026 16:17:31 +0100 Subject: [PATCH 030/347] chore(auth): regenerate backend lockfile with PyJWT/argon2-cffi/email-validator (E1.2) Owed since #72 merged the pinned-lockfile convention after this branch forked. Regenerated from a clean venv per the file header procedure (pip install ./apps/backend into an empty venv, then freeze) so no stale packages from a dev environment leak in. Adds argon2-cffi + argon2-cffi-bindings (password hashing), PyJWT (access tokens), and email-validator + dnspython (pydantic[email]'s validation extra) - exactly the new dependencies pyproject.toml declares for E1.2. Every other pinned version is unchanged; networkx and GitPython remain absent. --- apps/backend/requirements.txt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/backend/requirements.txt b/apps/backend/requirements.txt index 3abc4ca8..71bf6c1a 100644 --- a/apps/backend/requirements.txt +++ b/apps/backend/requirements.txt @@ -10,6 +10,8 @@ annotated-doc==0.0.4 annotated-types==0.7.0 anyio==4.14.1 arabic-reshaper==3.0.1 +argon2-cffi==25.1.0 +argon2-cffi-bindings==25.1.0 asn1crypto==1.5.1 certifi==2026.6.17 cffi==2.1.0 @@ -18,6 +20,8 @@ click==8.4.2 colorama==0.4.6 cryptography==49.0.0 cssselect2==0.9.0 +dnspython==2.8.0 +email-validator==2.3.0 fastapi==0.139.0 greenlet==3.5.3 h11==0.16.0 @@ -43,6 +47,7 @@ pydantic_core==2.46.4 Pygments==2.20.0 pyhanko-certvalidator==0.31.1 pyHanko==0.35.2 +PyJWT==2.13.0 pypdf==6.14.2 pytest==9.1.1 python-bidi==0.6.11 From ac83260b3dad3710352c11efeb2655df0839e77f Mon Sep 17 00:00:00 2001 From: Shaurya K Sharma Date: Sun, 12 Jul 2026 16:29:09 +0100 Subject: [PATCH 031/347] test(auth): drop unused local in test_claim_token_is_single_use The code-quality bot's finding on test_auth_concurrency.py:49 stayed resolved-but-not-outdated through the rebase (the line was untouched), so it was still live. Only token_id is asserted on; raw was never read. --- apps/backend/tests/test_auth_concurrency.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/backend/tests/test_auth_concurrency.py b/apps/backend/tests/test_auth_concurrency.py index ac29df7a..7e1ec356 100644 --- a/apps/backend/tests/test_auth_concurrency.py +++ b/apps/backend/tests/test_auth_concurrency.py @@ -46,7 +46,7 @@ def test_claim_token_is_single_use(client): user = User(id=str(uuid.uuid4()), email="claim@example.com") db.add(user) db.commit() - raw = _make_refresh_token(db, user.id) + _make_refresh_token(db, user.id) token_id = db.query(RefreshToken).one().id service = AuthService(db, get_settings()) From d9caf2340d4881d33a0c812230a59518abfe1845 Mon Sep 17 00:00:00 2001 From: Shaurya K Sharma Date: Sun, 12 Jul 2026 17:11:54 +0100 Subject: [PATCH 032/347] fix(auth): handle concurrent duplicate-registration race as a 409 AuthService.register() checked for an existing email, then inserted and committed - two simultaneous registrations for the same normalized email could both pass the check, and the loser's commit would raise a raw IntegrityError, surfacing as a generic 500 instead of the intended 409. Catch IntegrityError on commit, roll back (leaving the session usable), and re-check whether the email now exists before reporting the same ConflictServiceError the normal duplicate path already returns - any other integrity failure is re-raised rather than mislabeled as a duplicate email. No database error text, table, or constraint name is ever exposed; the unique constraint itself is unchanged and remains the real guard. Added a deterministic regression test that reproduces the exact race by intercepting the losing session's own commit() to run a second, independent session's successful registration first - the same database-level interleaving two real concurrent requests would produce - then verifies: the public 409/conflict_error contract, that the session is usable after rollback, that email normalization holds across the race (mixed case and whitespace on both sides), and that exactly one user row exists afterward. --- apps/backend/app/auth/service.py | 15 ++++++- apps/backend/tests/test_auth.py | 67 ++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 1 deletion(-) diff --git a/apps/backend/app/auth/service.py b/apps/backend/app/auth/service.py index 28da23ce..b272fada 100644 --- a/apps/backend/app/auth/service.py +++ b/apps/backend/app/auth/service.py @@ -3,6 +3,7 @@ from uuid import uuid4 from sqlalchemy import select, update +from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session from app.auth.security import ( @@ -45,7 +46,19 @@ def register(self, email: str, password: str) -> tuple[User, str, str]: user = User(id=str(uuid4()), email=normalized, password_hash=hash_password(password)) self.db.add(user) - self.db.commit() + try: + self.db.commit() + except IntegrityError: + # The existence check above can't see a concurrent registration for + # the same email that commits between our check and our insert; + # the unique constraint is the real guard. Roll back so the session + # is usable again, then confirm this was actually that email + # collision before reporting it as one - an unrelated integrity + # failure must not be mislabeled as a duplicate-email conflict. + self.db.rollback() + if self.db.scalars(select(User).where(User.email == normalized)).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) diff --git a/apps/backend/tests/test_auth.py b/apps/backend/tests/test_auth.py index 4d6d84d0..42634203 100644 --- a/apps/backend/tests/test_auth.py +++ b/apps/backend/tests/test_auth.py @@ -56,6 +56,73 @@ def test_register_rejects_short_password(client): assert response.status_code == 422 +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 commits; 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 `commit()` 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 commit proceeds and collides on the unique email constraint. + """ + 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() + + 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_commit = loser_session.commit + + def commit_after_concurrent_winner_lands(): + winner_session = SessionLocal() + try: + AuthService(winner_session, settings).register(winner_email, "correct-horse-battery") + finally: + winner_session.close() + return original_commit() + + monkeypatch.setattr(loser_session, "commit", commit_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 -------------------------------------------------------------------- From c2b9782aabd6abc3d69a3865f443e4538a62cbee Mon Sep 17 00:00:00 2001 From: Shaurya K Sharma Date: Sun, 12 Jul 2026 17:11:55 +0100 Subject: [PATCH 033/347] test(auth): prove concurrent-refresh family revocation to final state The existing Postgres concurrency test only proved "one refresh wins, one is rejected." It didn't prove or document what happens to the winner's own successor once the loser's replay handling revokes the whole family - which is the actual, intentional, strict fail-closed behavior: any concurrent presentation of the same refresh token is treated as possible theft, so the winner is logged out too rather than silently kept alive. Improving UX here (e.g. preserving the winner) would need a larger protocol decision - recoverable successor state, a grace window, device binding - that is out of scope for E1.2 and is not implemented here. Extended the real-Postgres test to capture the winner's successor, then from a fresh session after both threads finish: confirm the successor is itself rejected, confirm every row in the token family has revoked_at set, and confirm no unused/unrevoked row remains. Thread timeouts and unexpected exceptions (anything other than the intended UnauthorizedError) now fail the test explicitly rather than being folded into "rejected." Added a deterministic companion test (SQLite, any engine) that proves the same service-level rule sequentially, without needing threads or a real row lock - the atomic claim primitive itself is already covered separately by test_claim_token_is_single_use; this proves what happens next once a claim is lost. --- apps/backend/tests/test_auth_concurrency.py | 91 +++++++++++++++++++-- 1 file changed, 86 insertions(+), 5 deletions(-) diff --git a/apps/backend/tests/test_auth_concurrency.py b/apps/backend/tests/test_auth_concurrency.py index 7e1ec356..5e0244f8 100644 --- a/apps/backend/tests/test_auth_concurrency.py +++ b/apps/backend/tests/test_auth_concurrency.py @@ -57,17 +57,74 @@ def test_claim_token_is_single_use(client): 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 + + settings = get_settings() + 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.""" - from sqlalchemy import create_engine + 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) @@ -82,10 +139,12 @@ def test_concurrent_refresh_on_postgres_mints_one_successor(): 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) @@ -94,8 +153,10 @@ def worker() -> None: outcome = "error" try: start.wait(timeout=10) - AuthService(session, settings).refresh(raw) + _, _, successor = AuthService(session, settings).refresh(raw) outcome = "ok" + with results_lock: + winner_successors.append(successor) except UnauthorizedError: outcome = "rejected" finally: @@ -109,13 +170,33 @@ def worker() -> None: 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: - from app.models.refresh_token import RefreshToken - cleanup.query(RefreshToken).filter(RefreshToken.user_id == user_id).delete() cleanup.query(User).filter(User.id == user_id).delete() cleanup.commit() From 052c90717f0a5bceb020cb826b7a132387e70aab Mon Sep 17 00:00:00 2001 From: Shaurya K Sharma Date: Sat, 11 Jul 2026 20:23:48 +0100 Subject: [PATCH 034/347] feat(security): add per-client rate limiting with path-class budgets (E2.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixed-window request budgets keyed by client IP, enforced in middleware so no route signature changes. Budget classes, all configurable via env: - default 120/min; /ai/* 20/min; ingestion/analysis-start/docs-generate/export 30/min; /auth/login + /auth/register 10/min (inert until the auth router merges, then brute-force protection applies immediately). - Probes (/health /ready /metrics), docs, and OPTIONS preflight are exempt — a 429 on preflight would surface as an opaque CORS failure. Storage is a two-backend interface: an in-memory store (injectable clock, deterministic, per-process) for tests and bare development, and a Redis store (INCR+EXPIRE) wired to the existing redis client for multi-worker deployments; docker-compose selects redis. Store failures fail OPEN so availability wins over strictness, but never silently: each degraded request is logged and counted in a new partha_rate_limit_degraded_total metric alongside partha_rate_limited_requests_total. Over-budget requests get 429 with Retry-After and the standard error envelope. The middleware sits inside CORS so 429s stay readable to browser clients, and the identity resolver is a seam that auth enforcement upgrades to per-user keys. No new dependencies. Tests: classifier mapping, window reset and key independence (fake clock), 429 + Retry-After on exhaustion, per-class isolation, probe/preflight exemptions, loud fail-open, metrics exposure. Full suite: 96 passed, 1 skipped. --- apps/backend/.env.example | 8 ++ apps/backend/app/core/config.py | 23 ++++ apps/backend/app/core/observability.py | 27 ++++ apps/backend/app/core/rate_limit.py | 177 +++++++++++++++++++++++++ apps/backend/app/main.py | 8 ++ apps/backend/tests/test_rate_limit.py | 160 ++++++++++++++++++++++ docker-compose.yml | 3 + 7 files changed, 406 insertions(+) create mode 100644 apps/backend/app/core/rate_limit.py create mode 100644 apps/backend/tests/test_rate_limit.py diff --git a/apps/backend/.env.example b/apps/backend/.env.example index a2b52ab3..7c6188f1 100644 --- a/apps/backend/.env.example +++ b/apps/backend/.env.example @@ -10,3 +10,11 @@ AUTO_CREATE_TABLES=true AUTH_SECRET_KEY= ACCESS_TOKEN_TTL_SECONDS=900 REFRESH_TOKEN_TTL_SECONDS=1209600 +# 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/app/core/config.py b/apps/backend/app/core/config.py index fdc32c04..15e9d4c1 100644 --- a/apps/backend/app/core/config.py +++ b/apps/backend/app/core/config.py @@ -37,6 +37,16 @@ class Settings(BaseSettings): auth_secret_key: str = "" access_token_ttl_seconds: int = 900 refresh_token_ttl_seconds: int = 14 * 24 * 60 * 60 + # 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 clone_timeout_seconds: int = 120 max_upload_size_bytes: int = 100 * 1024 * 1024 max_clone_size_bytes: int = 500 * 1024 * 1024 @@ -107,12 +117,25 @@ def validate_cors_origins(cls, value: list[str]) -> list[str]: raise ValueError(f"Invalid CORS origin: {origin}") return value + @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( "clone_timeout_seconds", "max_upload_size_bytes", "max_clone_size_bytes", "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: 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..2d5195d1 --- /dev/null +++ b/apps/backend/app/core/rate_limit.py @@ -0,0 +1,177 @@ +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.core.config import Settings +from app.core.exceptions import ErrorResponse +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"}: + return "auth" + if path == "/ai" or path.startswith("/ai/"): + return "ai" + if method == "POST" and ( + path in {"/repositories/upload", "/repositories/github", "/documentation/generate", "/reports/export"} + or (path.startswith("/analysis/") and path.endswith("/start")) + ): + return "heavy" + return "default" + + +def resolve_rate_key(request: Request) -> str: + """Identity a budget is charged against: the client IP for now. + + E1.3 upgrades this to the authenticated user id when a valid Bearer token + is present, so signed-in users get per-user budgets instead of sharing a + NAT'd address. + """ + client = request.client + return client.host if client else "unknown" + + +class StoreUnavailableError(Exception): + """The backing store cannot be reached; the middleware fails open.""" + + +class RateLimitStore(Protocol): + 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. + """ + + def __init__(self, clock: Callable[[], float] = time.monotonic) -> None: + self._clock = clock + self._lock = Lock() + self._windows: dict[str, tuple[float, int]] = {} + + 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 + + +class RedisRateLimitStore: + """Fixed-window counters shared across processes via Redis INCR+EXPIRE.""" + + def __init__(self, client) -> None: + self._client = client + + def hit(self, key: str, window_seconds: int) -> tuple[int, int]: + redis_key = f"partha:ratelimit:{key}" + try: + count = self._client.incr(redis_key) + if count == 1: + self._client.expire(redis_key, window_seconds) + ttl = self._client.ttl(redis_key) + if ttl is None or ttl < 0: + # The key lost its expiry (e.g. crash between INCR and EXPIRE); + # re-arm it rather than rate-limiting forever. + self._client.expire(redis_key, window_seconds) + ttl = window_seconds + except Exception as exc: + raise StoreUnavailableError(str(exc)) from exc + return int(count), max(1, int(ttl)) + + +def build_rate_limit_store(settings: Settings) -> RateLimitStore: + if settings.rate_limit_backend == "redis": + from app.core.redis import create_redis_client + + return RedisRateLimitStore(create_redis_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)}" + + store: RateLimitStore = request.app.state.rate_limit_store + try: + count, retry_after = 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/main.py b/apps/backend/app/main.py index da713af3..9d150da6 100644 --- a/apps/backend/app/main.py +++ b/apps/backend/app/main.py @@ -16,6 +16,7 @@ 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.core.rate_limit import RateLimitMiddleware, build_rate_limit_store from app.core.security_headers import SecurityHeadersMiddleware from app.models.base import Base @@ -63,6 +64,13 @@ def create_app() -> FastAPI: lifespan=lifespan, ) + # 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, diff --git a/apps/backend/tests/test_rate_limit.py b/apps/backend/tests/test_rate_limit.py new file mode 100644 index 00000000..7189c9c7 --- /dev/null +++ b/apps/backend/tests/test_rate_limit.py @@ -0,0 +1,160 @@ +import os +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 + +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.main import create_app + from app.models.base import Base + + Base.metadata.create_all(bind=database.engine) + with TestClient(create_app()) as test_client: + 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", "/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", "/reports/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 + + +# --- memory store -------------------------------------------------------------- + + +def test_memory_store_counts_and_resets_after_window(): + now = [1000.0] + store = MemoryRateLimitStore(clock=lambda: now[0]) + + counts = [store.hit("k", 60)[0] for _ in range(3)] + assert counts == [1, 2, 3] + + _, retry_after = store.hit("k", 60) + assert 1 <= retry_after <= 60 + + now[0] += 61 # past the window: the counter starts over + count, _ = store.hit("k", 60) + assert count == 1 + + +def test_memory_store_keys_are_independent(): + store = MemoryRateLimitStore(clock=lambda: 0.0) + assert store.hit("a", 60)[0] == 1 + assert store.hit("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_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: + 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_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 diff --git a/docker-compose.yml b/docker-compose.yml index 4598f566..71218dbc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,6 +14,9 @@ services: STORAGE_PATH: /data/partha CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:5173,http://127.0.0.1:5173} AUTO_CREATE_TABLES: ${AUTO_CREATE_TABLES:-false} + # Redis-backed so budgets are shared across API workers; the app default + # ("memory") stays per-process for tests and bare-metal development. + RATE_LIMIT_BACKEND: ${RATE_LIMIT_BACKEND:-redis} volumes: - partha_storage:/data/partha depends_on: From 38f22bee0353a130be9fb359e50da88322154f0a Mon Sep 17 00:00:00 2001 From: Shaurya K Sharma Date: Sun, 12 Jul 2026 01:42:02 +0100 Subject: [PATCH 035/347] harden(security): async atomic Redis rate limiting + real-Redis test (E2.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-review hardening for the rate-limit PR. - The Redis backend now uses redis.asyncio and is awaited, so its network round trip no longer blocks the event loop inside the async middleware. - Replace the separate INCR / EXPIRE / TTL calls with a single Lua script that increments, arms (or re-arms) the TTL, and returns count + remaining TTL atomically in one round trip — closing the race between INCR and EXPIRE. - The store interface is now async (`async def hit`); the in-memory store is async-but-non-blocking. The RateLimitStore Protocol body is a docstring instead of a bare `...`, resolving the "statement has no effect" review note. - Document the trusted-proxy limitation on resolve_rate_key: it keys on the socket peer, which behind a proxy is the proxy's IP; honouring X-Forwarded-For safely needs a trusted-proxy allowlist and is deliberately not enabled, since trusting the header blindly makes the limiter spoofable. - Add a gated real-Redis functional test (atomic count, bounded TTL, key isolation) and a redis:7 service on the Backend CI job so it runs for real; it skips locally without PARTHA_TEST_REDIS_URL. Existing memory-store tests updated to drive the async interface. Full suite: 96 passed, 2 skipped (Redis-gated + pre-existing). --- .github/workflows/ci.yml | 19 ++++++-- apps/backend/app/core/rate_limit.py | 64 +++++++++++++++++++-------- apps/backend/tests/test_rate_limit.py | 53 +++++++++++++++++++--- 3 files changed, 108 insertions(+), 28 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ba89ed1d..366fe82e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -72,9 +72,12 @@ jobs: needs: repository-hygiene runs-on: ubuntu-latest - # A Postgres sidecar so the gated refresh-token concurrency test runs against - # a real database (SQLite serializes writes and cannot exercise the row-lock - # race). Every other test still uses per-test SQLite via the fixture. + # Postgres so the gated refresh-token concurrency test runs against a real + # database (SQLite serializes writes and cannot exercise the row-lock race), + # and Redis so the gated rate-limit backend test exercises the real atomic + # script and TTL behaviour. Every other test uses per-test SQLite and the + # in-memory rate-limit store, so neither service is required for the rest + # of the suite to run. services: postgres: image: postgres:16-alpine @@ -89,6 +92,15 @@ jobs: --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 @@ -112,6 +124,7 @@ jobs: 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 docker-compose: diff --git a/apps/backend/app/core/rate_limit.py b/apps/backend/app/core/rate_limit.py index 2d5195d1..64fcc724 100644 --- a/apps/backend/app/core/rate_limit.py +++ b/apps/backend/app/core/rate_limit.py @@ -45,11 +45,19 @@ def classify(method: str, path: str) -> str | None: def resolve_rate_key(request: Request) -> str: - """Identity a budget is charged against: the client IP for now. + """Identity a budget is charged against: the socket peer address for now. E1.3 upgrades this to the authenticated user id when a valid Bearer token is present, so signed-in users get per-user budgets instead of sharing a NAT'd address. + + Trusted-proxy caveat: this reads ``request.client.host``, the direct TCP + peer. Behind a reverse proxy or load balancer that is the proxy's address, + so every 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. """ client = request.client return client.host if client else "unknown" @@ -60,9 +68,10 @@ class StoreUnavailableError(Exception): class RateLimitStore(Protocol): - def hit(self, key: str, window_seconds: int) -> tuple[int, int]: + """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: @@ -70,7 +79,8 @@ class MemoryRateLimitStore: 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. + 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: @@ -78,7 +88,7 @@ def __init__(self, clock: Callable[[], float] = time.monotonic) -> None: self._lock = Lock() self._windows: dict[str, tuple[float, int]] = {} - def hit(self, key: str, window_seconds: int) -> tuple[int, 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)) @@ -90,24 +100,39 @@ def hit(self, key: str, window_seconds: int) -> tuple[int, int]: 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 Redis INCR+EXPIRE.""" + """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) - def hit(self, key: str, window_seconds: int) -> tuple[int, int]: + async def hit(self, key: str, window_seconds: int) -> tuple[int, int]: redis_key = f"partha:ratelimit:{key}" try: - count = self._client.incr(redis_key) - if count == 1: - self._client.expire(redis_key, window_seconds) - ttl = self._client.ttl(redis_key) - if ttl is None or ttl < 0: - # The key lost its expiry (e.g. crash between INCR and EXPIRE); - # re-arm it rather than rate-limiting forever. - self._client.expire(redis_key, window_seconds) - ttl = window_seconds + 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)) @@ -115,9 +140,10 @@ def hit(self, key: str, window_seconds: int) -> tuple[int, int]: def build_rate_limit_store(settings: Settings) -> RateLimitStore: if settings.rate_limit_backend == "redis": - from app.core.redis import create_redis_client + import redis.asyncio as redis_asyncio - return RedisRateLimitStore(create_redis_client()) + client = redis_asyncio.from_url(settings.redis_url, decode_responses=False) + return RedisRateLimitStore(client) return MemoryRateLimitStore() @@ -153,7 +179,7 @@ async def dispatch( store: RateLimitStore = request.app.state.rate_limit_store try: - count, retry_after = store.hit(key, WINDOW_SECONDS) + count, retry_after = await store.hit(key, WINDOW_SECONDS) except StoreUnavailableError: runtime_metrics.record_rate_limit_degraded() logger.warning( diff --git a/apps/backend/tests/test_rate_limit.py b/apps/backend/tests/test_rate_limit.py index 7189c9c7..cca0b855 100644 --- a/apps/backend/tests/test_rate_limit.py +++ b/apps/backend/tests/test_rate_limit.py @@ -1,4 +1,6 @@ +import asyncio import os +import uuid from collections.abc import Generator from pathlib import Path @@ -7,6 +9,13 @@ from app.core.rate_limit import MemoryRateLimitStore, StoreUnavailableError, classify +REDIS_URL = os.environ.get("PARTHA_TEST_REDIS_URL") + + +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)) + RATE_ENV = { "RATE_LIMIT_DEFAULT_PER_MINUTE": "3", "RATE_LIMIT_HEAVY_PER_MINUTE": "2", @@ -79,21 +88,21 @@ def test_memory_store_counts_and_resets_after_window(): now = [1000.0] store = MemoryRateLimitStore(clock=lambda: now[0]) - counts = [store.hit("k", 60)[0] for _ in range(3)] + counts = [_hit(store, "k", 60)[0] for _ in range(3)] assert counts == [1, 2, 3] - _, retry_after = store.hit("k", 60) + _, retry_after = _hit(store, "k", 60) assert 1 <= retry_after <= 60 now[0] += 61 # past the window: the counter starts over - count, _ = store.hit("k", 60) + count, _ = _hit(store, "k", 60) assert count == 1 def test_memory_store_keys_are_independent(): store = MemoryRateLimitStore(clock=lambda: 0.0) - assert store.hit("a", 60)[0] == 1 - assert store.hit("b", 60)[0] == 1 + assert _hit(store, "a", 60)[0] == 1 + assert _hit(store, "b", 60)[0] == 1 # --- middleware behaviour ------------------------------------------------------- @@ -140,7 +149,7 @@ def test_cors_preflight_is_never_limited(limited_client): def test_store_failure_fails_open_loudly(limited_client): class FailingStore: - def hit(self, key: str, window_seconds: int) -> tuple[int, int]: + async def hit(self, key: str, window_seconds: int) -> tuple[int, int]: raise StoreUnavailableError("redis is down") from app.core.observability import runtime_metrics @@ -158,3 +167,35 @@ 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 + + +# --- 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()) From 687ee421532894413e8cf7276a93cded9bfb1580 Mon Sep 17 00:00:00 2001 From: Shaurya K Sharma Date: Sun, 12 Jul 2026 18:59:02 +0100 Subject: [PATCH 036/347] harden(security): close Redis rate-limit client on shutdown; prove CORS/security headers and concurrent atomicity (E2.1) Pre-merge correctness pass after rebasing onto dev (post-#73 auth merge): the Redis client built in build_rate_limit_store was never closed, leaking its connection pool across app restarts/reloads. lifespan now closes it via a duck-typed aclose() on shutdown, a no-op for the in-memory store. Also strengthens test coverage per review: a 429 now has an explicit test proving it still carries CORS and security headers (RateLimitMiddleware is innermost of the three), and the gated real-Redis test gains a concurrent-hit case proving the atomic Lua script yields exact, non-overlapping counts under real parallel load rather than only sequential calls. --- apps/backend/app/core/rate_limit.py | 3 + apps/backend/app/main.py | 5 +- apps/backend/tests/test_rate_limit.py | 83 +++++++++++++++++++++++++++ 3 files changed, 90 insertions(+), 1 deletion(-) diff --git a/apps/backend/app/core/rate_limit.py b/apps/backend/app/core/rate_limit.py index 64fcc724..fdb9ec62 100644 --- a/apps/backend/app/core/rate_limit.py +++ b/apps/backend/app/core/rate_limit.py @@ -137,6 +137,9 @@ async def hit(self, key: str, window_seconds: int) -> tuple[int, int]: 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": diff --git a/apps/backend/app/main.py b/apps/backend/app/main.py index 9d150da6..7ea339b9 100644 --- a/apps/backend/app/main.py +++ b/apps/backend/app/main.py @@ -45,12 +45,15 @@ def check_storage_ready() -> bool: @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 + aclose = getattr(app.state.rate_limit_store, "aclose", None) + if aclose is not None: + await aclose() def create_app() -> FastAPI: diff --git a/apps/backend/tests/test_rate_limit.py b/apps/backend/tests/test_rate_limit.py index cca0b855..390652b2 100644 --- a/apps/backend/tests/test_rate_limit.py +++ b/apps/backend/tests/test_rate_limit.py @@ -163,12 +163,65 @@ async def hit(self, key: str, window_seconds: int) -> tuple[int, int]: 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() + + import app.main as main_module + + 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(main_module, "build_rate_limit_store", lambda settings: StubStore()) + + with TestClient(main_module.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_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) ----------------------------------------------- @@ -199,3 +252,33 @@ async def scenario() -> None: 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()) From 83ea6f023a4ce41ec4431d67c592d2a08438b139 Mon Sep 17 00:00:00 2001 From: Shaurya K Sharma Date: Sun, 12 Jul 2026 19:04:41 +0100 Subject: [PATCH 037/347] fix(tests): use a single import style for app.main in test_rate_limit.py (E2.1) Bot review on the pushed commit flagged app.main being imported both via "import app.main as main_module" and "from app.main import create_app" in the same file. Standardized on the existing "from app.main import ..." style already used by the limited_client fixture, monkeypatching build_rate_limit_store by its string target instead of via a module alias. --- apps/backend/tests/test_rate_limit.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/backend/tests/test_rate_limit.py b/apps/backend/tests/test_rate_limit.py index 390652b2..6d0ec035 100644 --- a/apps/backend/tests/test_rate_limit.py +++ b/apps/backend/tests/test_rate_limit.py @@ -175,7 +175,7 @@ def test_rate_limit_store_closed_on_app_shutdown(monkeypatch, tmp_path: Path) -> config.get_settings.cache_clear() - import app.main as main_module + from app.main import create_app closed = {"value": False} @@ -186,9 +186,9 @@ async def hit(self, key: str, window_seconds: int) -> tuple[int, int]: async def aclose(self) -> None: closed["value"] = True - monkeypatch.setattr(main_module, "build_rate_limit_store", lambda settings: StubStore()) + monkeypatch.setattr("app.main.build_rate_limit_store", lambda settings: StubStore()) - with TestClient(main_module.create_app()) as test_client: + with TestClient(create_app()) as test_client: assert closed["value"] is False test_client.get("/health") From f4c85a297237806eef54e8f044e50d076948d1e5 Mon Sep 17 00:00:00 2001 From: Shaurya K Sharma Date: Sun, 12 Jul 2026 20:15:27 +0100 Subject: [PATCH 038/347] =?UTF-8?q?fix(security):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20correct=20/export=20classification,=20expose=20rate?= =?UTF-8?q?-limit=20env=20in=20Compose,=20harden=20lifespan=20cleanup=20(E?= =?UTF-8?q?2.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - classify() checked "/reports/export", but the registered route is POST /export; the real endpoint was silently getting the default budget instead of the heavy one. Fixed, plus a regression test that drives the actual /export route (not just the classifier in isolation) and proves it now hits the heavy budget's 429. - docker-compose.yml only passed RATE_LIMIT_BACKEND through to the API service; the other four budget/enable knobs were undocumented dead env vars in Compose despite the PR claiming full env-configurability. Now all five are passed through. - Redis client cleanup ran after `yield` with no try/finally, so an exception during the app's running state would skip aclose() and leak the connection pool. Wrapped in try/finally, with a test that forces the lifespan to exit via an exception and asserts aclose() still ran. --- apps/backend/app/core/rate_limit.py | 2 +- apps/backend/app/main.py | 10 +-- apps/backend/tests/test_rate_limit.py | 92 ++++++++++++++++++++++++++- docker-compose.yml | 5 ++ 4 files changed, 103 insertions(+), 6 deletions(-) diff --git a/apps/backend/app/core/rate_limit.py b/apps/backend/app/core/rate_limit.py index fdb9ec62..b0b38f75 100644 --- a/apps/backend/app/core/rate_limit.py +++ b/apps/backend/app/core/rate_limit.py @@ -37,7 +37,7 @@ def classify(method: str, path: str) -> str | None: if path == "/ai" or path.startswith("/ai/"): return "ai" if method == "POST" and ( - path in {"/repositories/upload", "/repositories/github", "/documentation/generate", "/reports/export"} + path in {"/repositories/upload", "/repositories/github", "/documentation/generate", "/export"} or (path.startswith("/analysis/") and path.endswith("/start")) ): return "heavy" diff --git a/apps/backend/app/main.py b/apps/backend/app/main.py index 7ea339b9..18c924aa 100644 --- a/apps/backend/app/main.py +++ b/apps/backend/app/main.py @@ -50,10 +50,12 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: settings.storage_path.mkdir(parents=True, exist_ok=True) if settings.auto_create_tables: Base.metadata.create_all(bind=database.engine) - yield - aclose = getattr(app.state.rate_limit_store, "aclose", None) - if aclose is not None: - await aclose() + try: + yield + finally: + aclose = getattr(app.state.rate_limit_store, "aclose", None) + if aclose is not None: + await aclose() def create_app() -> FastAPI: diff --git a/apps/backend/tests/test_rate_limit.py b/apps/backend/tests/test_rate_limit.py index 6d0ec035..5427783f 100644 --- a/apps/backend/tests/test_rate_limit.py +++ b/apps/backend/tests/test_rate_limit.py @@ -1,6 +1,8 @@ import asyncio +import io import os import uuid +import zipfile from collections.abc import Generator from pathlib import Path @@ -16,6 +18,29 @@ 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", @@ -72,7 +97,7 @@ def test_classify_maps_routes_to_budget_classes(): 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", "/reports/export") == "heavy" + assert classify("POST", "/export") == "heavy" assert classify("GET", "/repositories") == "default" # Exemptions: probes, docs, and CORS preflight. assert classify("GET", "/health") is None @@ -131,6 +156,24 @@ def test_budget_classes_are_charged_separately(limited_client): 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 + + payload = {"repositoryId": repository_id, "target": "review", "format": "json"} + assert limited_client.post("/export", json=payload).status_code == 200 # 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 @@ -199,6 +242,53 @@ async def aclose(self) -> 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 diff --git a/docker-compose.yml b/docker-compose.yml index 71218dbc..3a88f9c2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,6 +17,11 @@ services: # Redis-backed so budgets are shared across API workers; the app default # ("memory") stays per-process for tests and bare-metal development. RATE_LIMIT_BACKEND: ${RATE_LIMIT_BACKEND:-redis} + RATE_LIMIT_ENABLED: ${RATE_LIMIT_ENABLED:-true} + RATE_LIMIT_DEFAULT_PER_MINUTE: ${RATE_LIMIT_DEFAULT_PER_MINUTE:-120} + RATE_LIMIT_AUTH_PER_MINUTE: ${RATE_LIMIT_AUTH_PER_MINUTE:-10} + RATE_LIMIT_AI_PER_MINUTE: ${RATE_LIMIT_AI_PER_MINUTE:-20} + RATE_LIMIT_HEAVY_PER_MINUTE: ${RATE_LIMIT_HEAVY_PER_MINUTE:-30} volumes: - partha_storage:/data/partha depends_on: From 948f936ec180db2a12c8b166f7672bc601aa7395 Mon Sep 17 00:00:00 2001 From: Shaurya K Sharma Date: Sun, 12 Jul 2026 21:18:04 +0100 Subject: [PATCH 039/347] feat(frontend): add login/register, guarded routes, and session-aware API client (E1.4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements #64: the frontend can now sign in against the E1.2 auth backend and every existing route is gated on that session. - Access token lives in memory only (Zustand auth store), never in localStorage/sessionStorage; the httpOnly refresh cookie does the persistence across reloads. Bootstrap on app start does one silent POST /auth/refresh then GET /auth/me before any protected UI renders. - API client: fetch/XHR calls now send credentials, and a single shared in-flight refresh promise backs the 401 interceptor — concurrent 401s await one /auth/refresh and each retry their own request once; login/ register/refresh/logout are excluded to avoid recursing into themselves. A refresh that fails (or isn't retryable) clears session state instead of looping. - RequireAuth guards every existing app route (unchanged), showing a neutral loading state while bootstrap runs and redirecting to /login (preserving the intended destination) once it resolves unauthenticated. - Login/Register pages outside MainLayout; Settings' account section and the sidebar's sign-out control (both previously hardcoded/disabled) now use the real session. Does not touch #63: the backend's anonymous fallback (X-Dev-User / seed user) and existing auth semantics are untouched, and rate keys stay IP-based. #63 is next — it removes the anonymous fallback and switches enforcement + rate-limit identity to the authenticated user. --- apps/frontend/src/app/App.tsx | 8 + apps/frontend/src/app/pages/LoginPage.tsx | 73 +++++++ apps/frontend/src/app/pages/RegisterPage.tsx | 75 +++++++ apps/frontend/src/app/pages/SettingsPage.tsx | 24 ++- .../src/app/routes/RequireAuth.test.tsx | 79 ++++++++ apps/frontend/src/app/routes/RequireAuth.tsx | 25 +++ apps/frontend/src/app/routes/router.tsx | 186 ++++++++++-------- apps/frontend/src/app/store/useAuthStore.ts | 81 ++++++++ .../src/features/auth/hooks/useLoginForm.ts | 37 ++++ .../features/auth/hooks/useRegisterForm.ts | 40 ++++ .../src/shared/components/layout/TopBar.tsx | 21 +- apps/frontend/src/shared/services/api/auth.ts | 25 +++ .../src/shared/services/api/client.test.ts | 79 ++++++++ .../src/shared/services/api/client.ts | 76 ++++++- .../frontend/src/shared/services/api/index.ts | 1 + .../frontend/src/shared/services/api/types.ts | 23 +++ 16 files changed, 755 insertions(+), 98 deletions(-) create mode 100644 apps/frontend/src/app/pages/LoginPage.tsx create mode 100644 apps/frontend/src/app/pages/RegisterPage.tsx create mode 100644 apps/frontend/src/app/routes/RequireAuth.test.tsx create mode 100644 apps/frontend/src/app/routes/RequireAuth.tsx create mode 100644 apps/frontend/src/app/store/useAuthStore.ts create mode 100644 apps/frontend/src/features/auth/hooks/useLoginForm.ts create mode 100644 apps/frontend/src/features/auth/hooks/useRegisterForm.ts create mode 100644 apps/frontend/src/shared/services/api/auth.ts create mode 100644 apps/frontend/src/shared/services/api/client.test.ts diff --git a/apps/frontend/src/app/App.tsx b/apps/frontend/src/app/App.tsx index 87b10996..cc9a4978 100644 --- a/apps/frontend/src/app/App.tsx +++ b/apps/frontend/src/app/App.tsx @@ -1,9 +1,17 @@ +import { useEffect } from 'react'; import { RouterProvider } from 'react-router-dom'; import { Toaster } from 'sonner'; import { router } from '@/app/routes/router'; +import { useAuthStore } from '@/app/store/useAuthStore'; import { RepositoryProvider } from '@/features/repositories/context/RepositoryProvider'; export function App() { + const bootstrap = useAuthStore((state) => state.bootstrap); + + useEffect(() => { + void bootstrap(); + }, [bootstrap]); + return ( diff --git a/apps/frontend/src/app/pages/LoginPage.tsx b/apps/frontend/src/app/pages/LoginPage.tsx new file mode 100644 index 00000000..f8c42752 --- /dev/null +++ b/apps/frontend/src/app/pages/LoginPage.tsx @@ -0,0 +1,73 @@ +import { Link } from 'react-router-dom'; +import { Hexagon, Loader2 } from 'lucide-react'; +import { useLoginForm } from '@/features/auth/hooks/useLoginForm'; + +export function LoginPage() { + const { email, setEmail, password, setPassword, submitting, error, submit } = useLoginForm(); + + return ( +
+
+
+
+ +
+

Sign in to PARTHA

+
+ +
+
+ + setEmail(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" + /> +
+
+ + setPassword(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" + /> +
+ + {error && ( +

+ {error} +

+ )} + + +
+ +

+ Don't have an account?{' '} + + Create one + +

+
+
+ ); +} diff --git a/apps/frontend/src/app/pages/RegisterPage.tsx b/apps/frontend/src/app/pages/RegisterPage.tsx new file mode 100644 index 00000000..6d6cced6 --- /dev/null +++ b/apps/frontend/src/app/pages/RegisterPage.tsx @@ -0,0 +1,75 @@ +import { Link } from 'react-router-dom'; +import { Hexagon, Loader2 } from 'lucide-react'; +import { PASSWORD_MIN_LENGTH, useRegisterForm } from '@/features/auth/hooks/useRegisterForm'; + +export function RegisterPage() { + const { email, setEmail, password, setPassword, submitting, error, submit } = useRegisterForm(); + + return ( +
+
+
+
+ +
+

Create your PARTHA account

+
+ +
+
+ + setEmail(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" + /> +
+
+ + setPassword(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" + /> +

At least {PASSWORD_MIN_LENGTH} characters.

+
+ + {error && ( +

+ {error} +

+ )} + + +
+ +

+ Already have an account?{' '} + + Sign in + +

+
+
+ ); +} diff --git a/apps/frontend/src/app/pages/SettingsPage.tsx b/apps/frontend/src/app/pages/SettingsPage.tsx index 9a108018..d500c5ea 100644 --- a/apps/frontend/src/app/pages/SettingsPage.tsx +++ b/apps/frontend/src/app/pages/SettingsPage.tsx @@ -1,10 +1,12 @@ import { PageHeader } from '@/shared/components/ui/PageHeader'; import { useSettings } from '@/features/settings/hooks/useSettings'; +import { useAuthStore } from '@/app/store/useAuthStore'; import { cn } from '@/shared/utils/cn'; export function SettingsPage() { const settings = useSettings(); const { tabs, activeTab, setActiveTab } = settings; + const user = useAuthStore((state) => state.user); const providers = [ ['openai', 'OpenAI'], ['anthropic', 'Anthropic'], @@ -39,28 +41,30 @@ export function SettingsPage() { {activeTab === 'General' && (
-

Profile

+

Account

- +
- +
diff --git a/apps/frontend/src/app/routes/RequireAuth.test.tsx b/apps/frontend/src/app/routes/RequireAuth.test.tsx new file mode 100644 index 00000000..69a146fb --- /dev/null +++ b/apps/frontend/src/app/routes/RequireAuth.test.tsx @@ -0,0 +1,79 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { createMemoryRouter, RouterProvider } from 'react-router-dom'; +import { RequireAuth } from './RequireAuth'; +import { useAuthStore } from '@/app/store/useAuthStore'; +import { authService } from '@/shared/services/api'; + +function renderGuarded(initialEntry: string) { + const router = createMemoryRouter( + [ + { path: '/login', element:
Login Page
}, + { + element: , + children: [{ path: '/', element:
Protected Home
}], + }, + ], + { initialEntries: [initialEntry] }, + ); + return render(); +} + +describe('RequireAuth', () => { + beforeEach(() => { + useAuthStore.setState({ status: 'initialising', accessToken: null, user: null }); + }); + + it('redirects anonymous users to /login', async () => { + useAuthStore.setState({ status: 'unauthenticated' }); + + renderGuarded('/'); + + expect(await screen.findByText('Login Page')).toBeInTheDocument(); + expect(screen.queryByText('Protected Home')).not.toBeInTheDocument(); + }); + + it('renders the protected route for authenticated users', async () => { + useAuthStore.setState({ + status: 'authenticated', + user: { id: 'u1', email: 'dev@example.com', createdAt: new Date().toISOString() }, + }); + + renderGuarded('/'); + + expect(await screen.findByText('Protected Home')).toBeInTheDocument(); + expect(screen.queryByText('Login Page')).not.toBeInTheDocument(); + }); + + it('shows a neutral loading state while initialising, not protected content or the login page', () => { + useAuthStore.setState({ status: 'initialising' }); + + renderGuarded('/'); + + expect(screen.queryByText('Protected Home')).not.toBeInTheDocument(); + expect(screen.queryByText('Login Page')).not.toBeInTheDocument(); + expect(screen.getByRole('status', { name: 'Loading session' })).toBeInTheDocument(); + }); + + it('logout clears the session and the guard redirects to /login', async () => { + vi.spyOn(authService, 'logout').mockResolvedValue(undefined); + useAuthStore.setState({ + status: 'authenticated', + accessToken: 'token-123', + user: { id: 'u1', email: 'dev@example.com', createdAt: new Date().toISOString() }, + }); + + renderGuarded('/'); + expect(await screen.findByText('Protected Home')).toBeInTheDocument(); + + await useAuthStore.getState().logout(); + + expect(authService.logout).toHaveBeenCalledTimes(1); + expect(useAuthStore.getState()).toMatchObject({ + status: 'unauthenticated', + accessToken: null, + user: null, + }); + expect(await screen.findByText('Login Page')).toBeInTheDocument(); + }); +}); diff --git a/apps/frontend/src/app/routes/RequireAuth.tsx b/apps/frontend/src/app/routes/RequireAuth.tsx new file mode 100644 index 00000000..d2cda1a2 --- /dev/null +++ b/apps/frontend/src/app/routes/RequireAuth.tsx @@ -0,0 +1,25 @@ +import { Navigate, Outlet, useLocation } from 'react-router-dom'; +import { useAuthStore } from '@/app/store/useAuthStore'; + +export function RequireAuth() { + const status = useAuthStore((state) => state.status); + const location = useLocation(); + + if (status === 'initialising') { + return ( +
+
+
+ ); + } + + if (status === 'unauthenticated') { + return ; + } + + return ; +} diff --git a/apps/frontend/src/app/routes/router.tsx b/apps/frontend/src/app/routes/router.tsx index 2b1500ad..6bacea9f 100644 --- a/apps/frontend/src/app/routes/router.tsx +++ b/apps/frontend/src/app/routes/router.tsx @@ -1,93 +1,113 @@ import { createBrowserRouter } from 'react-router-dom'; import { MainLayout } from '@/shared/components/layout/MainLayout'; +import { RequireAuth } from './RequireAuth'; export const router = createBrowserRouter([ { - element: , + path: '/login', + lazy: async () => { + const { LoginPage } = await import('@/app/pages/LoginPage'); + return { Component: LoginPage }; + }, + }, + { + path: '/register', + lazy: async () => { + const { RegisterPage } = await import('@/app/pages/RegisterPage'); + return { Component: RegisterPage }; + }, + }, + { + element: , children: [ { - path: '/', - lazy: async () => { - const { DashboardPage } = await import('@/app/pages/DashboardPage'); - return { Component: DashboardPage }; - }, - }, - { - path: '/repositories', - lazy: async () => { - const { RepositoriesPage } = await import('@/app/pages/RepositoriesPage'); - return { Component: RepositoriesPage }; - }, - }, - { - path: '/repositories/:id', - lazy: async () => { - const { RepositoryDetailPage } = await import('@/app/pages/RepositoryDetailPage'); - return { Component: RepositoryDetailPage }; - }, - }, - { - path: '/upload', - lazy: async () => { - const { UploadPage } = await import('@/app/pages/UploadPage'); - return { Component: UploadPage }; - }, - }, - { - path: '/analysis/:id', - lazy: async () => { - const { AnalysisPipelinePage } = await import('@/app/pages/AnalysisPipelinePage'); - return { Component: AnalysisPipelinePage }; - }, - }, - { - path: '/architecture', - lazy: async () => { - const { ArchitecturePage } = await import('@/app/pages/ArchitecturePage'); - return { Component: ArchitecturePage }; - }, - }, - { - path: '/dependencies', - lazy: async () => { - const { DependenciesPage } = await import('@/app/pages/DependenciesPage'); - return { Component: DependenciesPage }; - }, - }, - { - path: '/review', - lazy: async () => { - const { EngineeringReviewPage } = await import('@/app/pages/EngineeringReviewPage'); - return { Component: EngineeringReviewPage }; - }, - }, - { - path: '/ai-workspace', - lazy: async () => { - const { AIWorkspacePage } = await import('@/app/pages/AIWorkspacePage'); - return { Component: AIWorkspacePage }; - }, - }, - { - path: '/documentation', - lazy: async () => { - const { DocumentationPage } = await import('@/app/pages/DocumentationPage'); - return { Component: DocumentationPage }; - }, - }, - { - path: '/insights', - lazy: async () => { - const { InsightsPage } = await import('@/app/pages/InsightsPage'); - return { Component: InsightsPage }; - }, - }, - { - path: '/settings', - lazy: async () => { - const { SettingsPage } = await import('@/app/pages/SettingsPage'); - return { Component: SettingsPage }; - }, + element: , + children: [ + { + path: '/', + lazy: async () => { + const { DashboardPage } = await import('@/app/pages/DashboardPage'); + return { Component: DashboardPage }; + }, + }, + { + path: '/repositories', + lazy: async () => { + const { RepositoriesPage } = await import('@/app/pages/RepositoriesPage'); + return { Component: RepositoriesPage }; + }, + }, + { + path: '/repositories/:id', + lazy: async () => { + const { RepositoryDetailPage } = await import('@/app/pages/RepositoryDetailPage'); + return { Component: RepositoryDetailPage }; + }, + }, + { + path: '/upload', + lazy: async () => { + const { UploadPage } = await import('@/app/pages/UploadPage'); + return { Component: UploadPage }; + }, + }, + { + path: '/analysis/:id', + lazy: async () => { + const { AnalysisPipelinePage } = await import('@/app/pages/AnalysisPipelinePage'); + return { Component: AnalysisPipelinePage }; + }, + }, + { + path: '/architecture', + lazy: async () => { + const { ArchitecturePage } = await import('@/app/pages/ArchitecturePage'); + return { Component: ArchitecturePage }; + }, + }, + { + path: '/dependencies', + lazy: async () => { + const { DependenciesPage } = await import('@/app/pages/DependenciesPage'); + return { Component: DependenciesPage }; + }, + }, + { + path: '/review', + lazy: async () => { + const { EngineeringReviewPage } = await import('@/app/pages/EngineeringReviewPage'); + return { Component: EngineeringReviewPage }; + }, + }, + { + path: '/ai-workspace', + lazy: async () => { + const { AIWorkspacePage } = await import('@/app/pages/AIWorkspacePage'); + return { Component: AIWorkspacePage }; + }, + }, + { + path: '/documentation', + lazy: async () => { + const { DocumentationPage } = await import('@/app/pages/DocumentationPage'); + return { Component: DocumentationPage }; + }, + }, + { + path: '/insights', + lazy: async () => { + const { InsightsPage } = await import('@/app/pages/InsightsPage'); + return { Component: InsightsPage }; + }, + }, + { + path: '/settings', + lazy: async () => { + const { SettingsPage } = await import('@/app/pages/SettingsPage'); + return { Component: SettingsPage }; + }, + }, + ], }, ], }, diff --git a/apps/frontend/src/app/store/useAuthStore.ts b/apps/frontend/src/app/store/useAuthStore.ts new file mode 100644 index 00000000..b1d39ce7 --- /dev/null +++ b/apps/frontend/src/app/store/useAuthStore.ts @@ -0,0 +1,81 @@ +import { create } from 'zustand'; +import { authService, configureApiClient } from '@/shared/services/api'; +import type { UserResponse } from '@/shared/services/api/types'; + +export type AuthStatus = 'initialising' | 'authenticated' | 'unauthenticated'; + +interface AuthState { + status: AuthStatus; + accessToken: string | null; + user: UserResponse | null; + + /** Runs once at app start: silently refresh the session from the httpOnly + * cookie, then confirm identity via /auth/me. Never throws. */ + bootstrap: () => Promise; + login: (email: string, password: string) => Promise; + register: (email: string, password: string) => Promise; + /** Always clears local session state, even if the server call fails. */ + logout: () => Promise; + /** Silent refresh used by the API client's 401 interceptor. Never throws. */ + refreshSession: () => Promise; +} + +export const useAuthStore = create((set) => ({ + status: 'initialising', + accessToken: null, + user: null, + + async bootstrap() { + try { + const auth = await authService.refresh(); + set({ accessToken: auth.accessToken }); + const user = await authService.me(); + set({ user, status: 'authenticated' }); + } catch { + set({ accessToken: null, user: null, status: 'unauthenticated' }); + } + }, + + async login(email, password) { + const auth = await authService.login({ email, password }); + set({ accessToken: auth.accessToken, user: auth.user, status: 'authenticated' }); + }, + + async register(email, password) { + const auth = await authService.register({ email, password }); + set({ accessToken: auth.accessToken, user: auth.user, status: 'authenticated' }); + }, + + async logout() { + try { + await authService.logout(); + } catch { + // The user asked to sign out; a failed revocation call shouldn't leave + // them stuck signed in on the client. + } finally { + set({ accessToken: null, user: null, status: 'unauthenticated' }); + } + }, + + async refreshSession() { + try { + const auth = await authService.refresh(); + set({ accessToken: auth.accessToken, user: auth.user, status: 'authenticated' }); + return true; + } catch { + set({ accessToken: null, user: null, status: 'unauthenticated' }); + return false; + } + }, +})); + +// Wired here (not in client.ts) so the API client stays a generic HTTP layer +// with no knowledge of auth state, and this store is the only thing that +// knows both sides — avoids a circular import between the two modules. +configureApiClient({ + getAuthToken: () => useAuthStore.getState().accessToken, + refreshSession: () => useAuthStore.getState().refreshSession(), + onUnauthorized: () => { + useAuthStore.setState({ accessToken: null, user: null, status: 'unauthenticated' }); + }, +}); 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..19dfcc08 --- /dev/null +++ b/apps/frontend/src/features/auth/hooks/useLoginForm.ts @@ -0,0 +1,37 @@ +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'; + +interface LocationState { + from?: { pathname?: string }; +} + +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); + const state = location.state as LocationState | null; + navigate(state?.from?.pathname || '/', { replace: true }); + } catch (caught) { + setError(getErrorMessage(caught)); + } finally { + setSubmitting(false); + } + }; + + return { email, setEmail, password, setPassword, submitting, error, submit }; +} 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..a554e142 --- /dev/null +++ b/apps/frontend/src/features/auth/hooks/useRegisterForm.ts @@ -0,0 +1,40 @@ +import { useState, type FormEvent } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useAuthStore } from '@/app/store/useAuthStore'; +import { getErrorMessage } from '@/shared/services/api'; + +// 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 [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('/', { replace: true }); + } catch (caught) { + setError(getErrorMessage(caught)); + } finally { + setSubmitting(false); + } + }; + + return { email, setEmail, password, setPassword, submitting, error, submit }; +} diff --git a/apps/frontend/src/shared/components/layout/TopBar.tsx b/apps/frontend/src/shared/components/layout/TopBar.tsx index 721a5db1..ff30c41e 100644 --- a/apps/frontend/src/shared/components/layout/TopBar.tsx +++ b/apps/frontend/src/shared/components/layout/TopBar.tsx @@ -13,6 +13,7 @@ import { } from 'lucide-react'; import { cn } from '@/shared/utils/cn'; import { useAppStore } from '@/app/store/useAppStore'; +import { useAuthStore } from '@/app/store/useAuthStore'; import { useState, useRef, useEffect } from 'react'; import { useRepository } from '@/features/repositories/hooks/useRepository'; import type { FileTreeNode } from '@/shared/types'; @@ -28,6 +29,18 @@ export function TopBar() { setSearchOpen, } = useAppStore(); const { repositories, activeRepository, selectRepository } = useRepository(); + const logout = useAuthStore((state) => state.logout); + const [signingOut, setSigningOut] = useState(false); + + const handleSignOut = async () => { + setSigningOut(true); + try { + await logout(); + } finally { + setSigningOut(false); + navigate('/login', { replace: true }); + } + }; const [repoDropdownOpen, setRepoDropdownOpen] = useState(false); const [notifOpen, setNotifOpen] = useState(false); @@ -231,8 +244,12 @@ export function TopBar() { > Settings -
diff --git a/apps/frontend/src/shared/services/api/auth.ts b/apps/frontend/src/shared/services/api/auth.ts new file mode 100644 index 00000000..2bbd7c99 --- /dev/null +++ b/apps/frontend/src/shared/services/api/auth.ts @@ -0,0 +1,25 @@ +import { api } from './client'; +import type { RequestConfig } from './client'; +import type { AuthResponse, LoginRequest, RegisterRequest, UserResponse } from './types'; + +export const authService = { + register(request: RegisterRequest, config?: RequestConfig): Promise { + return api.post('/auth/register', request, config); + }, + + login(request: LoginRequest, config?: RequestConfig): Promise { + return api.post('/auth/login', request, config); + }, + + refresh(config?: RequestConfig): Promise { + return api.post('/auth/refresh', undefined, config); + }, + + logout(config?: RequestConfig): Promise { + return api.post('/auth/logout', undefined, config); + }, + + me(config?: RequestConfig): Promise { + return api.get('/auth/me', config); + }, +}; diff --git a/apps/frontend/src/shared/services/api/client.test.ts b/apps/frontend/src/shared/services/api/client.test.ts new file mode 100644 index 00000000..817204e7 --- /dev/null +++ b/apps/frontend/src/shared/services/api/client.test.ts @@ -0,0 +1,79 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { api, configureApiClient, getApiConfig } from './client'; +import { ApiError } from './errors'; + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }); +} + +describe('api client 401 handling', () => { + const baseConfig = getApiConfig(); + + beforeEach(() => { + configureApiClient({ ...baseConfig, getAuthToken: () => 'stale-token', refreshSession: undefined, onUnauthorized: undefined }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + configureApiClient(baseConfig); + }); + + it('shares one in-flight refresh across concurrent 401s and retries each request exactly once', async () => { + let authorized = false; + const refreshSession = vi.fn(async () => { + await new Promise((resolve) => setTimeout(resolve, 20)); + authorized = true; + return true; + }); + const onUnauthorized = vi.fn(); + + const fetchMock = vi.fn(async () => + authorized ? jsonResponse(200, { ok: true }) : jsonResponse(401, { code: 'unauthorized', message: 'expired' }), + ); + vi.stubGlobal('fetch', fetchMock); + configureApiClient({ refreshSession, onUnauthorized }); + + const results = await Promise.all([ + api.get('/repositories'), + api.get('/repositories'), + api.get('/repositories'), + ]); + + expect(refreshSession).toHaveBeenCalledTimes(1); + expect(onUnauthorized).not.toHaveBeenCalled(); + results.forEach((result) => expect(result).toEqual({ ok: true })); + }); + + it('gives up after one failed refresh instead of retrying indefinitely', async () => { + const refreshSession = vi.fn(async () => false); + const onUnauthorized = vi.fn(); + const fetchMock = vi.fn(async () => jsonResponse(401, { code: 'unauthorized', message: 'expired' })); + vi.stubGlobal('fetch', fetchMock); + configureApiClient({ refreshSession, onUnauthorized }); + + await expect(api.get('/repositories')).rejects.toBeInstanceOf(ApiError); + + expect(refreshSession).toHaveBeenCalledTimes(1); + expect(onUnauthorized).toHaveBeenCalledTimes(1); + // The refresh failed, so there's nothing worth retrying with — exactly + // the one original attempt, no retry loop. + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('never refreshes for the auth endpoints themselves', async () => { + const refreshSession = vi.fn(async () => true); + const onUnauthorized = vi.fn(); + const fetchMock = vi.fn(async () => jsonResponse(401, { code: 'unauthorized', message: 'bad credentials' })); + vi.stubGlobal('fetch', fetchMock); + configureApiClient({ refreshSession, onUnauthorized }); + + await expect(api.post('/auth/login', { email: 'a@b.com', password: 'x' })).rejects.toBeInstanceOf(ApiError); + + expect(refreshSession).not.toHaveBeenCalled(); + expect(onUnauthorized).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/frontend/src/shared/services/api/client.ts b/apps/frontend/src/shared/services/api/client.ts index f07e65ed..45cfd725 100644 --- a/apps/frontend/src/shared/services/api/client.ts +++ b/apps/frontend/src/shared/services/api/client.ts @@ -18,6 +18,12 @@ export interface ApiClientConfig { defaultRetries: number; defaultRetryDelay: number; getAuthToken?: () => string | null; + /** Attempts a silent session refresh; resolves true if a fresh access token + * is now available and the triggering request should be retried once. */ + refreshSession?: () => Promise; + /** Called when a 401 could not be resolved by a refresh (or refresh isn't + * configured) — the terminal "give up" signal, e.g. to clear state and + * redirect to /login. */ onUnauthorized?: () => void; } @@ -38,6 +44,46 @@ export function getApiConfig(): ApiClientConfig { return clientConfig; } +// The endpoints below must never trigger a silent refresh-and-retry: retrying +// a failed login/register is nonsensical, and refreshing off the back of a +// failed /auth/refresh or /auth/logout would recurse into the same endpoint. +const NO_REFRESH_ENDPOINTS = ['/auth/login', '/auth/register', '/auth/refresh', '/auth/logout']; + +function isNoRefreshEndpoint(endpoint: string): boolean { + return NO_REFRESH_ENDPOINTS.some((path) => endpoint === path || endpoint.startsWith(`${path}?`)); +} + +// Several requests can each hit a 401 for the same expired session around the +// same time; without this, each would fire its own /auth/refresh, and the +// backend's rotating refresh tokens mean only the first would succeed — the +// rest would revoke the whole session as replay. Sharing one in-flight +// promise makes every concurrent 401 await the same single refresh attempt. +let inFlightRefresh: Promise | null = null; + +function refreshSessionOnce(): Promise { + if (!clientConfig.refreshSession) return Promise.resolve(false); + if (!inFlightRefresh) { + inFlightRefresh = clientConfig + .refreshSession() + .catch(() => false) + .finally(() => { + inFlightRefresh = null; + }); + } + return inFlightRefresh; +} + +/** Resolves true (and the caller should retry once) if this was an + * unauthorized response on a refreshable endpoint and the shared refresh + * succeeded; otherwise fires onUnauthorized and resolves false. */ +async function tryRecoverFromUnauthorized(endpoint: string, isRetry: boolean): Promise { + if (!isRetry && !isNoRefreshEndpoint(endpoint) && (await refreshSessionOnce())) { + return true; + } + clientConfig.onUnauthorized?.(); + return false; +} + async function request( method: HttpMethod, endpoint: string, @@ -75,6 +121,7 @@ async function executeRequest( body: unknown, config: RequestConfig | undefined, timeout: number, + isRetry = false, ): Promise { const url = `${clientConfig.baseUrl}${endpoint}`; const controller = new AbortController(); @@ -102,6 +149,7 @@ async function executeRequest( const response = await fetch(url, { method, headers, + credentials: 'include', body: body instanceof FormData ? body : body ? JSON.stringify(body) : undefined, signal: controller.signal, }); @@ -115,7 +163,11 @@ async function executeRequest( } const error = new ApiError(response.status, response.statusText, responseBody, endpoint); - if (error.isUnauthorized) clientConfig.onUnauthorized?.(); + if (error.isUnauthorized) { + if (await tryRecoverFromUnauthorized(endpoint, isRetry)) { + return executeRequest(method, endpoint, body, config, timeout, true); + } + } throw error; } @@ -143,6 +195,7 @@ export async function uploadFile( file: File, fields?: Record, config?: RequestConfig, + isRetry = false, ): Promise { const url = `${clientConfig.baseUrl}${endpoint}`; const controller = new AbortController(); @@ -174,6 +227,7 @@ export async function uploadFile( const response = await fetch(url, { method: 'POST', headers, + credentials: 'include', body: formData, signal: controller.signal, }); @@ -186,7 +240,14 @@ export async function uploadFile( return await response.json() as T; } catch (error) { - if (error instanceof ApiError) throw error; + if (error instanceof ApiError) { + if (error.isUnauthorized && (await tryRecoverFromUnauthorized(endpoint, isRetry))) { + clearTimeout(timeoutId); + if (signal && abortListener) signal.removeEventListener('abort', abortListener); + return uploadFile(endpoint, file, fields, config, true); + } + throw error; + } if (controller.signal.aborted) { if (signal?.aborted) throw new CancelledError(endpoint); throw new TimeoutError(endpoint, timeout); @@ -208,6 +269,7 @@ async function uploadWithProgress( return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open('POST', url); + xhr.withCredentials = true; Object.entries(headers).forEach(([key, value]) => xhr.setRequestHeader(key, value)); xhr.upload.onprogress = (event) => { @@ -267,6 +329,7 @@ export async function streamRequest( const response = await fetch(url, { method: 'POST', headers, + credentials: 'include', body: JSON.stringify(body), signal: controller.signal, }); @@ -274,7 +337,14 @@ export async function streamRequest( if (!response.ok) { let responseBody: unknown; try { responseBody = await response.json(); } catch { responseBody = null; } - throw new ApiError(response.status, response.statusText, responseBody, endpoint); + const error = new ApiError(response.status, response.statusText, responseBody, endpoint); + // No refresh-and-retry here: the stream hasn't started, so there's + // nothing partially consumed to protect, but replaying a POST with a + // streaming response is a different risk profile than a plain retry — + // out of scope for this pass. Still surface the terminal signal so a + // truly expired session redirects to /login like every other request. + if (error.isUnauthorized) clientConfig.onUnauthorized?.(); + throw error; } const reader = response.body?.getReader(); diff --git a/apps/frontend/src/shared/services/api/index.ts b/apps/frontend/src/shared/services/api/index.ts index 110e97a6..51f2efd8 100644 --- a/apps/frontend/src/shared/services/api/index.ts +++ b/apps/frontend/src/shared/services/api/index.ts @@ -3,6 +3,7 @@ export type { RequestConfig, ApiClientConfig, HttpMethod } from './client'; export { ApiError, NetworkError, TimeoutError, CancelledError, isApiError, isNetworkError, isTimeoutError, isCancelledError, getErrorMessage } from './errors'; +export { authService } from './auth'; export { repositoryService } from './repositories'; export { uploadService } from './upload'; export { analysisService } from './analysis'; diff --git a/apps/frontend/src/shared/services/api/types.ts b/apps/frontend/src/shared/services/api/types.ts index 70e27d81..e73f89c6 100644 --- a/apps/frontend/src/shared/services/api/types.ts +++ b/apps/frontend/src/shared/services/api/types.ts @@ -196,6 +196,29 @@ export interface GenerateDocResponse { generatedAt: string; } +// Auth +export interface UserResponse { + id: string; + email: string; + createdAt: string; +} + +export interface AuthResponse { + accessToken: string; + tokenType: 'bearer'; + user: UserResponse; +} + +export interface LoginRequest { + email: string; + password: string; +} + +export interface RegisterRequest { + email: string; + password: string; +} + // Export export type ExportFormat = 'json' | 'markdown' | 'html' | 'pdf'; export type ExportTarget = 'review' | 'documentation' | 'architecture' | 'dependencies'; From 3ec2e13eff5d7dc2f8e4ab6691a5d04f2ecaa5aa Mon Sep 17 00:00:00 2001 From: Shaurya K Sharma Date: Sun, 12 Jul 2026 21:40:10 +0100 Subject: [PATCH 040/347] fix(frontend): single-flight bootstrap refresh, gate repository fetch on auth, stream 401 retry (E1.4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two corrective fixes to #64 before review approval, both pre-#63 latent bugs the anonymous fallback currently masks: - bootstrap() called authService.refresh() directly, bypassing the API client's single-flight mutex entirely. Once #63 removes the anonymous fallback, a request firing during bootstrap (e.g. the repository list) would 401 and trigger its own independent /auth/refresh via the interceptor, racing bootstrap's own call — two concurrent refreshes against one rotating refresh-token cookie trip the backend's strict replay-family revocation and destroy the session. client.ts now exports the mutex itself (requestSharedRefresh) as the one sanctioned entry point; bootstrap and the 401 interceptor both go through it, so there is no path left that calls /auth/refresh outside that mutex. - RepositoryProvider was mounted above the router in App.tsx, fetching immediately on app load regardless of auth state — today it silently succeeds against the anonymous/seed-user fallback. Moved it inside MainLayout, which only ever renders inside RequireAuth's authenticated branch, so it structurally cannot mount (let alone fetch) while initialising or unauthenticated. useAuthStore now also clears useAppStore's repository/active-repository state synchronously on every transition away from authenticated (logout, failed refresh, unrecoverable 401) and at the moment a different login/register succeeds, so a second user can never observe the previous user's in-memory repositories, even briefly. - streamRequest's pre-stream 401 previously went straight to onUnauthorized with no recovery attempt. A 401 here means the backend rejected the request before any event streamed, so it's exactly as safe to recover as any other request: it now shares the same tryRecoverFromUnauthorized path as executeRequest — one silent refresh, retry opening the stream once, terminal onUnauthorized only if that fails. Backend auth semantics, the anonymous fallback, and rate-limit identity are untouched. --- apps/frontend/src/app/App.tsx | 5 +- .../src/app/routes/RequireAuth.test.tsx | 86 ++++++++++- .../src/app/store/useAuthStore.test.ts | 142 ++++++++++++++++++ apps/frontend/src/app/store/useAuthStore.ts | 43 +++++- .../shared/components/layout/MainLayout.tsx | 33 ++-- .../src/shared/services/api/client.ts | 29 +++- .../frontend/src/shared/services/api/index.ts | 2 +- 7 files changed, 304 insertions(+), 36 deletions(-) create mode 100644 apps/frontend/src/app/store/useAuthStore.test.ts diff --git a/apps/frontend/src/app/App.tsx b/apps/frontend/src/app/App.tsx index cc9a4978..9f75efb2 100644 --- a/apps/frontend/src/app/App.tsx +++ b/apps/frontend/src/app/App.tsx @@ -3,7 +3,6 @@ import { RouterProvider } from 'react-router-dom'; import { Toaster } from 'sonner'; import { router } from '@/app/routes/router'; import { useAuthStore } from '@/app/store/useAuthStore'; -import { RepositoryProvider } from '@/features/repositories/context/RepositoryProvider'; export function App() { const bootstrap = useAuthStore((state) => state.bootstrap); @@ -13,7 +12,7 @@ export function App() { }, [bootstrap]); return ( - + <> - + ); } diff --git a/apps/frontend/src/app/routes/RequireAuth.test.tsx b/apps/frontend/src/app/routes/RequireAuth.test.tsx index 69a146fb..8e57cc33 100644 --- a/apps/frontend/src/app/routes/RequireAuth.test.tsx +++ b/apps/frontend/src/app/routes/RequireAuth.test.tsx @@ -1,9 +1,11 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; import { createMemoryRouter, RouterProvider } from 'react-router-dom'; import { RequireAuth } from './RequireAuth'; import { useAuthStore } from '@/app/store/useAuthStore'; -import { authService } from '@/shared/services/api'; +import { authService, repositoryService } from '@/shared/services/api'; +import { RepositoryProvider } from '@/features/repositories/context/RepositoryProvider'; +import { useRepository } from '@/features/repositories/hooks/useRepository'; function renderGuarded(initialEntry: string) { const router = createMemoryRouter( @@ -19,11 +21,46 @@ function renderGuarded(initialEntry: string) { return render(); } +// Stands in for MainLayout, which wraps its authenticated Outlet with the +// real RepositoryProvider — mirrors that structure without pulling in +// Sidebar/TopBar's unrelated chrome and dependencies. +function RepoProbe() { + const { repositories, loading } = useRepository(); + return
Repos: {repositories.length}{loading ? ' (loading)' : ''}
; +} + +function renderGuardedWithRepositoryProvider(initialEntry: string) { + const router = createMemoryRouter( + [ + { path: '/login', element:
Login Page
}, + { + element: , + children: [ + { + path: '/', + element: ( + + + + ), + }, + ], + }, + ], + { initialEntries: [initialEntry] }, + ); + return render(); +} + describe('RequireAuth', () => { beforeEach(() => { useAuthStore.setState({ status: 'initialising', accessToken: null, user: null }); }); + afterEach(() => { + vi.restoreAllMocks(); + }); + it('redirects anonymous users to /login', async () => { useAuthStore.setState({ status: 'unauthenticated' }); @@ -77,3 +114,46 @@ describe('RequireAuth', () => { expect(await screen.findByText('Login Page')).toBeInTheDocument(); }); }); + +describe('RepositoryProvider gating (mirrors MainLayout mounting it inside RequireAuth)', () => { + beforeEach(() => { + useAuthStore.setState({ status: 'initialising', accessToken: null, user: null }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('does not fetch repositories while the guard is unauthenticated', async () => { + const listSpy = vi.spyOn(repositoryService, 'list').mockResolvedValue({ data: [], total: 0 }); + useAuthStore.setState({ status: 'unauthenticated' }); + + renderGuardedWithRepositoryProvider('/'); + + expect(await screen.findByText('Login Page')).toBeInTheDocument(); + expect(listSpy).not.toHaveBeenCalled(); + }); + + it('does not fetch repositories while the guard is still initialising', async () => { + const listSpy = vi.spyOn(repositoryService, 'list').mockResolvedValue({ data: [], total: 0 }); + useAuthStore.setState({ status: 'initialising' }); + + renderGuardedWithRepositoryProvider('/'); + + await screen.findByRole('status', { name: 'Loading session' }); + expect(listSpy).not.toHaveBeenCalled(); + }); + + it('fetches repositories once the guard confirms an authenticated session', async () => { + const listSpy = vi.spyOn(repositoryService, 'list').mockResolvedValue({ data: [], total: 0 }); + useAuthStore.setState({ + status: 'authenticated', + user: { id: 'u1', email: 'dev@example.com', createdAt: new Date().toISOString() }, + }); + + renderGuardedWithRepositoryProvider('/'); + + await waitFor(() => expect(listSpy).toHaveBeenCalledTimes(1)); + expect(await screen.findByText('Repos: 0')).toBeInTheDocument(); + }); +}); diff --git a/apps/frontend/src/app/store/useAuthStore.test.ts b/apps/frontend/src/app/store/useAuthStore.test.ts new file mode 100644 index 00000000..632245b2 --- /dev/null +++ b/apps/frontend/src/app/store/useAuthStore.test.ts @@ -0,0 +1,142 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useAuthStore } from './useAuthStore'; +import { useAppStore } from './useAppStore'; +import { authService, api, getApiConfig } from '@/shared/services/api'; +import type { Repository } from '@/shared/types'; + +function fakeRepository(id: string): Repository { + return { + id, + name: id, + source: 'upload', + size: 0, + fileCount: 0, + status: 'completed', + dataSource: 'real', + analysisStage: 'completed', + analysisProgress: 100, + uploadedAt: new Date().toISOString(), + meta: null, + fileTree: [], + }; +} + +function fakeUser(id: string, email: string) { + return { id, email, createdAt: new Date().toISOString() }; +} + +describe('useAuthStore', () => { + beforeEach(() => { + useAuthStore.setState({ status: 'initialising', accessToken: null, user: null }); + useAppStore.setState({ repositories: [], activeRepositoryId: null }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('bootstrap refresh sharing', () => { + it('bootstrap and a simultaneous protected-request 401 share exactly one /auth/refresh call', async () => { + let refreshCount = 0; + let authorized = false; + + vi.spyOn(authService, 'refresh').mockImplementation(async () => { + refreshCount += 1; + // Long enough that both the bootstrap call and the concurrent + // protected request are guaranteed to have already reached the + // shared mutex before either refresh resolves. + await new Promise((resolve) => setTimeout(resolve, 20)); + authorized = true; + return { accessToken: 'fresh-token', tokenType: 'bearer' as const, user: fakeUser('u1', 'a@example.com') }; + }); + vi.spyOn(authService, 'me').mockResolvedValue(fakeUser('u1', 'a@example.com')); + + // Stands in for RepositoryProvider's fetch racing bootstrap: 401 until + // the shared refresh completes, then succeeds on retry. + vi.stubGlobal( + 'fetch', + vi.fn(async () => { + const body = authorized ? { data: [], total: 0 } : { code: 'unauthorized', message: 'expired' }; + return new Response(JSON.stringify(body), { + status: authorized ? 200 : 401, + headers: { 'content-type': 'application/json' }, + }); + }), + ); + + const bootstrapPromise = useAuthStore.getState().bootstrap(); + const protectedRequestPromise = api.get('/repositories'); + + await Promise.all([bootstrapPromise, protectedRequestPromise]); + + expect(refreshCount).toBe(1); + expect(useAuthStore.getState().status).toBe('authenticated'); + expect(useAuthStore.getState().user?.id).toBe('u1'); + + vi.unstubAllGlobals(); + }); + + it('bootstrap does not fetch repositories itself before authentication completes', async () => { + vi.spyOn(authService, 'refresh').mockResolvedValue({ + accessToken: 'fresh-token', + tokenType: 'bearer', + user: fakeUser('u1', 'a@example.com'), + }); + vi.spyOn(authService, 'me').mockResolvedValue(fakeUser('u1', 'a@example.com')); + + expect(useAppStore.getState().repositories).toEqual([]); + + await useAuthStore.getState().bootstrap(); + + // bootstrap's own contract is refresh -> /auth/me only; it must never + // reach into repository data itself. (RepositoryProvider — mounted only + // once RequireAuth observes 'authenticated' — owns that fetch.) + expect(useAppStore.getState().repositories).toEqual([]); + expect(useAuthStore.getState().status).toBe('authenticated'); + }); + }); + + describe('cross-user repository isolation', () => { + it('clears repository state on logout so a subsequent user never sees stale data', async () => { + vi.spyOn(authService, 'logout').mockResolvedValue(undefined); + useAppStore.setState({ repositories: [fakeRepository('repo-a')], activeRepositoryId: 'repo-a' }); + useAuthStore.setState({ status: 'authenticated', accessToken: 'a-token', user: fakeUser('userA', 'a@example.com') }); + + await useAuthStore.getState().logout(); + + expect(useAppStore.getState().repositories).toEqual([]); + expect(useAppStore.getState().activeRepositoryId).toBeNull(); + expect(useAuthStore.getState().status).toBe('unauthenticated'); + }); + + it('clears stale repository state at the moment a different user logs in', async () => { + useAppStore.setState({ repositories: [fakeRepository('repo-a')], activeRepositoryId: 'repo-a' }); + vi.spyOn(authService, 'login').mockResolvedValue({ + accessToken: 'b-token', + tokenType: 'bearer', + user: fakeUser('userB', 'b@example.com'), + }); + + await useAuthStore.getState().login('b@example.com', 'password123'); + + expect(useAppStore.getState().repositories).toEqual([]); + expect(useAppStore.getState().activeRepositoryId).toBeNull(); + expect(useAuthStore.getState().user?.id).toBe('userB'); + expect(useAuthStore.getState().status).toBe('authenticated'); + }); + + it('clears repository state when the API client reports an unrecoverable 401', () => { + useAppStore.setState({ repositories: [fakeRepository('repo-a')], activeRepositoryId: 'repo-a' }); + useAuthStore.setState({ status: 'authenticated', accessToken: 'a-token', user: fakeUser('userA', 'a@example.com') }); + + // Calls the real onUnauthorized callback useAuthStore registered via + // configureApiClient — the same one the API client's interceptor + // invokes on a refresh that can't recover a 401. + getApiConfig().onUnauthorized?.(); + + expect(useAppStore.getState().repositories).toEqual([]); + expect(useAppStore.getState().activeRepositoryId).toBeNull(); + expect(useAuthStore.getState().status).toBe('unauthenticated'); + }); + }); +}); diff --git a/apps/frontend/src/app/store/useAuthStore.ts b/apps/frontend/src/app/store/useAuthStore.ts index b1d39ce7..5038b999 100644 --- a/apps/frontend/src/app/store/useAuthStore.ts +++ b/apps/frontend/src/app/store/useAuthStore.ts @@ -1,6 +1,7 @@ import { create } from 'zustand'; -import { authService, configureApiClient } from '@/shared/services/api'; +import { authService, configureApiClient, requestSharedRefresh } from '@/shared/services/api'; import type { UserResponse } from '@/shared/services/api/types'; +import { useAppStore } from './useAppStore'; export type AuthStatus = 'initialising' | 'authenticated' | 'unauthenticated'; @@ -16,7 +17,12 @@ interface AuthState { register: (email: string, password: string) => Promise; /** Always clears local session state, even if the server call fails. */ logout: () => Promise; - /** Silent refresh used by the API client's 401 interceptor. Never throws. */ + /** The refresh primitive. Only ever called through requestSharedRefresh's + * single-flight mutex (wired below as the client's refreshSession hook) — + * nothing else may call this directly, so every refresh in the app, + * bootstrap included, goes through that one shared operation and can never + * race a concurrent request's 401 recovery into two independent + * /auth/refresh calls. */ refreshSession: () => Promise; } @@ -26,23 +32,28 @@ export const useAuthStore = create((set) => ({ user: null, async bootstrap() { + const refreshed = await requestSharedRefresh(); + if (!refreshed) { + clearAuthenticatedState(); + return; + } try { - const auth = await authService.refresh(); - set({ accessToken: auth.accessToken }); const user = await authService.me(); set({ user, status: 'authenticated' }); } catch { - set({ accessToken: null, user: null, status: 'unauthenticated' }); + clearAuthenticatedState(); } }, async login(email, password) { const auth = await authService.login({ email, password }); + clearRepositoryState(); set({ accessToken: auth.accessToken, user: auth.user, status: 'authenticated' }); }, async register(email, password) { const auth = await authService.register({ email, password }); + clearRepositoryState(); set({ accessToken: auth.accessToken, user: auth.user, status: 'authenticated' }); }, @@ -53,7 +64,7 @@ export const useAuthStore = create((set) => ({ // The user asked to sign out; a failed revocation call shouldn't leave // them stuck signed in on the client. } finally { - set({ accessToken: null, user: null, status: 'unauthenticated' }); + clearAuthenticatedState(); } }, @@ -63,12 +74,28 @@ export const useAuthStore = create((set) => ({ set({ accessToken: auth.accessToken, user: auth.user, status: 'authenticated' }); return true; } catch { - set({ accessToken: null, user: null, status: 'unauthenticated' }); + clearAuthenticatedState(); return false; } }, })); +// Repository state lives in the separate, unauthenticated-by-default +// useAppStore, so it survives independently of who's signed in unless we +// clear it ourselves. Cleared here — not in RepositoryProvider — so the +// clearing always happens in the same tick as the auth transition, before +// any component can re-render with a newly (or no longer) authenticated +// status and observe the previous user's repositories, even briefly. +function clearRepositoryState() { + useAppStore.getState().setRepositories([]); + useAppStore.getState().setActiveRepositoryId(null); +} + +function clearAuthenticatedState() { + useAuthStore.setState({ accessToken: null, user: null, status: 'unauthenticated' }); + clearRepositoryState(); +} + // Wired here (not in client.ts) so the API client stays a generic HTTP layer // with no knowledge of auth state, and this store is the only thing that // knows both sides — avoids a circular import between the two modules. @@ -76,6 +103,6 @@ configureApiClient({ getAuthToken: () => useAuthStore.getState().accessToken, refreshSession: () => useAuthStore.getState().refreshSession(), onUnauthorized: () => { - useAuthStore.setState({ accessToken: null, user: null, status: 'unauthenticated' }); + clearAuthenticatedState(); }, }); diff --git a/apps/frontend/src/shared/components/layout/MainLayout.tsx b/apps/frontend/src/shared/components/layout/MainLayout.tsx index da94879c..4332cf71 100644 --- a/apps/frontend/src/shared/components/layout/MainLayout.tsx +++ b/apps/frontend/src/shared/components/layout/MainLayout.tsx @@ -5,26 +5,33 @@ import { useAppStore } from '@/app/store/useAppStore'; import { useToastNotifications } from '@/shared/hooks/useToastNotifications'; import { useKeyboardShortcuts } from '@/shared/hooks/useKeyboardShortcuts'; import { cn } from '@/shared/utils/cn'; +import { RepositoryProvider } from '@/features/repositories/context/RepositoryProvider'; +// MainLayout only ever renders inside RequireAuth's authenticated branch, so +// mounting the repository fetch here (rather than in App.tsx, above the +// router) guarantees it can't fire while the session is still initialising +// or unauthenticated — RequireAuth simply never mounts this tree until then. export function MainLayout() { const { sidebarCollapsed } = useAppStore(); useToastNotifications(); useKeyboardShortcuts(); return ( -
- -
- -
- -
+ +
+ +
+ +
+ +
+
-
+ ); } diff --git a/apps/frontend/src/shared/services/api/client.ts b/apps/frontend/src/shared/services/api/client.ts index 45cfd725..b9e2d0d0 100644 --- a/apps/frontend/src/shared/services/api/client.ts +++ b/apps/frontend/src/shared/services/api/client.ts @@ -58,9 +58,16 @@ function isNoRefreshEndpoint(endpoint: string): boolean { // backend's rotating refresh tokens mean only the first would succeed — the // rest would revoke the whole session as replay. Sharing one in-flight // promise makes every concurrent 401 await the same single refresh attempt. +// +// This is exported and is the ONLY sanctioned way to trigger a refresh: +// callers outside this module (e.g. the auth store's bootstrap, which also +// needs a refresh before anything else can run) must call this instead of +// invoking their refresh primitive directly, or bootstrap's own refresh and +// a concurrent request's 401 recovery could each fire an independent +// /auth/refresh and race each other into the backend's replay-revocation. let inFlightRefresh: Promise | null = null; -function refreshSessionOnce(): Promise { +export function requestSharedRefresh(): Promise { if (!clientConfig.refreshSession) return Promise.resolve(false); if (!inFlightRefresh) { inFlightRefresh = clientConfig @@ -77,7 +84,7 @@ function refreshSessionOnce(): Promise { * unauthorized response on a refreshable endpoint and the shared refresh * succeeded; otherwise fires onUnauthorized and resolves false. */ async function tryRecoverFromUnauthorized(endpoint: string, isRetry: boolean): Promise { - if (!isRetry && !isNoRefreshEndpoint(endpoint) && (await refreshSessionOnce())) { + if (!isRetry && !isNoRefreshEndpoint(endpoint) && (await requestSharedRefresh())) { return true; } clientConfig.onUnauthorized?.(); @@ -305,6 +312,7 @@ export async function streamRequest( body: unknown, onChunk: (chunk: string) => void, config?: RequestConfig, + isRetry = false, ): Promise { const url = `${clientConfig.baseUrl}${endpoint}`; const controller = new AbortController(); @@ -338,12 +346,17 @@ export async function streamRequest( let responseBody: unknown; try { responseBody = await response.json(); } catch { responseBody = null; } const error = new ApiError(response.status, response.statusText, responseBody, endpoint); - // No refresh-and-retry here: the stream hasn't started, so there's - // nothing partially consumed to protect, but replaying a POST with a - // streaming response is a different risk profile than a plain retry — - // out of scope for this pass. Still surface the terminal signal so a - // truly expired session redirects to /login like every other request. - if (error.isUnauthorized) clientConfig.onUnauthorized?.(); + if (error.isUnauthorized) { + // A 401 here means the backend rejected the request before any + // event ever streamed (the SSE body only starts after auth passes), + // so retrying is exactly as safe as retrying any other request — + // same recover-once-then-give-up contract as executeRequest. + if (await tryRecoverFromUnauthorized(endpoint, isRetry)) { + clearTimeout(timeoutId); + if (signal && abortListener) signal.removeEventListener('abort', abortListener); + return streamRequest(endpoint, body, onChunk, config, true); + } + } throw error; } diff --git a/apps/frontend/src/shared/services/api/index.ts b/apps/frontend/src/shared/services/api/index.ts index 51f2efd8..2e39f56c 100644 --- a/apps/frontend/src/shared/services/api/index.ts +++ b/apps/frontend/src/shared/services/api/index.ts @@ -1,4 +1,4 @@ -export { api, configureApiClient, getApiConfig, uploadFile, streamRequest } from './client'; +export { api, configureApiClient, getApiConfig, uploadFile, streamRequest, requestSharedRefresh } from './client'; export type { RequestConfig, ApiClientConfig, HttpMethod } from './client'; export { ApiError, NetworkError, TimeoutError, CancelledError, isApiError, isNetworkError, isTimeoutError, isCancelledError, getErrorMessage } from './errors'; From 268dfc0ab02ed549600772d803604a851cbb3186 Mon Sep 17 00:00:00 2001 From: Shaurya K Sharma Date: Sun, 12 Jul 2026 21:57:20 +0100 Subject: [PATCH 041/347] fix(frontend): single-flight bootstrap, full session-scoped state clearing, guest-auth isolation, streaming 401, redirect parity (E1.4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses Parth's formal review on PR #79 (submitted against c950712, before the prior corrective commit) — five issues, all verified against the code before fixing: 1. bootstrap() called authService.refresh() directly. requestSharedRefresh() already deduped the refresh itself, but bootstrap() as a whole wasn't idempotent: under React.StrictMode (kept, not removed) a duplicate mount effect still raced ahead to two independent /auth/me calls. bootstrap() is now wrapped in its own single-flight promise, so concurrent callers share one run end to end and land on the same final state. 2. Session-end clearing only reset repositories. useAppStore also carries running-analysis state and notifications, both user-scoped; clearing now goes through cancelAnalysis()/clearNotifications() too, on every transition away from authenticated and at the moment a different user's login/register succeeds. RepositoryProvider's fetch effect also now depends on the authenticated user's id (not just mount), so a second user is guaranteed a fresh fetch even in a hypothetical no-unmount path. 3. A failed /auth/login or /auth/register call was still reaching the global onUnauthorized handler, clearing any OTHER already-authenticated session (e.g. a second tab). Split "must not attempt a refresh" from "signals the session is dead": onUnauthorized no longer fires for those two guest-only endpoints, only for a genuinely failed /auth/refresh or /auth/logout. My own client.test.ts had a test asserting the old (buggy) behavior as correct — fixed together with the underlying bug. 4. streamRequest's pre-stream 401 now shares tryRecoverFromUnauthorized with executeRequest instead of going straight to onUnauthorized: one refresh, one retry, never mid-stream (the branch only runs before the reader is ever obtained). 5. Register always navigated to '/', dropping the intended destination RequireAuth captured, and neither auth page forwarded it through the login<->register link. Both now resolve through one shared resolveRedirectTarget() (pathname + search + hash), and the link between them forwards the location state. 11 new/changed tests (38 total, up from 27) cover: bootstrap concurrency and post-bootstrap session usability; analysis/notification clearing; RepositoryProvider refetch on identity change without unmount; a failed login/register leaving an existing session untouched (exercised through the real client interceptor, not a mock); streamRequest recovery and give-up paths; and the full redirect round-trip through real Login/Register pages. Backend auth semantics, the anonymous fallback, and rate-limit identity remain untouched. --- apps/frontend/src/app/pages/LoginPage.tsx | 4 +- apps/frontend/src/app/pages/RegisterPage.tsx | 4 +- .../src/app/routes/RequireAuth.test.tsx | 32 +++++++ .../src/app/store/useAuthStore.test.ts | 89 +++++++++++++++++- apps/frontend/src/app/store/useAuthStore.ts | 66 +++++++++----- .../src/features/auth/authRedirect.test.tsx | 63 +++++++++++++ .../src/features/auth/authRedirect.ts | 16 ++++ .../src/features/auth/hooks/useLoginForm.ts | 12 +-- .../features/auth/hooks/useRegisterForm.ts | 10 +- .../context/RepositoryProvider.tsx | 9 +- .../src/shared/services/api/client.test.ts | 91 ++++++++++++++++++- .../src/shared/services/api/client.ts | 23 ++++- 12 files changed, 376 insertions(+), 43 deletions(-) create mode 100644 apps/frontend/src/features/auth/authRedirect.test.tsx create mode 100644 apps/frontend/src/features/auth/authRedirect.ts diff --git a/apps/frontend/src/app/pages/LoginPage.tsx b/apps/frontend/src/app/pages/LoginPage.tsx index f8c42752..bf3c0f93 100644 --- a/apps/frontend/src/app/pages/LoginPage.tsx +++ b/apps/frontend/src/app/pages/LoginPage.tsx @@ -3,7 +3,7 @@ import { Hexagon, Loader2 } from 'lucide-react'; import { useLoginForm } from '@/features/auth/hooks/useLoginForm'; export function LoginPage() { - const { email, setEmail, password, setPassword, submitting, error, submit } = useLoginForm(); + const { email, setEmail, password, setPassword, submitting, error, submit, redirectState } = useLoginForm(); return (
@@ -63,7 +63,7 @@ export function LoginPage() {

Don't have an account?{' '} - + Create one

diff --git a/apps/frontend/src/app/pages/RegisterPage.tsx b/apps/frontend/src/app/pages/RegisterPage.tsx index 6d6cced6..747c1f68 100644 --- a/apps/frontend/src/app/pages/RegisterPage.tsx +++ b/apps/frontend/src/app/pages/RegisterPage.tsx @@ -3,7 +3,7 @@ import { Hexagon, Loader2 } from 'lucide-react'; import { PASSWORD_MIN_LENGTH, useRegisterForm } from '@/features/auth/hooks/useRegisterForm'; export function RegisterPage() { - const { email, setEmail, password, setPassword, submitting, error, submit } = useRegisterForm(); + const { email, setEmail, password, setPassword, submitting, error, submit, redirectState } = useRegisterForm(); return (
@@ -65,7 +65,7 @@ export function RegisterPage() {

Already have an account?{' '} - + Sign in

diff --git a/apps/frontend/src/app/routes/RequireAuth.test.tsx b/apps/frontend/src/app/routes/RequireAuth.test.tsx index 8e57cc33..a59dfc64 100644 --- a/apps/frontend/src/app/routes/RequireAuth.test.tsx +++ b/apps/frontend/src/app/routes/RequireAuth.test.tsx @@ -3,6 +3,7 @@ import { render, screen, waitFor } from '@testing-library/react'; import { createMemoryRouter, RouterProvider } from 'react-router-dom'; import { RequireAuth } from './RequireAuth'; import { useAuthStore } from '@/app/store/useAuthStore'; +import { useAppStore } from '@/app/store/useAppStore'; import { authService, repositoryService } from '@/shared/services/api'; import { RepositoryProvider } from '@/features/repositories/context/RepositoryProvider'; import { useRepository } from '@/features/repositories/hooks/useRepository'; @@ -118,6 +119,7 @@ describe('RequireAuth', () => { describe('RepositoryProvider gating (mirrors MainLayout mounting it inside RequireAuth)', () => { beforeEach(() => { useAuthStore.setState({ status: 'initialising', accessToken: null, user: null }); + useAppStore.setState({ repositories: [], activeRepositoryId: null }); }); afterEach(() => { @@ -156,4 +158,34 @@ describe('RepositoryProvider gating (mirrors MainLayout mounting it inside Requi await waitFor(() => expect(listSpy).toHaveBeenCalledTimes(1)); expect(await screen.findByText('Repos: 0')).toBeInTheDocument(); }); + + it('refetches when the authenticated user identity changes, even without an unmount', async () => { + const listSpy = vi.spyOn(repositoryService, 'list').mockResolvedValue({ data: [], total: 0 }); + useAuthStore.setState({ + status: 'authenticated', + user: { id: 'userA', email: 'a@example.com', createdAt: new Date().toISOString() }, + }); + + render( + + + , + ); + + await waitFor(() => expect(listSpy).toHaveBeenCalledTimes(1)); + + // A same-id update (e.g. a fresh /auth/me object) must not cause a + // redundant fetch. + useAuthStore.setState({ user: { id: 'userA', email: 'a@example.com', createdAt: new Date().toISOString() } }); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(listSpy).toHaveBeenCalledTimes(1); + + // A genuinely different identity must trigger a fresh fetch — the second + // user must never be served straight from the first user's cached list, + // even in the (unreachable-via-routing-today, but not relied upon) case + // where the provider never unmounts between them. + useAuthStore.setState({ user: { id: 'userB', email: 'b@example.com', createdAt: new Date().toISOString() } }); + + await waitFor(() => expect(listSpy).toHaveBeenCalledTimes(2)); + }); }); diff --git a/apps/frontend/src/app/store/useAuthStore.test.ts b/apps/frontend/src/app/store/useAuthStore.test.ts index 632245b2..3b0b62f7 100644 --- a/apps/frontend/src/app/store/useAuthStore.test.ts +++ b/apps/frontend/src/app/store/useAuthStore.test.ts @@ -2,7 +2,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { useAuthStore } from './useAuthStore'; import { useAppStore } from './useAppStore'; import { authService, api, getApiConfig } from '@/shared/services/api'; -import type { Repository } from '@/shared/types'; +import type { Notification, Repository } from '@/shared/types'; + +function fakeNotification(id: string): Notification { + return { id, title: 'Analysis Complete', message: 'repo-a done', type: 'success', read: false, createdAt: new Date().toISOString() }; +} function fakeRepository(id: string): Repository { return { @@ -94,6 +98,43 @@ describe('useAuthStore', () => { expect(useAppStore.getState().repositories).toEqual([]); expect(useAuthStore.getState().status).toBe('authenticated'); }); + + it('is idempotent and single-flight when called concurrently (React StrictMode double-invokes mount effects)', async () => { + let refreshCount = 0; + let meCount = 0; + + vi.spyOn(authService, 'refresh').mockImplementation(async () => { + refreshCount += 1; + await new Promise((resolve) => setTimeout(resolve, 10)); + return { accessToken: 'fresh-token', tokenType: 'bearer' as const, user: fakeUser('u1', 'a@example.com') }; + }); + vi.spyOn(authService, 'me').mockImplementation(async () => { + meCount += 1; + return fakeUser('u1', 'a@example.com'); + }); + + // Two callers invoking bootstrap() "at the same time", exactly as + // StrictMode's mount -> cleanup -> remount does to a bare `void + // bootstrap()` in a mount effect. + await Promise.all([useAuthStore.getState().bootstrap(), useAuthStore.getState().bootstrap()]); + + expect(refreshCount).toBe(1); + expect(meCount).toBe(1); + expect(useAuthStore.getState()).toMatchObject({ status: 'authenticated', accessToken: 'fresh-token' }); + expect(useAuthStore.getState().user?.id).toBe('u1'); + + // The resulting session must actually be usable afterward, not just + // "some state got set" — a protected request should succeed on the + // token bootstrap established, without needing yet another refresh. + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(JSON.stringify({ data: [], total: 0 }), { status: 200, headers: { 'content-type': 'application/json' } })), + ); + await expect(api.get('/repositories')).resolves.toEqual({ data: [], total: 0 }); + expect(refreshCount).toBe(1); + + vi.unstubAllGlobals(); + }); }); describe('cross-user repository isolation', () => { @@ -138,5 +179,51 @@ describe('useAuthStore', () => { expect(useAppStore.getState().activeRepositoryId).toBeNull(); expect(useAuthStore.getState().status).toBe('unauthenticated'); }); + + it('clears running-analysis and notification state on logout too, not just repositories', async () => { + vi.spyOn(authService, 'logout').mockResolvedValue(undefined); + useAppStore.setState({ + repositories: [fakeRepository('repo-a')], + activeRepositoryId: 'repo-a', + analysisRunning: true, + currentAnalysisId: 'repo-a', + notifications: [fakeNotification('n1')], + }); + useAuthStore.setState({ status: 'authenticated', accessToken: 'a-token', user: fakeUser('userA', 'a@example.com') }); + + await useAuthStore.getState().logout(); + + expect(useAppStore.getState()).toMatchObject({ + repositories: [], + activeRepositoryId: null, + analysisRunning: false, + currentAnalysisId: null, + notifications: [], + }); + }); + }); + + describe('guest-only auth failures must not touch an existing session', () => { + it('a failed login attempt does not clear an existing authenticated session', async () => { + useAuthStore.setState({ status: 'authenticated', accessToken: 'a-token', user: fakeUser('userA', 'a@example.com') }); + + // Exercises the real client interceptor (not a mocked authService), + // since the bug lived in that wiring: a 401 from a guest-only endpoint + // must not reach the globally-registered onUnauthorized handler. + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(JSON.stringify({ code: 'unauthorized', message: 'bad credentials' }), { + status: 401, + headers: { 'content-type': 'application/json' }, + })), + ); + + await expect(authService.login({ email: 'attacker@example.com', password: 'wrong' })).rejects.toThrow(); + + expect(useAuthStore.getState()).toMatchObject({ status: 'authenticated', accessToken: 'a-token' }); + expect(useAuthStore.getState().user?.id).toBe('userA'); + + vi.unstubAllGlobals(); + }); }); }); diff --git a/apps/frontend/src/app/store/useAuthStore.ts b/apps/frontend/src/app/store/useAuthStore.ts index 5038b999..a9ea0ec6 100644 --- a/apps/frontend/src/app/store/useAuthStore.ts +++ b/apps/frontend/src/app/store/useAuthStore.ts @@ -31,29 +31,17 @@ export const useAuthStore = create((set) => ({ accessToken: null, user: null, - async bootstrap() { - const refreshed = await requestSharedRefresh(); - if (!refreshed) { - clearAuthenticatedState(); - return; - } - try { - const user = await authService.me(); - set({ user, status: 'authenticated' }); - } catch { - clearAuthenticatedState(); - } - }, + bootstrap, async login(email, password) { const auth = await authService.login({ email, password }); - clearRepositoryState(); + clearUserScopedAppState(); set({ accessToken: auth.accessToken, user: auth.user, status: 'authenticated' }); }, async register(email, password) { const auth = await authService.register({ email, password }); - clearRepositoryState(); + clearUserScopedAppState(); set({ accessToken: auth.accessToken, user: auth.user, status: 'authenticated' }); }, @@ -80,20 +68,56 @@ export const useAuthStore = create((set) => ({ }, })); -// Repository state lives in the separate, unauthenticated-by-default +// React 18 StrictMode deliberately double-invokes mount effects in +// development, so a naive bootstrap() would fire twice on every dev load. +// requestSharedRefresh() already dedupes the /auth/refresh call itself, but +// without this, each invocation would still independently race ahead to its +// own /auth/me call — harmless in itself, but bootstrap() should be safe to +// call concurrently as a whole, not just safe in its riskiest sub-step. One +// shared in-flight promise makes every concurrent bootstrap() call (and its +// caller) await the exact same run and land on the exact same final state. +let inFlightBootstrap: Promise | null = null; + +function bootstrap(): Promise { + if (!inFlightBootstrap) { + inFlightBootstrap = performBootstrap().finally(() => { + inFlightBootstrap = null; + }); + } + return inFlightBootstrap; +} + +async function performBootstrap(): Promise { + const refreshed = await requestSharedRefresh(); + if (!refreshed) { + clearAuthenticatedState(); + return; + } + try { + const user = await authService.me(); + useAuthStore.setState({ user, status: 'authenticated' }); + } catch { + clearAuthenticatedState(); + } +} + +// User-scoped app state lives in the separate, unauthenticated-by-default // useAppStore, so it survives independently of who's signed in unless we // clear it ourselves. Cleared here — not in RepositoryProvider — so the // clearing always happens in the same tick as the auth transition, before // any component can re-render with a newly (or no longer) authenticated -// status and observe the previous user's repositories, even briefly. -function clearRepositoryState() { - useAppStore.getState().setRepositories([]); - useAppStore.getState().setActiveRepositoryId(null); +// status and observe the previous user's data, even briefly. +function clearUserScopedAppState() { + const appStore = useAppStore.getState(); + appStore.setRepositories([]); + appStore.setActiveRepositoryId(null); + appStore.cancelAnalysis(); + appStore.clearNotifications(); } function clearAuthenticatedState() { useAuthStore.setState({ accessToken: null, user: null, status: 'unauthenticated' }); - clearRepositoryState(); + clearUserScopedAppState(); } // Wired here (not in client.ts) so the API client stays a generic HTTP layer 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..ab5faa7d --- /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('/'); + expect(resolveRedirectTarget(undefined)).toBe('/'); + expect(resolveRedirectTarget({})).toBe('/'); + }); + + 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..01f48598 --- /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 '/'; + return `${from.pathname}${from.search ?? ''}${from.hash ?? ''}`; +} diff --git a/apps/frontend/src/features/auth/hooks/useLoginForm.ts b/apps/frontend/src/features/auth/hooks/useLoginForm.ts index 19dfcc08..a9e51dcb 100644 --- a/apps/frontend/src/features/auth/hooks/useLoginForm.ts +++ b/apps/frontend/src/features/auth/hooks/useLoginForm.ts @@ -2,10 +2,7 @@ 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'; - -interface LocationState { - from?: { pathname?: string }; -} +import { resolveRedirectTarget } from '../authRedirect'; export function useLoginForm() { const login = useAuthStore((state) => state.login); @@ -24,8 +21,7 @@ export function useLoginForm() { setError(null); try { await login(email.trim(), password); - const state = location.state as LocationState | null; - navigate(state?.from?.pathname || '/', { replace: true }); + navigate(resolveRedirectTarget(location.state), { replace: true }); } catch (caught) { setError(getErrorMessage(caught)); } finally { @@ -33,5 +29,7 @@ export function useLoginForm() { } }; - return { email, setEmail, password, setPassword, submitting, error, submit }; + // 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/useRegisterForm.ts b/apps/frontend/src/features/auth/hooks/useRegisterForm.ts index a554e142..a4dfe70d 100644 --- a/apps/frontend/src/features/auth/hooks/useRegisterForm.ts +++ b/apps/frontend/src/features/auth/hooks/useRegisterForm.ts @@ -1,7 +1,8 @@ import { useState, type FormEvent } from 'react'; -import { useNavigate } from 'react-router-dom'; +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 @@ -11,6 +12,7 @@ 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(''); @@ -28,7 +30,7 @@ export function useRegisterForm() { setError(null); try { await register(email.trim(), password); - navigate('/', { replace: true }); + navigate(resolveRedirectTarget(location.state), { replace: true }); } catch (caught) { setError(getErrorMessage(caught)); } finally { @@ -36,5 +38,7 @@ export function useRegisterForm() { } }; - return { email, setEmail, password, setPassword, submitting, error, submit }; + // 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/repositories/context/RepositoryProvider.tsx b/apps/frontend/src/features/repositories/context/RepositoryProvider.tsx index 12fd1b92..fba70d48 100644 --- a/apps/frontend/src/features/repositories/context/RepositoryProvider.tsx +++ b/apps/frontend/src/features/repositories/context/RepositoryProvider.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { backendService } from '@/shared/services/backend'; import { getErrorMessage } from '@/shared/services/api'; import { useAppStore } from '@/app/store/useAppStore'; +import { useAuthStore } from '@/app/store/useAuthStore'; import { RepositoryContext, type RepositoryContextValue } from './repository-context'; export function RepositoryProvider({ children }: { children: React.ReactNode }) { @@ -9,6 +10,12 @@ export function RepositoryProvider({ children }: { children: React.ReactNode }) const activeRepositoryId = useAppStore((state) => state.activeRepositoryId); const setActiveRepositoryId = useAppStore((state) => state.setActiveRepositoryId); const setRepositories = useAppStore((state) => state.setRepositories); + // Mounting only happens once RequireAuth confirms an authenticated session + // (this provider lives inside MainLayout), which covers the common case. + // This dependency additionally covers the case where the identity changes + // *without* an intervening unmount, so a second user can never be served + // straight from the first user's already-fetched state. + const userId = useAuthStore((state) => state.user?.id); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -43,7 +50,7 @@ export function RepositoryProvider({ children }: { children: React.ReactNode }) return () => { cancelled = true; }; - }, [setRepositories]); + }, [setRepositories, userId]); const value = useMemo(() => { const activeRepository = repositories.find((repo) => repo.id === activeRepositoryId) || null; diff --git a/apps/frontend/src/shared/services/api/client.test.ts b/apps/frontend/src/shared/services/api/client.test.ts index 817204e7..6e62e5c9 100644 --- a/apps/frontend/src/shared/services/api/client.test.ts +++ b/apps/frontend/src/shared/services/api/client.test.ts @@ -9,6 +9,16 @@ function jsonResponse(status: number, body: unknown): Response { }); } +function sseResponse(chunks: string[]): Response { + const stream = new ReadableStream({ + start(controller) { + for (const chunk of chunks) controller.enqueue(new TextEncoder().encode(chunk)); + controller.close(); + }, + }); + return new Response(stream, { status: 200, headers: { 'content-type': 'text/event-stream' } }); +} + describe('api client 401 handling', () => { const baseConfig = getApiConfig(); @@ -73,7 +83,86 @@ describe('api client 401 handling', () => { await expect(api.post('/auth/login', { email: 'a@b.com', password: 'x' })).rejects.toBeInstanceOf(ApiError); expect(refreshSession).not.toHaveBeenCalled(); - expect(onUnauthorized).toHaveBeenCalledTimes(1); expect(fetchMock).toHaveBeenCalledTimes(1); }); + + it('does not invalidate an existing session on a failed login or register attempt', async () => { + // A wrong password or a duplicate email is a guest-only failure — it must + // surface as a form error, not clear some OTHER already-authenticated + // session (e.g. a user who opens /login in a second tab while still + // signed in elsewhere). + const onUnauthorized = vi.fn(); + const fetchMock = vi.fn(async () => jsonResponse(401, { code: 'unauthorized', message: 'bad credentials' })); + vi.stubGlobal('fetch', fetchMock); + configureApiClient({ onUnauthorized }); + + await expect(api.post('/auth/login', { email: 'a@b.com', password: 'wrong' })).rejects.toBeInstanceOf(ApiError); + await expect(api.post('/auth/register', { email: 'a@b.com', password: 'x' })).rejects.toBeInstanceOf(ApiError); + + expect(onUnauthorized).not.toHaveBeenCalled(); + }); + + it('still reports a failed refresh or logout through onUnauthorized (unlike login/register)', async () => { + const onUnauthorized = vi.fn(); + const fetchMock = vi.fn(async () => jsonResponse(401, { code: 'unauthorized', message: 'invalid refresh token' })); + vi.stubGlobal('fetch', fetchMock); + configureApiClient({ onUnauthorized }); + + await expect(api.post('/auth/refresh')).rejects.toBeInstanceOf(ApiError); + await expect(api.post('/auth/logout')).rejects.toBeInstanceOf(ApiError); + + expect(onUnauthorized).toHaveBeenCalledTimes(2); + }); +}); + +describe('streamRequest 401 handling', () => { + const baseConfig = getApiConfig(); + + beforeEach(() => { + configureApiClient({ ...baseConfig, getAuthToken: () => 'stale-token', refreshSession: undefined, onUnauthorized: undefined }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + configureApiClient(baseConfig); + }); + + it('recovers from a pre-stream 401: refreshes once, retries once, and streams the retried response', async () => { + let authorized = false; + const refreshSession = vi.fn(async () => { + authorized = true; + return true; + }); + const onUnauthorized = vi.fn(); + + const fetchMock = vi.fn(async () => + authorized ? sseResponse(['data: hello\n\n', 'data: world\n\n']) : jsonResponse(401, { code: 'unauthorized', message: 'expired' }), + ); + vi.stubGlobal('fetch', fetchMock); + configureApiClient({ refreshSession, onUnauthorized }); + + const chunks: string[] = []; + await api.stream('/ai/query', { query: 'hi' }, (chunk) => chunks.push(chunk)); + + expect(refreshSession).toHaveBeenCalledTimes(1); + expect(onUnauthorized).not.toHaveBeenCalled(); + expect(fetchMock).toHaveBeenCalledTimes(2); // the initial 401, then the one retry + expect(chunks.join('')).toBe('data: hello\n\ndata: world\n\n'); + }); + + it('gives up cleanly (no infinite retry) when the refresh fails', async () => { + const refreshSession = vi.fn(async () => false); + const onUnauthorized = vi.fn(); + const fetchMock = vi.fn(async () => jsonResponse(401, { code: 'unauthorized', message: 'expired' })); + vi.stubGlobal('fetch', fetchMock); + configureApiClient({ refreshSession, onUnauthorized }); + + const chunks: string[] = []; + await expect(api.stream('/ai/query', { query: 'hi' }, (chunk) => chunks.push(chunk))).rejects.toBeInstanceOf(ApiError); + + expect(refreshSession).toHaveBeenCalledTimes(1); + expect(onUnauthorized).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledTimes(1); // refresh failed, nothing to retry with + expect(chunks).toEqual([]); + }); }); diff --git a/apps/frontend/src/shared/services/api/client.ts b/apps/frontend/src/shared/services/api/client.ts index b9e2d0d0..f3b40f48 100644 --- a/apps/frontend/src/shared/services/api/client.ts +++ b/apps/frontend/src/shared/services/api/client.ts @@ -49,8 +49,16 @@ export function getApiConfig(): ApiClientConfig { // failed /auth/refresh or /auth/logout would recurse into the same endpoint. const NO_REFRESH_ENDPOINTS = ['/auth/login', '/auth/register', '/auth/refresh', '/auth/logout']; -function isNoRefreshEndpoint(endpoint: string): boolean { - return NO_REFRESH_ENDPOINTS.some((path) => endpoint === path || endpoint.startsWith(`${path}?`)); +// A failed login/register attempt (bad credentials, duplicate email, ...) is +// a guest-only action — it says nothing about whether some OTHER, +// already-established session is still valid, and must not clear it. This is +// a *narrower* set than NO_REFRESH_ENDPOINTS: /auth/refresh failing (session +// genuinely dead) and /auth/logout failing (session ending anyway) still +// report through onUnauthorized; only login/register are exempt from that. +const GUEST_ONLY_ENDPOINTS = ['/auth/login', '/auth/register']; + +function matchesEndpoint(paths: string[], endpoint: string): boolean { + return paths.some((path) => endpoint === path || endpoint.startsWith(`${path}?`)); } // Several requests can each hit a 401 for the same expired session around the @@ -82,12 +90,17 @@ export function requestSharedRefresh(): Promise { /** Resolves true (and the caller should retry once) if this was an * unauthorized response on a refreshable endpoint and the shared refresh - * succeeded; otherwise fires onUnauthorized and resolves false. */ + * succeeded; otherwise resolves false, firing onUnauthorized unless this was + * a guest-only endpoint (a failed login/register must not invalidate some + * other, already-authenticated session). */ async function tryRecoverFromUnauthorized(endpoint: string, isRetry: boolean): Promise { - if (!isRetry && !isNoRefreshEndpoint(endpoint) && (await requestSharedRefresh())) { + const noRefresh = matchesEndpoint(NO_REFRESH_ENDPOINTS, endpoint); + if (!isRetry && !noRefresh && (await requestSharedRefresh())) { return true; } - clientConfig.onUnauthorized?.(); + if (!matchesEndpoint(GUEST_ONLY_ENDPOINTS, endpoint)) { + clientConfig.onUnauthorized?.(); + } return false; } From db03af747e02a171e370fc600873e4c1c5f67b67 Mon Sep 17 00:00:00 2001 From: Parth Rohit Date: Mon, 13 Jul 2026 11:05:01 +0100 Subject: [PATCH 042/347] chore: start Phase 0 --- .gitignore | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 22de1458..c667222f 100644 --- a/.gitignore +++ b/.gitignore @@ -141,4 +141,6 @@ coverage/ # ========================================== .fseventsd -.Trashesg \ No newline at end of file +.Trashesg + +PARTHA_Defensible_Repository_Intelligence_Roadmap_2026_2027.html \ No newline at end of file From 85b0eb0be833cab2ccc3ecb7a0615c14c48ea084 Mon Sep 17 00:00:00 2001 From: Parth Rohit Date: Mon, 13 Jul 2026 12:36:58 +0100 Subject: [PATCH 043/347] docs: refocus documentation and contribution workflow --- .github/CODEOWNERS | 67 +-- .github/ISSUE_TEMPLATE/bug_report.yml | 8 +- .github/ISSUE_TEMPLATE/engineering_task.yml | 4 +- .github/dependabot.yml | 5 +- .github/pull_request_template.md | 70 ++- .github/workflows/codeql.yml | 5 +- CODE_OF_CONDUCT.md | 139 +++++ CONTRIBUTING.md | 507 ++++++++++------- README.md | 511 +++++------------- apps/backend/README.md | 61 ++- apps/frontend/README.md | 46 +- docs/README.md | 81 +-- docs/architecture/AI_ARCHITECTURE.md | 158 ------ docs/architecture/REPOSITORY_INTELLIGENCE.md | 192 +++++++ .../REPOSITORY_INTELLIGENCE_ENGINE.md | 148 ----- docs/architecture/SYSTEM_OVERVIEW.md | 240 ++++++++ docs/assets/partha-hero.svg | 148 +++-- docs/assets/partha-logo.svg | 38 -- docs/audit/CORE_1_INGESTION_PIPELINE_AUDIT.md | 194 ------- .../CORE_2_REPOSITORY_INTELLIGENCE_AUDIT.md | 101 ---- docs/brand/VISUAL_IDENTITY.md | 109 ---- docs/operations/dependency-management.md | 56 -- docs/operations/observability.md | 51 -- docs/operations/production-deployment.md | 87 --- docs/operations/release-management.md | 57 -- docs/planning/DEFINITION_OF_DONE.md | 111 ---- docs/planning/PRODUCTION_READINESS_PLAN.md | 407 -------------- docs/product/PUBLIC_FACE_AUDIT.md | 140 ----- scripts/README.md | 14 +- 29 files changed, 1270 insertions(+), 2485 deletions(-) create mode 100644 CODE_OF_CONDUCT.md delete mode 100644 docs/architecture/AI_ARCHITECTURE.md create mode 100644 docs/architecture/REPOSITORY_INTELLIGENCE.md delete mode 100644 docs/architecture/REPOSITORY_INTELLIGENCE_ENGINE.md create mode 100644 docs/architecture/SYSTEM_OVERVIEW.md delete mode 100644 docs/assets/partha-logo.svg delete mode 100644 docs/audit/CORE_1_INGESTION_PIPELINE_AUDIT.md delete mode 100644 docs/audit/CORE_2_REPOSITORY_INTELLIGENCE_AUDIT.md delete mode 100644 docs/brand/VISUAL_IDENTITY.md delete mode 100644 docs/operations/dependency-management.md delete mode 100644 docs/operations/observability.md delete mode 100644 docs/operations/production-deployment.md delete mode 100644 docs/operations/release-management.md delete mode 100644 docs/planning/DEFINITION_OF_DONE.md delete mode 100644 docs/planning/PRODUCTION_READINESS_PLAN.md delete mode 100644 docs/product/PUBLIC_FACE_AUDIT.md diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 2ed86c15..1e1135a0 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,66 +1 @@ -# CODEOWNERS — Second-Origin/PARTHA -# -# Automatically requests the right reviewer when a pull request touches an owned -# path. Derived from docs/planning/PRODUCTION_READINESS_PLAN.md §2.4 -# ("Ownership as you scale"). -# -# Ownership model: each experienced hire owns exactly ONE area and becomes the -# default reviewer and decision-maker there — ownership scales, task-assignment -# doesn't. Until those hires land, the founders own every area as the fallback. -# -# Founders / fallback owners: -# @SHAURYAKSHARMA24 (Shaurya) -# @parthrohit22 (Parth) -# -# Syntax notes: -# - The LAST matching pattern wins, so the catch-all is first and the most -# specific area rules are last. -# - Every owner must have write access to the repo, or GitHub silently skips -# the rule. Add new hires to the repo (or an org team) before listing them. -# - Prefer an org team (e.g. @Second-Origin/frontend) over an individual once -# an area has more than one owner. - -# ----------------------------------------------------------------------------- -# Fallback — anything not matched by a rule below is reviewed by the founders. -# ----------------------------------------------------------------------------- -* @SHAURYAKSHARMA24 @parthrohit22 - -# ----------------------------------------------------------------------------- -# Area: ai — AI providers, prompt construction, context retrieval, streaming. -# Label: area/ai -# -# TODO(ownership): when the AI hire starts, replace the founders on this rule -# with @ and leave the founders only as fallback via the `*` rule. -# ----------------------------------------------------------------------------- -/apps/backend/app/ai/ @SHAURYAKSHARMA24 @parthrohit22 - -# ----------------------------------------------------------------------------- -# Area: intelligence — parsers, knowledge graph, and the analysis engine. -# This is the heart of E4 (Persisted Knowledge Graph); keep it with one owner. -# Labels: area/backend, area/ai -# -# TODO(ownership): when the intelligence hire starts, replace the founders on -# these three rules with @. -# ----------------------------------------------------------------------------- -/apps/backend/app/parsers/ @SHAURYAKSHARMA24 @parthrohit22 -/apps/backend/app/graph/ @SHAURYAKSHARMA24 @parthrohit22 -/apps/backend/app/intelligence/ @SHAURYAKSHARMA24 @parthrohit22 - -# ----------------------------------------------------------------------------- -# Area: frontend — React/TS app, including the test harness stood up in E3.2. -# Label: area/frontend -# -# TODO(ownership): when the frontend hire starts, replace the founders on this -# rule with @. -# ----------------------------------------------------------------------------- -/apps/frontend/ @SHAURYAKSHARMA24 @parthrohit22 - -# ----------------------------------------------------------------------------- -# Area: infra — config, secrets handling, CI/CD, and repo governance. -# -# These stay with the founders permanently. Per §2.5, changes here (along with -# auth and migrations) warrant TWO approving reviews. No TODO: this is not -# delegated to a new hire. -# ----------------------------------------------------------------------------- -/apps/backend/app/core/ @SHAURYAKSHARMA24 @parthrohit22 -/.github/ @SHAURYAKSHARMA24 @parthrohit22 +* @parthrohit22 diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 8e124728..4c4d47e8 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,7 +56,7 @@ 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: 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 index 1cd215e5..ae162fc9 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,8 +1,7 @@ # Dependabot configuration for the PARTHA monorepo. # -# Satisfies part of E2.3 ("Dependency, secret, and code scanning in CI") in -# docs/planning/PRODUCTION_READINESS_PLAN.md, and §2.5, which makes Dependabot a -# required status check once branch protection is enabled. +# Part of the dependency, secret, and code scanning baseline. Intended to become +# a required status check once branch protection is enabled. # # Two ecosystems, one per app: npm for the React/TS frontend, pip for the # FastAPI backend. Minor and patch bumps are grouped into a single PR per diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 716fa336..8d2ceb42 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,38 +1,62 @@ ## 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 +## What changed -Commands/results: + + +## 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 +- [ ] 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/codeql.yml b/.github/workflows/codeql.yml index 7acea7d0..633dd441 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -1,8 +1,7 @@ # CodeQL static analysis for PARTHA. # -# Satisfies part of E2.3 ("Dependency, secret, and code scanning in CI") in -# docs/planning/PRODUCTION_READINESS_PLAN.md. Per §2.5, this should become a -# required status check on `dev` and `main` once branch protection is enabled. +# Part of the dependency, secret, and code scanning baseline. Intended to become +# a required status check on `dev` and `main` once branch protection is enabled. name: CodeQL on: 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..477605e0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,329 +1,428 @@ # 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 self-hosted 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. --- -## 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 | +| Docker | any recent | Optional local Compose stack | -### 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. -- 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`. -- 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. +### Set up your fork once + +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. +4. Comment on the issue stating that you want to work on it. +5. Wait for assignment or an explicit maintainer acknowledgement. +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, commenting and receiving a maintainer acknowledgement **is** the claim mechanism. -Maintainers should be able to review the PR without reverse-engineering intent from the diff. +You must not begin substantial work on: ---- +- an issue assigned to someone else +- an obsolete issue +- an issue whose scope is disputed +- an issue blocked by unmerged prerequisite work +- an issue without testable acceptance criteria -## Code Standards +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. -### Repository Intelligence Boundary +### Choosing a template -Do: +The repository provides three issue templates in [`.github/ISSUE_TEMPLATE/`](.github/ISSUE_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. +| 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. | -Do not: +In every template: write acceptance criteria that someone other than you can verify, name the affected components, and disclose dependencies and blocking work. -- 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. +For documentation changes, open a Feature Request or Engineering Task describing what is inaccurate and what it should say. -### Backend +### Security vulnerabilities -- 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 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. -### Frontend +--- -- 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. +## 4. Branch naming -### General +Every issue gets a dedicated branch created from the latest `upstream/dev`. -- Remove dead code. -- Avoid speculative abstractions. -- Prefer small, reviewable changes. -- Do not commit secrets, local env files, local databases, build outputs, or generated caches. +```text +/- +``` ---- +```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 +``` -## Testing Expectations +Allowed types: `feature`, `fix`, `docs`, `test`, `refactor`, `chore`, `security`. -Run the checks relevant to your change. +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). -### Frontend +--- -```bash -npm run lint:frontend -npm run build:frontend -``` +## 5. Rebase onto `dev` -### Backend +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: + +- 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. -```bash -npm run build +--- + +## 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 | +| --- | --- | +| `npm run test:backend` | Backend tests (pytest) | +| `npm --prefix apps/frontend run test` | Frontend tests (vitest) | +| `npm run lint:frontend` | ESLint | +| `npm run build:frontend` | `tsc -b && vite build` — type errors surface here, not in lint | +| `npm run docker:config` | `docker compose config` | +| `npm run docker:validate` | Starts the local Compose stack, waits for `/ready`, tears it down | -Common types: +`npm run build` runs the frontend build plus the backend tests. It does **not** run frontend lint or frontend tests — run those separately. -| 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` | +| Docker, Compose, CI, config, startup, health | `npm run docker:config` and `npm run docker:validate` | +| Anything user-visible | Update the documentation **in the same pull request** | + +Three backend tests are gated on real PostgreSQL and Redis and skip locally; CI provides both services. + +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.** + +### 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.** PARTHA has file-level evidence and no line spans. 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 -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. +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.** + +--- -Good contributor questions include: +## 14. Conduct and licensing -- 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? +All participation is governed by the [Code of Conduct](CODE_OF_CONDUCT.md). -Thanks for helping make PARTHA a trustworthy Engineering Intelligence Platform. +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. diff --git a/README.md b/README.md index c14f686b..7abd74cf 100644 --- a/README.md +++ b/README.md @@ -1,29 +1,15 @@

- 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 + Getting started + · + What works · - Capabilities + How it works · - Architecture + Limitations · Docs · @@ -32,454 +18,243 @@

License - Python 3.12+ + Python 3.12-3.13 FastAPI - React - TypeScript - Docker Compose + React 18 + TypeScript 5

--- -## Why PARTHA Exists +## What PARTHA is -Modern software systems do not fail because engineers lack files. They fail because repository knowledge is fragmented. +**A self-hosted Repository Intelligence Platform for private and evolving codebases.** -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: +PARTHA parses a repository once into a shared layer of repository facts, and has every surface — architecture, dependencies, reviews, documentation, exports, and optional AI — read from that one layer rather than deriving its own. -- 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? +> **Repository Intelligence is the shared repository-understanding layer. AI is an optional downstream consumer of it, never an independent interpreter of the repository.** -PARTHA exists to turn software repositories into reusable engineering knowledge. +The purpose, stated at the level it is actually being pursued: build *persistent* understanding of a repository as it evolves, reuse that understanding across every surface, and make repository claims progressively more checkable. PARTHA is early. What follows describes what exists, not what is intended. -The key idea is simple: +> **Status: early development.** PARTHA runs locally and is useful for exploring a repository. It is **not production-ready**. Multi-user authentication and owner isolation are not consistently enforced across every backend surface — PARTHA should currently be used only in a trusted local environment. -> Analyse a repository once, build a repository intelligence layer, and let every product surface consume the same source of truth. - -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. - ---- +## The problem -## Vision +- **Architecture is implicit.** Module boundaries, layering, and entry points live in folder conventions and framework idioms, not in anything you can read. +- **Dependencies are scattered** across manifests, imports, config, and container files. +- **Documentation goes stale**, and nobody can tell which parts still hold. +- **AI explanations are hard to trust** when the assistant reads raw files and guesses, giving you no way to check its answer. -PARTHA’s long-term direction is Engineering Decision Intelligence. - -```mermaid -flowchart LR - A[Repository Intelligence] --> B[Architecture Intelligence] - B --> C[Engineering Intelligence] - C --> D[Software Change Intelligence] - D --> E[Engineering Decision Intelligence] -``` - -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. - -The goal is not to replace engineers. The goal is to make the engineering context visible, consistent, and reviewable. +The common failure is that each tool re-derives its own private understanding of the repository, and they quietly disagree. PARTHA's answer is to derive it once, in one place, and make everything else a consumer. --- -## Core Capabilities +## What currently works -PARTHA is organised around engineering capabilities rather than individual screens. +Statuses below were checked against the implementation, not against prior documentation. -| Capability | Current implementation | Status | +| Capability | Status | Notes | | --- | --- | --- | -| 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 (languages, frameworks, modules, dependencies, and file paths). Providers do not parse repositories directly. Answers are grounded in repository structure/metadata only — source-line citations are not produced yet and arrive with the persisted knowledge graph. | Partial | -| 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. +| Archive upload (`.zip`, `.tar.gz`) | **Implemented** | Size caps; path-traversal and symlink escape rejected; empty and invalid archives rejected. | +| Public GitHub import | **Implemented** | Shallow clone of public HTTPS GitHub URLs, with clone timeout and size cap. Records the HEAD commit. Private repositories and other hosts are not supported. | +| Repository explorer and file preview | **Implemented** | File tree, text and image preview, binary detection, truncation of large files. | +| Documentation and report export | **Implemented** | JSON, Markdown, HTML, and PDF through a shared report pipeline. | +| Authentication and frontend session flow | **Implemented** | Email/password with Argon2, short-lived access tokens, rotating refresh tokens with reuse detection. All frontend routes are behind an auth guard. | +| Repository Intelligence | **Implemented but limited** | Extracts discovery facts, file roles, imports/exports, routes, symbols, modules, and dependencies, then persists and reuses them. Extraction is regex- and path-convention-based, not language-aware. | +| Architecture output | **Implemented but limited** | Modules, layers, relationships, and an interactive graph — with heuristic module and layer assignment. | +| Dependency inventory | **Implemented but limited** | Reads `package.json`, `requirements.txt`, and `pyproject.toml`. Other ecosystems and lockfiles are not parsed. | +| Engineering review | **Implemented but limited** | A fixed set of heuristic checks with derived category scores. Scores are arithmetic over finding severities, not a measured quality metric. | +| AI provider integration | **Implemented but limited** | Several providers behind one abstraction. Provider configuration is global rather than per-user. | +| Authorization and owner isolation | **Partially implemented** | Repository routes are owner-scoped. Analysis, AI, documentation, and export routes are not, and the backend still accepts unauthenticated requests. | +| Citations and grounded AI answers | **Not implemented** | No source content or line numbers are sent to providers, and no citations are returned. | +| Asynchronous / incremental processing | **Not implemented** | Ingestion and analysis run synchronously in the request; there is no background job system and no incremental re-analysis. | +| Change-impact analysis | **Not implemented** | — | +| Vulnerability and outdated-dependency scanning | **Not implemented** | The API exposes these fields, but they are constants. No scanning is performed. | +| Persistent semantic knowledge graph | **Not implemented** | The graph is serialized as JSON onto the repository row; there is no queryable graph store. | + +A capability is listed as implemented only where the behaviour exists in code — not because a model, an API field, a class name, or an issue describes it. --- -## System Overview +## Evidence and provenance -```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 -``` +Two terms PARTHA uses precisely, because the difference decides how far a claim can be trusted. + +- **Evidence** is the source artifact supporting a repository fact: a file, declaration, import, route, or configuration entry. +- **Provenance** identifies where the fact came from: repository revision, path, symbol, line span, and extraction method. -Repository Intelligence is the source of truth. Downstream features consume it; they should not re-parse repositories independently. +**PARTHA currently provides only partial evidence and provenance.** Facts carry the *file* they came from. They do not carry line spans, they do not record which extraction method produced them, and they are not addressed to a specific revision — the commit is recorded on the repository, not on the fact. + +So PARTHA can tell you *which file* a fact came from. It cannot yet tell you which line, from which revision, or how the fact was derived. It does not provide exact file-symbol-line-commit traceability, complete citations, fully evidence-backed AI answers, a persistent semantic knowledge graph, or language-aware extraction. Treat heuristic output as a lead to verify, not a guarantee. --- -## Architecture +## How the system works -PARTHA is a monorepo with a React frontend and FastAPI backend. +A repository is parsed once at ingestion. Repository Intelligence is built from that parse, persisted, and read back by every surface. ```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 clone"] + Ingest["Ingestion and parsing
storage · repository parser"] + RI["Repository Intelligence
shared repository-understanding layer"] + Consumers["Architecture · Dependencies
Reviews · Documentation · Exports"] + AI["AI
optional consumer"] + + Input --> Ingest --> RI --> Consumers + RI -.-> AI ``` -### Subsystems +Consumers transform Repository Intelligence into their own response shapes. None of them opens repository files or re-parses the tree, and the export pipeline is a second-order consumer that renders analysis output that already exists. -| 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 rule.** If a feature needs a repository fact, add reusable extraction to `app/intelligence/`. Do not build a second parser inside a consumer. -```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 -``` - -Provider implementations own HTTP requests, authentication, response parsing, and error normalization. They do not read repository files or rebuild analysis. - -### Export Architecture Boundary +### Runtime shape ```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] + UI["Frontend
React · Vite · TypeScript"] + API["REST API
FastAPI"] + RI["Repository Intelligence"] + DB[("SQLite local
PostgreSQL via Compose")] + Disk[("Local filesystem")] + + UI --> API --> RI + RI --> DB + RI --> Disk ``` -The export pipeline consumes existing analysis output. It does not re-analyse repositories. +Deeper detail lives in [System Overview](docs/architecture/SYSTEM_OVERVIEW.md) and [Repository Intelligence](docs/architecture/REPOSITORY_INTELLIGENCE.md). --- -## Repository Workflow +## Repository structure -```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 +```text +PARTHA/ +├── apps/ +│ ├── backend/ FastAPI backend +│ │ ├── app/ +│ │ │ ├── api/ Routes and dependency wiring +│ │ │ ├── intelligence/ Repository Intelligence engine and models +│ │ │ ├── parsers/ File-tree parser +│ │ │ ├── analysis/ Architecture modelling (consumer) +│ │ │ ├── graph/ Dependency graph construction (consumer) +│ │ │ ├── review/ Engineering review (consumer) +│ │ │ ├── ai/ Orchestration, context, prompts, providers (consumer) +│ │ │ ├── reports/ Report model, renderers, export service +│ │ │ ├── auth/ Password hashing, tokens, auth service +│ │ │ ├── core/ Config, database, logging, rate limiting, security headers +│ │ │ ├── models/ SQLAlchemy models +│ │ │ └── storage/ Local repository and upload storage +│ │ ├── alembic/ Database migrations +│ │ └── tests/ Backend tests +│ └── frontend/ React frontend +│ └── src/ +│ ├── app/ Shell, router, pages, stores +│ ├── features/ Domain features +│ └── shared/ API clients, UI, config, types, utilities +├── docs/ Public documentation +├── packages/ Reserved for future shared packages +├── scripts/ Local workflow helpers +└── docker-compose.yml Local development stack ``` --- -## 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. | - ---- - -## Quick Start - -### Prerequisites +## Getting started | 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 | +| Node.js | 22 | | Git | Required for GitHub repository import | +| Docker | Optional, for the local Compose stack | + +### Backend -### Clone and Install +The backend defaults to SQLite and local filesystem storage, so it starts with no PostgreSQL and no Redis. ```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 is then on `http://localhost:8000` — OpenAPI docs at `/docs`, readiness at `/ready`. -- frontend: `http://localhost:5173` -- backend docs: `http://localhost:8000/docs` -- readiness: `http://localhost:8000/ready` -- metrics: `http://localhost:8000/metrics` +No `.env` file is required for local development: every setting has a working default. Copy `apps/backend/.env.example` only when you want to change one. ---- - -## Local Development - -Common commands: +### Frontend ```bash +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`. - ---- - -## Docker - -Validate the Docker Compose configuration: +The app is then on `http://localhost:5173` and expects the backend on `http://localhost:8000`. Register an account through the UI, then sign in. -```bash -npm run docker:config -``` +### Docker Compose (local development only) -Run Compose with runtime readiness validation: +Compose runs the API against PostgreSQL and Redis for local development. It is not deployment guidance. ```bash -npm run docker:validate +npm run docker:config # validate the Compose file +npm run docker:up # start the local stack +npm run docker:validate # start, wait for /ready, tear down ``` -Start the Compose stack for local infrastructure: - -```bash -npm run docker:up -``` - -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 (planned) | Symbol extraction today is regex-based; tree-sitter is a planned foundation for deeper, line-accurate language-aware extraction and is not yet wired for parsing. | -| 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. | +## Testing ---- - -## 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 +```bash +npm run test:backend # backend tests (pytest) +npm --prefix apps/frontend run test # frontend tests (vitest) +npm run lint:frontend # eslint +npm run build:frontend # tsc -b && vite build ``` -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. | - ---- - -## Roadmap +`npm run build` runs the frontend build and the backend tests; it does not run frontend lint or frontend tests, so run those separately. CI runs all of the above plus a Docker Compose check. -### Current Milestone - -Vrrently building the foundation for Repository Intelligence and Engineering Intelligence: - -- 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. - -### Future Milestones - -| 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. | - -### Current Non-Goals - -PARTHA does not yet provide public multi-user SaaS controls, vulnerability scanning, deep semantic change-impact analysis, or full OpenTelemetry tracing. +Backend coverage is the stronger of the two. Frontend coverage is thin and there is no end-to-end suite, so passing tests indicate the covered paths work rather than overall maturity. --- -## Contributing +## Limitations -PARTHA welcomes focused engineering contributions that preserve the Repository Intelligence boundary. - -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. +- **Use only in a trusted local environment.** Multi-user authentication and owner isolation are not consistently enforced across every backend surface. PARTHA is not production-ready and should not be exposed to untrusted users or the public internet. +- **Extraction is heuristic.** File roles, modules, and layers are inferred from paths and filenames; symbols come from regular expressions. Expect wrong answers on projects that do not follow common conventions, and do not treat heuristic output as guaranteed fact. +- **Evidence and provenance are partial.** File-level only — no line spans, no per-fact extraction method, no revision-addressed facts. +- **No persistent semantic graph.** Repository facts are serialized as JSON onto the repository row rather than into a queryable graph store. +- **Analysis is synchronous and whole-repository.** No background jobs, no incremental re-analysis. +- **No change-impact analysis, and no vulnerability or outdated-dependency scanning.** +- **AI answers are not evidence-backed.** They are grounded in repository structure and file paths only, with no citations. Treat them as a hypothesis to verify. --- -## Project Identity - -PARTHA is the public product name. +## Contributing and security -The internal expansion is: +- **[CONTRIBUTING.md](CONTRIBUTING.md)** — the contribution rules: fork-first workflow, claiming an issue, branch naming, rebasing, pull requests, and the Definition of Ready and Done. Read it before opening a PR. +- **[SECURITY.md](SECURITY.md)** — report a vulnerability privately. Never open a public issue for one. +- **[CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md)** — expected conduct. +- **[docs/README.md](docs/README.md)** — documentation index. -> Platform for Architecture, Repository Intelligence, Transformation & Heuristic Analysis +Before changing analysis behaviour, read [Repository Intelligence](docs/architecture/REPOSITORY_INTELLIGENCE.md). That boundary is the one architectural rule this project will not bend on. -The expansion explains the project origin, but the public brand should remain simple: **PARTHA — Engineering Intelligence Platform**. +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 licensed under the Apache License 2.0. See [LICENSE](LICENSE) for the full text. diff --git a/apps/backend/README.md b/apps/backend/README.md index 7ad6b2bc..a2f28187 100644 --- a/apps/backend/README.md +++ b/apps/backend/README.md @@ -1,30 +1,65 @@ # 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. +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). -## Local Development +## 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 ``` -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`). + +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. + +`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 +python -c "import secrets; print(secrets.token_urlsafe(64))" +``` + +## Tests + +```bash +python -m pytest # from apps/backend +npm run test:backend # from the repository root +``` + +Tests run against per-test SQLite and the in-memory rate limiter. Three tests are gated on real services — refresh-token concurrency (PostgreSQL) and the Redis rate-limit backend — and skip unless `PARTHA_TEST_PG_URL` and `PARTHA_TEST_REDIS_URL` are set. CI provides both. + +## 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. -Useful system endpoints: +## System endpoints | 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. | +| `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 in containers. 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 -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. +`/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. + +**The API does not currently require authentication.** A presented Bearer token is always validated strictly — a bad token is a 401, never a silent downgrade — but a request with *no* token falls back to a shared seed user. Additionally, only the `/repositories` routes are owner-scoped; the analysis, AI, documentation, and export routes resolve a repository by ID with no owner check. Do not expose this backend to untrusted users. See [SYSTEM_OVERVIEW.md](../../docs/architecture/SYSTEM_OVERVIEW.md#current-architectural-limitations). ## Docker @@ -33,12 +68,14 @@ cd ../.. docker compose up --build ``` -Swagger UI is available at `http://localhost:8000/docs`. +Compose runs the API against PostgreSQL and Redis. It does not run the frontend. -## First Import Flow +## First import ```bash curl -X POST http://localhost:8000/repositories/github \ -H "Content-Type: application/json" \ -d '{"url":"https://github.com/octocat/Hello-World"}' ``` + +Only public GitHub HTTPS URLs are accepted. Ingestion and analysis run **synchronously** inside the request — a large repository will block until the clone, parse, and analysis finish. diff --git a/apps/frontend/README.md b/apps/frontend/README.md index a3af7732..5d1a45cc 100644 --- a/apps/frontend/README.md +++ b/apps/frontend/README.md @@ -1,22 +1,48 @@ # 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). ## 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. + +## 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 ``` + +From the repository root: `npm run dev:frontend`, `npm run lint:frontend`, `npm run build:frontend`. There is no root alias for the frontend tests — 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 + +The suite currently covers the auth store, API client, error mapping, route guard, and a few utilities. Feature and page coverage is thin and there is no end-to-end suite. New frontend behaviour should come with tests; this is an area where contributions are especially welcome. diff --git a/docs/README.md b/docs/README.md index e4ce7c8d..4aece958 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,58 +1,35 @@ # PARTHA Documentation -This directory contains durable product, architecture, operations, audit, and brand documentation for PARTHA. +Every document listed here is maintained and describes the system as it currently exists. -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. | +| [SECURITY](../SECURITY.md) | Anyone reporting a vulnerability | How to disclose privately. Never open a public issue for a vulnerability. | +| [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.** | +| [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. | +| [Packages README](../packages/README.md) | All contributors | The shared-packages directory. | + +## Reading paths + +**New contributor** — [README](../README.md) → [CONTRIBUTING](../CONTRIBUTING.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. 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..c167a748 --- /dev/null +++ b/docs/architecture/REPOSITORY_INTELLIGENCE.md @@ -0,0 +1,192 @@ +# 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 + +Concretely, it is one Pydantic model — `RepositoryIntelligence` ([`app/intelligence/models.py`](../../apps/backend/app/intelligence/models.py)) — built by one engine — `RepositoryIntelligenceEngine` ([`app/intelligence/engine.py`](../../apps/backend/app/intelligence/engine.py)) — and serialized onto the repository row. + +```mermaid +flowchart LR + Root["Repository on disk
extracted archive or clone"] + Parser["RepositoryParser
file tree + metadata"] + Engine["RepositoryIntelligenceEngine.build()"] + Model["RepositoryIntelligence"] + Store[("repositories.repo_metadata
intelligence key · JSON column")] + Consumers["Consumers"] + + Root --> Parser --> Engine --> Model --> Store + Store -->|"from_record()"| Consumers +``` + +--- + +## Where parsing happens + +Exactly two places in the backend read repository source from disk: + +1. **`RepositoryParser`** walks the extracted tree and produces `FileTreeNode[]` plus `RepositoryMeta` (languages, framework guess, entry point, counts, README/license presence). +2. **`RepositoryIntelligenceEngine`** reads individual file contents during `build()` — capped at 512 KB per file — to extract imports, exports, routes, symbols, and technology hints, and reads dependency manifests from the repository root. + +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. + +### `TreeSitterParser` is a placeholder + +`app/parsers/tree_sitter_parser.py` is **not functional**. It maps a file extension to a language name and always returns an empty symbol list. `tree-sitter` is a declared dependency but is not wired into parsing. All symbol extraction today is regex-based, inside `RepositoryIntelligenceEngine`. Do not read the class name as a promise of syntax-aware parsing. + +--- + +## What is currently extracted + +| Field | Contents | How it is derived | +| --- | --- | --- | +| `metadata` | Parser repository metadata. | From `RepositoryParser`. | +| `discovery` | Primary language, language counts, frameworks, package managers, config/env/Docker/CI files, entry points, build systems, database technologies, cloud providers, statistics. | Filename matching, dependency-name lookup tables, and substring scans of file text. | +| `files` | Per-file: path, module, language, extension, size, role, imports, exports, API routes, symbols, technologies. | Regex over file text; role from path/filename conventions. | +| `modules` | Grouped modules with role, layer, path prefix, files, symbols, dependencies. | Files grouped by a derived `module_id`; role is the most common file role; layer is a lookup from role. | +| `symbols` | Functions, classes, interfaces, types, enums, constants, routes. | Regex per language. **Python and TypeScript/JavaScript only.** | +| `dependencies` | Name, version, type, ecosystem, source file. | `package.json`, `requirements.txt`, `pyproject.toml`. | +| `graph` | Serializable nodes and relationships. | Assembled from the above. | + +### 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; +- file counts and folder counts; +- presence of README, license, Dockerfiles, CI workflow files, env files; +- dependency names and version specifiers **as declared in** the three supported manifests; +- literal import statements and route decorator strings matched by the regexes. + +**Heuristic** — an inference that can be wrong, and is wrong on projects that do not follow common conventions: + +- **file role** (`service`, `route`, `model`, `repository`, …) — inferred from path segments and filename substrings. A file whose path merely contains `test` is classified as a test. +- **module grouping and layer** — derived from role and the first meaningful path segment, not from any real module system. +- **symbols** — regex matches. They will match text inside comments and strings, and will miss anything the pattern does not anticipate (decorated definitions, nested classes, arrow-function exports, non-Python/TS languages entirely). +- **frameworks, database technologies, cloud providers** — substring scans over file text. The word `redis` in a comment is enough to report Redis. +- **primary language and entry point** — parser guesses. + +**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 + +At import, the engine builds `RepositoryIntelligence` and `RepositoryService` serializes it into the repository row: + +```text +repositories.repo_metadata["intelligence"] -- entire model, JSON +repositories.repo_metadata["commitSha"] -- git HEAD SHA, or "sha256:..." of the uploaded archive +repositories.file_tree -- parsed tree, JSON +``` + +Consumers call `RepositoryIntelligenceEngine.from_record(record)`, which returns the persisted model if present and **rebuilds it from disk as a fallback** if it is missing or fails validation. + +**There are no graph tables.** The knowledge graph is a JSON blob inside a metadata column. It cannot be queried, indexed, joined, or partially updated — it is read and written whole. + +--- + +## The knowledge graph + +Node types: `repository`, `module`, `file`, `symbol`, `dependency`. + +Relationship types declared in the model: `contains`, `imports`, `exports`, `depends_on`, `calls`, `extends`, `implements`, `references`. + +**Only the first four are ever emitted.** `calls`, `extends`, `implements`, and `references` exist in the type union, but nothing produces them. Their presence in the model is not a guarantee that the data exists — do not build a feature that assumes they are populated. + +Each relationship carries an `evidence` list — which currently holds **file paths only**, not line spans. + +--- + +## Consumers + +| Consumer | Module | Reads | +| --- | --- | --- | +| Architecture | `app/analysis/` | modules, files, discovery | +| Dependency graph | `app/graph/` | dependencies, `depends_on` relationships | +| Engineering review | `app/review/` | discovery, statistics, file roles and sizes | +| Documentation | `app/services/documentation_service.py` | discovery, files, routes, architecture, dependencies | +| AI | `app/ai/repository_context.py` | discovery, modules, dependencies, file paths | +| Reports and exports | `app/reports/` | existing 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 `RepositoryIntelligence` into a response shape. That is all. + +--- + +## Evidence and provenance + +Two terms with distinct meanings. PARTHA uses them precisely, and supports neither of them completely. + +- **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 + +**Evidence: partial.** Graph relationships and engineering-review findings carry the **file paths** they were derived from. That is real evidence, and it is enough to point a reader at the right file. + +**Provenance: incomplete.** Specifically: + +- **No line spans.** `SourceSymbol` has `id`, `name`, `kind`, `file_path`, and `exported`. It has **no start or end line**. Nothing in the model records where in a file a fact was found. +- **No extraction method on the fact.** A consumer cannot tell whether a given fact was matched deterministically or inferred heuristically. That distinction lives in this document, not in the data. +- **Revision identity is coarse.** `commitSha` (the git HEAD SHA, or a `sha256:` content hash for uploads) is stored on the **repository row**, not on the `RepositoryIntelligence` model or on any individual fact. Facts are not addressed to a revision, and re-importing does not version them. + +The honest summary: **PARTHA can tell you which file a fact came from. It cannot tell you which line, from which revision, or how the fact was derived.** + +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). + +Do not describe PARTHA as having evidence-backed or grounded output until line spans, per-fact extraction method, and revision-addressed facts actually exist. + +--- + +## Current limitations + +- **Symbols:** regex-derived, Python and TS/JS only, no line spans, no signatures, no nesting, no cross-file resolution. Matches inside comments and strings are not excluded. +- **Line spans:** not extracted anywhere in the system. +- **Graph persistence:** a JSON blob on a metadata column. No graph tables, no queryability, no incremental update. +- **Relationships:** four of the eight declared types are never emitted. An import edge resolves to a declared dependency when the name matches and otherwise creates an `external:` node — there is no real module resolution. +- **Revision identity:** recorded per repository, not per fact. No history, no diffing, no re-analysis on change. +- **Dependencies:** three manifest formats, no lockfiles, no transitive resolution, and no vulnerability or outdated data (those API fields are hardcoded to `false`/`0`). +- **Languages:** meaningful extraction covers Python and TypeScript/JavaScript. 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, synchronously, inside the HTTP request. + +--- + +## Contributing to the engine + +1. Add the fact to the model in `app/intelligence/models.py`. +2. Extract it in `app/intelligence/engine.py`. +3. Cover it with a test in `apps/backend/tests/test_repository_intelligence.py`. +4. Consume it in the feature that needed 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/SYSTEM_OVERVIEW.md b/docs/architecture/SYSTEM_OVERVIEW.md new file mode 100644 index 00000000..428d6349 --- /dev/null +++ b/docs/architecture/SYSTEM_OVERVIEW.md @@ -0,0 +1,240 @@ +# 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. There is no message queue, no background worker, and no external analysis service. + +```mermaid +flowchart LR + subgraph Client + UI["Frontend — React 18 · Vite · TypeScript
apps/frontend"] + end + + subgraph Server["Backend — FastAPI · apps/backend"] + MW["Middleware
rate limit · security headers · CORS · request ID"] + Routes["Routes
app/api/routes/"] + Services["Services
app/services/"] + RI["Repository Intelligence
app/intelligence/"] + Consumers["Consumers
analysis · graph · review · ai · reports"] + end + + subgraph Persistence + DB[("Relational DB
SQLite local · PostgreSQL Compose")] + Disk[("Filesystem
STORAGE_PATH")] + end + + subgraph External + GH["GitHub
git clone over HTTPS"] + LLM["AI providers
OpenAI · Anthropic · Gemini · OpenRouter · Ollama"] + end + + UI --> MW --> Routes --> Services + Services --> RI + Services --> Consumers + Consumers --> RI + RI --> 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. `TreeSitterParser` is a **placeholder** — it maps extensions to language names and always returns zero symbols. | Produce feature-specific output. | +| `analysis/` | Architecture model — modules, layers, edges, request-flow hints. **Consumer.** | Read the filesystem. | +| `graph/` | Dependency graph response model. **Consumer.** | Re-read dependency manifests. | +| `review/` | Engineering review findings, scores, roadmap. **Consumer.** | Re-read the filesystem. | +| `ai/` | Context builder, prompt builder, orchestrator, provider registry/factory, and five provider implementations. **Consumer.** | Parse repositories or read source files. | +| `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/` | **Empty placeholder package.** No background jobs exist. | — | + +--- + +## Ingestion flow + +Both entry points converge on the same path: land the source on disk, parse it, build Repository Intelligence, persist everything on one row. + +```mermaid +sequenceDiagram + participant UI as Frontend + participant API as FastAPI route + participant Repo as RepositoryService + participant Store as LocalStorage + participant Parser as RepositoryParser + participant RI as RepositoryIntelligenceEngine + 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;
upload and clone size caps enforced + Store-->>Repo: repository root on disk + Repo->>Parser: parse(root) + Parser-->>Repo: FileTreeNode[] + RepositoryMeta + size + Repo->>RI: build(...) + RI-->>Repo: RepositoryIntelligence + Repo->>DB: insert row (metadata + file_tree + serialized intelligence + commitSha) + DB-->>UI: RepositoryResponse +``` + +This runs **synchronously inside the HTTP request**. A large repository blocks a worker for the whole clone-parse-analyse duration. There is no job queue and no progress streaming; the `analysisStage` and `analysisProgress` fields on the row are set at fixed points, not driven by a real background pipeline. + +`POST /analysis/{id}/start` then re-runs the consumers (architecture, dependencies, review) and marks the row complete — also synchronously. + +--- + +## Persistence boundaries + +| Store | Holds | Notes | +| --- | --- | --- | +| Relational DB | `users`, `refresh_tokens`, `repositories` | SQLite by default for local development; PostgreSQL under Docker Compose. Three Alembic migrations. | +| `repositories.repo_metadata` (JSON column) | Parser metadata, `commitSha`, and the **entire serialized Repository Intelligence** under the `intelligence` key. | There are **no graph tables**. The knowledge graph is a JSON blob on this column. | +| `repositories.file_tree` (JSON column) | The parsed file tree. | Serves the explorer. | +| Filesystem (`STORAGE_PATH`) | Extracted archives and cloned repositories; uploaded archives (deleted after extraction); `ai-provider.json`. | Repository source is read from here on demand for file preview. | + +`ai-provider.json` is a **single global file** (mode `0600`), not per-user. Whichever provider config was saved last is the one every user's queries run against. + +--- + +## 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. + +**The enforcement gap.** The frontend requires a session for every route. The backend does not. `get_current_user_or_default` validates a presented Bearer token strictly (a bad token is a 401, never a silent downgrade), but when **no** token is presented it falls back to a fixed seed user (`00000000-…-0000`). Only `/auth/me` uses the strict `get_current_user`. So the API remains open to unauthenticated callers, and all anonymous traffic shares one owner bucket. + +--- + +## 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.** + +Only two places in the backend read repository source from disk: + +1. `RepositoryParser` / `RepositoryIntelligenceEngine`, at build time. +2. `RepositoryService.read_file`, which serves the explorer's file preview — a direct, path-checked read for display only. It feeds no analysis. + +Everything else calls `RepositoryIntelligenceEngine.from_record(record)` and transforms the result. See [REPOSITORY_INTELLIGENCE.md](REPOSITORY_INTELLIGENCE.md) for what the engine extracts and what it does not. + +### Current consumers + +| Consumer | Reads | Produces | +| --- | --- | --- | +| `analysis/` | modules, files, discovery | Architecture nodes, edges, layers, request-flow hints | +| `graph/` | dependencies, `depends_on` relationships | Dependency graph response | +| `review/` | discovery, statistics, file roles and sizes | Findings, category scores, roadmap | +| `services/documentation_service.py` | discovery, files, routes, architecture, dependencies | Markdown / HTML documentation | +| `ai/repository_context.py` | discovery, modules, dependencies, file paths | `RepositoryContext` → `PromptBundle` | +| `reports/` | existing 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 deployment; none required. | AI workspace is unusable until a provider is configured; the rest of the system is unaffected. | +| PostgreSQL, Redis | Compose and CI only. 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"] + Engine["Parse + Repository Intelligence"] + 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 +``` + +- **Uploaded archives and cloned repositories are untrusted input.** Extraction rejects path traversal and symlink escape; 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. +- **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 not fully enforced.** See the limitations below — this is the most important trust gap in the system today. + +--- + +## Current architectural limitations + +These are properties of the system as built, not a wish list. + +1. **Owner isolation is not enforced across every surface.** Only the `/repositories` routes are owner-scoped (`get_for_owner` / `list_for_owner`). `AnalysisService`, `AiOrchestrator`, `DocumentationService`, and `ExportService` resolve a repository through the unscoped `RepositoryRepository.get(id)`, so there is no owner check on those paths and no tenant isolation. Contributors touching these services must use the owner-scoped accessors. +2. **Authentication is not enforced at the API.** Requests without a token are attributed to a shared seed user rather than rejected. PARTHA should therefore be run only in a trusted local environment. +3. **Extraction is heuristic, not language-aware.** File roles, modules, and layers are inferred from path segments and filenames. Symbols come from regular expressions. `TreeSitterParser` returns nothing, even though `tree-sitter` is a declared dependency. +4. **No line-level provenance.** Facts carry a file path and nothing finer. Revision identity (`commitSha`) lives on the repository row, not on the facts. +5. **The knowledge graph is not persisted as a graph.** It is a JSON blob on `repo_metadata`. It cannot be queried, indexed, or joined. Four of the eight declared relationship types are never emitted. +6. **Processing is synchronous and whole-repository.** No background jobs, no incremental re-analysis, no cancellation. +7. **AI provider configuration is global rather than per-user.** A single stored configuration serves every caller. +8. **Dependency coverage is narrow.** Three manifest formats, no lockfiles, no transitive resolution; the vulnerability and outdated fields in the API are constants, not scan results. +9. **Frontend assurance is thin.** Coverage is limited and there is no end-to-end suite. + +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-hero.svg b/docs/assets/partha-hero.svg index 99450191..1562e1ff 100644 --- a/docs/assets/partha-hero.svg +++ b/docs/assets/partha-hero.svg @@ -1,96 +1,86 @@ - - 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 a self-hosted Repository Intelligence Platform for private and evolving codebases. Repository sources are ingested into a shared repository-understanding layer, which architecture, dependency, review, and documentation surfaces consume. AI is an optional downstream consumer. + - - - - + + + + - - - - - + + + + - - - - - - + + + - - - - - - - + + + + + - - + + - - - - - + + PARTHA + + Repository Intelligence Platform + Persistent understanding for private and evolving codebases - - - - - + - - - - - - - - - - - + + + + + - - - - - Repository Intelligence - Architecture Models - Engineering Outputs - + + + + + + + - - - - - - - - - - - - + SOURCES + + Archives + + Git repositories + + + SHARED LAYER + Repository + Intelligence + + - - - - - - - + + + + + - - PARTHA - Engineering Intelligence Platform - Transform Repositories into Actionable Engineering Intelligence + CONSUMERS + + Architecture + + Dependencies + + Reviews + + Documentation + + AI (optional) + 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/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 808b119c..00000000 --- a/docs/operations/production-deployment.md +++ /dev/null @@ -1,87 +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. | -| `MAX_CLONE_SIZE_BYTES` | Maximum on-disk size of a cloned GitHub repository (default 500 MiB). Over-limit clones are aborted and cleaned up. | - -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/planning/DEFINITION_OF_DONE.md b/docs/planning/DEFINITION_OF_DONE.md deleted file mode 100644 index f08511bb..00000000 --- a/docs/planning/DEFINITION_OF_DONE.md +++ /dev/null @@ -1,111 +0,0 @@ -# Definition of Ready & Definition of Done - -Two checklists that gate work into and out of the sprint. Extracted from -[`PRODUCTION_READINESS_PLAN.md`](./PRODUCTION_READINESS_PLAN.md) §2.6 so they can be -referenced directly from an issue or a pull request. - -- **Definition of Ready (DoR)** gates an issue *into* the sprint — it decides when an - issue may be labeled `ready` and assigned. -- **Definition of Done (DoD)** gates a pull request *out* — it decides when a PR may - merge to `dev`. - -These complement, and do not replace, [`CONTRIBUTING.md`](../../CONTRIBUTING.md) and the -[pull request template](../../.github/pull_request_template.md). Where the PR template -asks "did you?", this document defines what a correct answer looks like. - ---- - -## Definition of Ready - -An issue is `ready` — assignable, and pullable into a sprint — when **all** of the -following are true. Anything unchecked means the issue is still `needs-design` or -`blocked`, not `ready`. - -- [ ] **Clear acceptance criteria.** The issue states what must be true for it to be - closed, in terms someone other than the author can verify. Not "improve - parsing" but "parser emits an edge for every resolved import; test proves it." -- [ ] **Area is set.** Exactly one of `area/backend`, `area/frontend`, `area/ai`, - `area/infra`, `area/db`, `area/docs` (§2.3). -- [ ] **Priority is set.** `P0`–`P3`. -- [ ] **Milestone is set.** One of `M1 — Foundations`, `M2 — Real Intelligence`, - `M3 — Operate It`, `M4 — Launch Polish` (§2.2). -- [ ] **Dependencies are linked.** Blocking issues are referenced, and the issue - carries `blocked` if any of them are still open. -- [ ] **Design is agreed** — required if the work touches **API shape, persistence, or - security**. The decision, and the alternatives rejected, live in a comment on the - issue itself, so the trail is readable by whoever picks it up next. - -> Issue type (Epic / Feature / Task / Bug) is a native GitHub issue type, not a label -> (§2.1). Sub-issues inherit their parent's Project and Milestone automatically. - -### Ready in one line - -> Someone who did not write this issue could pick it up, know when they are finished, -> and know who to ask about the one decision that was already made. - ---- - -## Definition of Done - -A pull request may merge to `dev` when **all** of the following are true. - -### Correctness - -- [ ] **Acceptance criteria met.** Every criterion on the linked issue is satisfied. If - one was dropped or changed, the issue is updated to say so — silently shipping a - narrower scope than the issue promised is not done. -- [ ] **Tests added or updated.** New behavior gets a test; changed behavior gets its - test changed. A bug fix gets a test that fails without the fix. -- [ ] **CI is green.** The `Frontend`, `Backend`, and `Docker Compose` jobs pass. Once - branch protection lands (§2.5), CodeQL and Dependabot join them as required - checks. - -### Code quality - -- [ ] **No new `any` in TypeScript.** If a type is genuinely unknown, use `unknown` and - narrow it. An `any` that must ship carries a comment explaining why. -- [ ] **No new broad `except:` in Python.** Catch the exception you can actually handle. - A bare or `except Exception:` clause that must ship re-raises or logs with context, - and says why it is broad. - -### Documentation & safety - -- [ ] **Docs updated if behavior changed.** API shape, env vars, setup steps, and - operational runbooks. If a reader of the docs would now be wrong, the docs change - in the same PR. -- [ ] **No secrets committed.** No API keys, tokens, `.env` files, credentials, or - local databases — in the diff or anywhere in the branch's history. -- [ ] **No build artifacts committed.** No `dist/`, no `*.tsbuildinfo`, no - `__pycache__/`, no coverage output. - -### Reachability - -- [ ] **The feature is reachable through the UI**, or it is **explicitly documented as - internal**. Backend capability that no user can reach, and that no document - admits is internal-only, is not done — it is a half-finished feature that reads as - a finished one. - -### Done in one line - -> The acceptance criteria hold, CI proves it, a reader of the docs would not be misled, -> nothing secret or generated is in the diff, and a user can actually get to it. - ---- - -## Applying this - -- **Weekly planning** (§2.7) is where issues are triaged against the DoR and labeled - `ready`. An issue that fails the DoR is not "almost ready" — it goes back with the - missing piece named. -- **Review** is where the DoD is enforced. A reviewer may block on any unchecked box. -- Keep PRs under **~400 lines of diff** (§2.7). A PR too large to review against this - checklist is too large to merge; split the epic into task-sized PRs. -- PRs touching `core/`, auth, or migrations require **two** approving reviews (§2.5). - -## See also - -- [`PRODUCTION_READINESS_PLAN.md`](./PRODUCTION_READINESS_PLAN.md) — milestones (§2.2), - labels (§2.3), ownership (§2.4), branch protection (§2.5), cadence (§2.7). -- [`CONTRIBUTING.md`](../../CONTRIBUTING.md) — branch strategy, Conventional Commits, - squash merge, issue-assignment flow. -- [`.github/CODEOWNERS`](../../.github/CODEOWNERS) — who reviews which area. diff --git a/docs/planning/PRODUCTION_READINESS_PLAN.md b/docs/planning/PRODUCTION_READINESS_PLAN.md deleted file mode 100644 index 712f4bf6..00000000 --- a/docs/planning/PRODUCTION_READINESS_PLAN.md +++ /dev/null @@ -1,407 +0,0 @@ -# PARTHA — Path to Production & Team Working Structure - -_A plan for taking PARTHA — the **Engineering Intelligence Platform** ("transform repositories into actionable engineering intelligence; understand systems, assess change impact, and make engineering decisions with confidence") — from a fast-moving MVP to a production-ready platform, and for structuring the work as the team grows from 2 to 5–7 experienced engineers._ - -Status as of Jul 9, 2026. Owners: Shaurya, Parth. - -> **Update (this revision):** PARTHA has been repositioned from "AI-powered software architecture intelligence platform" to **"Engineering Intelligence Platform"**, with new brand assets (`docs/brand/VISUAL_IDENTITY.md`, hero/logo SVGs) and a `docs/product/PUBLIC_FACE_AUDIT.md`. A "production readiness baseline" has also merged into `dev` (PRs #30/#31). Importantly, that baseline is the **docs + observability scaffolding** layer — not the security hardening — so the P0 keystones below (E1 auth, E2 security) remain fully open. Shipped-vs-open status is now marked per epic in §3. - ---- - -## 1. Where we are, where "production-ready" is - -PARTHA today is a structurally complete MVP: import → parse → intelligence → product features works end to end. Backend is ~4.4k LOC of FastAPI, frontend ~8.3k LOC of React/TS, 66 backend tests, CI with lint/build/pytest/compose smoke, Alembic migrations, and an observability module already scaffolded. - -The honest gap between "works in a demo" and "production-ready" is four things we do **not** have yet: - -1. **No identity.** No users, no auth, no multi-tenancy. Every deploy is single-tenant and open. This is the single biggest blocker to real users. -2. **No security posture.** No rate limiting, no security headers, CORS not locked down, AI provider secrets not encrypted at rest, no dependency/secret scanning gate. -3. **Intelligence is still heuristic.** The persisted knowledge graph — the thing the whole architecture is designed around — is still "in progress." Architecture/dependency/review/docs are all self-labeled "Partial." -4. **Thin reliability & test safety net.** Frontend has **zero** tests. No error tracking, no SLOs, no staging environment, no rollback story beyond "redeploy." - -"Production-ready" for PARTHA = a user can sign in, import their repo, trust the output, and we can operate it safely (observe it, roll it back, keep their data and secrets safe). Everything below serves that definition. - -> **Housekeeping — `main` is stale, `dev` is the real trunk.** As of this revision, `dev` sits ~18 commits and 158 files ahead of `main` (the production-readiness baseline, AI providers, reports/export, intelligence engine, and ops docs all live on `dev`). The earlier "whole tree modified" mess is resolved on the remote — it landed cleanly via PRs #30/#31; any local churn is CRLF/filemode noise from the mount. The real risk now: **anyone cloning `main` gets almost none of the platform.** Before onboarding, either promote `dev` → `main` on a regular cadence or make `dev` the default branch (see §2.2/§2.5). Also: **issue creation is currently restricted on the repo** — lift that (or pre-create the issues yourselves) before contributors arrive. - ---- - -## 2. Team working structure - -You already have a strong `CONTRIBUTING.md` (dev/main/feature branches, Conventional Commits, squash merge, issue-assignment flow, PR template). Keep all of it. The additions below are what a 5–7 person team needs that a 2-person team can skip. - -### 2.1 Plan in a hierarchy, track in one board - -Adopt GitHub's now-GA **issue types + sub-issues** instead of the `[AI 1]` / `[AI 1.3]` naming convention. It gives you the same hierarchy natively, and sub-issues inherit the parent's Project and Milestone automatically. - -``` -Epic (issue type: Epic) e.g. "Authentication & Multi-Tenancy" - └─ Feature (issue type: Feature) "Email/password + session auth" - └─ Task (issue type: Task) "Add User model + Alembic migration" - └─ Task "Add JWT issue/verify + refresh rotation" - └─ Bug (issue type: Bug) -``` - -Run **one GitHub Project (v2)** for the whole team with these views: -- **Board** (Todo / In Progress / In Review / Done) — daily driver. -- **Table** grouped by Milestone — release planning. -- **Roadmap** — the timeline for stakeholders / new contributors. - -Custom fields to add to the Project: `Priority` (P0–P3, you already use this), `Area`, `Estimate` (S/M/L), `Sprint`. - -### 2.2 Milestones = releases - -Move from continuous merging to **milestone-based delivery** so a growing team pulls in the same direction. Proposed milestones (details in §3): - -| Milestone | Theme | Definition of "shipped" | -| --- | --- | --- | -| `M1 — Foundations` | Auth, security baseline, clean repo, frontend test harness | A user can sign in; the app is not open to the internet | -| `M2 — Real Intelligence` | Persisted knowledge graph, dependency edges, evidence-backed review | Outputs are trustworthy, not heuristic guesses | -| `M3 — Operate It` | Observability, error tracking, staging, deploy + rollback | We can run it safely and see when it breaks | -| `M4 — Launch Polish` | Finish "Partial" features, AI workspace epic, E2E coverage | End-to-end story is demo-perfect and covered by tests | - -Timebox each to ~3–4 weeks. Don't start M2 area-work before M1's auth boundary exists — features built without auth get re-plumbed later. - -### 2.3 Labels (consolidated taxonomy) - -Keep it small and orthogonal. One label per axis: - -- **Area:** `area/backend` `area/frontend` `area/ai` `area/infra` `area/db` `area/docs` -- **Type:** use native issue types (Epic/Feature/Task/Bug) instead of type labels. -- **Priority:** `P0`–`P3` (already in your template). -- **Status/flow:** `blocked` `needs-design` `ready` (`ready` = spec'd, assignable). -- **Contributor-facing** (for when the 3–5 arrive): `good-first-issue` `help-wanted`. - -### 2.4 Ownership as you scale - -Add a `CODEOWNERS` file so PRs auto-request the right reviewer. Assign areas to people, not everything to you two: - -``` -# .github/CODEOWNERS -/apps/backend/app/ai/ @ai-owner -/apps/backend/app/parsers/ @intelligence-owner -/apps/backend/app/graph/ @intelligence-owner -/apps/frontend/ @frontend-owner -/apps/backend/app/core/ @shaurya @parth # infra/config stays with founders -/.github/ @shaurya @parth -``` - -Rule of thumb for experienced hires: give each new person **one Area to own** (they become the default reviewer and decision-maker there), not a stream of disconnected tickets. Ownership scales; task-assignment doesn't. - -### 2.5 Branch protection & required checks - -Now that non-founders will push, enforce in GitHub settings what `CONTRIBUTING.md` currently only asks politely: -- Protect `main` and `dev`: no direct pushes, require PR. -- Require the `Frontend`, `Backend`, and `Docker Compose` CI jobs to pass before merge. -- Require **1 approving review** (2 for anything touching `core/`, auth, or migrations). -- Require branches up to date with `dev` before merge; require linear history (you already squash). -- Add **Dependabot** + **CodeQL** (or GitHub Advanced Security) as required status checks — this is part of the security baseline anyway. - -### 2.6 Definition of Ready / Definition of Done - -Put these in the repo (e.g. `docs/planning/DEFINITION_OF_DONE.md`) and gate on them. - -**Definition of Ready** (before an issue is assignable / labeled `ready`): -- Clear acceptance criteria; area + priority + milestone set; dependencies linked; design agreed if it touches API/persistence/security. - -**Definition of Done** (before a PR merges): -- Acceptance criteria met; tests added/updated and CI green; no new `any` / broad excepts; docs updated if behavior changed; no secrets/build artifacts committed; feature reachable through the UI or documented as internal. - -### 2.7 Cadence & communication - -- **Weekly planning** (30–45 min): triage new issues → `ready`, pull the next slice into the sprint, confirm milestone burn-down. -- **Async standups** in the Project (or a `#standup` channel): what moved, what's blocked. No daily meeting needed for experienced devs. -- **PR SLA:** first review within one working day; keep PRs < ~400 lines of diff — split epics into task-sized PRs. -- Keep all design discussion **on the issue**, per your existing philosophy, so new contributors can read the decision trail. - ---- - -## 3. Production-readiness roadmap (the epics) - -These are the "solid new issues" to create, grouped by milestone. Each is an **Epic**; the ready-to-paste child issues are in §4. PARTHA's existing open AI epic (#14 and its 1.3–1.8 subtasks) folds into **M4**. - -**Status legend:** 🔴 not started · 🟡 partially shipped · 🟢 done. - -### M1 — Foundations -- 🔴 **E1. Authentication & Multi-Tenancy** — user model, sign-up/sign-in, sessions/JWT with refresh rotation, per-user data scoping. _The keystone; almost everything else assumes it._ **Confirmed absent on `dev` — no auth/user modules exist yet.** -- 🔴 **E2. Security Baseline** — rate limiting (slowapi + Redis), security-headers middleware, CORS lockdown, encrypted-at-rest storage for AI provider keys, Dependabot + CodeQL + secret scanning in CI. **Not started; the "readiness baseline" that shipped did not include any of this.** -- 🟡 **E3. Repo Hygiene & Frontend Test Harness** — build-artifact hygiene is **done** on `dev` (stopped tracking `dist/`/`tsbuildinfo`, improved monorepo `.gitignore`). **Still open:** promote `dev`→`main` cadence, and the frontend test harness (Vitest + RTL) — frontend still has zero tests. - -### M2 — Real Intelligence -- 🔴 **E4. Persisted Knowledge Graph** — the core in-progress work: a real graph model (nodes/edges/artifacts) persisted in Postgres, replacing per-feature heuristics as the single source of truth. _An `intelligence/engine.py` exists but outputs are still heuristic/in-memory — the persisted graph is not built yet._ -- 🔴 **E5. Dependency Graph Depth** — resolve real edges between manifests, add outdated + vulnerability signals, surface a true dependency graph (not just an inventory). -- 🔴 **E6. Evidence-Backed Engineering Review** — expand rule depth and make every finding cite concrete files/lines from the graph. _(A `test_review_evidence.py` now exists — evidence scaffolding is beginning.)_ - -### M3 — Operate It _(partially underway via the readiness baseline)_ -- 🟡 **E7. Observability & Error Tracking** — `apps/backend/app/core/observability.py` and `docs/operations/observability.md` have **shipped** (scaffolding). **Still open:** wire it to real metrics + traces (OpenTelemetry), add error tracking (Sentry), and define 2–3 user-facing SLOs + burn-rate alerts. -- 🟡 **E8. Deploy Pipeline & Environments** — a `.github/workflows/release.yml`, `docs/operations/production-deployment.md`, and `release-management.md` have **shipped**, and Compose already uses Postgres. **Still open:** an actual staging environment, container registry, automated deploy, verified rollback, and DB backups. -- 🔴 **E9. Background Work Robustness** — make ingestion of large repos async and resilient (timeouts, retries, idempotency, progress) so the API stays responsive. - -### M4 — Launch Polish -- **E10. AI Workspace Completion** — absorb existing issues #14, #32–#37 (provider config/secrets, context retrieval, streaming, persistence, citations, hardening). -- **E11. Finish the "Partial" Surfaces** — Insights backend endpoint, real Settings/account, deep-link search, documentation HTML/export quality. -- **E12. End-to-End Test Coverage** — Playwright E2E for the golden path (import → explore → review → AI), plus API contract tests. - ---- - -## 4. Ready-to-paste issues - -Formatted for your `engineering_task.yml` template (Task / Rationale / References / Implementation Notes / Acceptance Criteria / Priority). Create the Epics first, then attach the Tasks as **sub-issues**. This is a starter set for **M1 + the start of M2** — the highest-leverage work. Repeat the pattern for later milestones. - ---- - -### EPIC E1 — Authentication & Multi-Tenancy -**Type:** Epic · **Area:** backend, frontend, db · **Priority:** P0 · **Milestone:** M1 - -**Goal.** Introduce identity so PARTHA can support real, isolated users. Every repository, analysis, and provider secret becomes owned by a user (or workspace). No feature should read or write data outside the current user's scope. - -**Why now.** The platform is currently open and single-tenant. Auth is a prerequisite for security hardening (E2), per-user provider keys, conversation persistence (#35), and any real deployment. Building more features before this means re-plumbing every table and route later. - -**Child issues:** E1.1–E1.5 below. - -> Optional accelerator: the workspace has the Auth0 skill set installed (`auth0-fastapi-api`, `auth0-react`). If you'd rather not own auth infrastructure, Auth0 can provide login + JWT issuance and we only validate tokens + scope data. Decide build-vs-buy in this epic before starting E1.2. - ---- - -#### E1.1 — Add User model and auth tables + migration -**Type:** Task · **Area:** backend, db · **Priority:** P0 - -**Task.** Add `User` (and, if we choose workspaces, `Workspace` / `Membership`) SQLAlchemy models and an Alembic migration. Add `owner_id` foreign keys to `Repository` and any other user-scoped tables. - -**Rationale.** Everything in this epic depends on a persisted identity and an ownership column to scope queries by. - -**References.** `apps/backend/app/models/`, `apps/backend/alembic/versions/`, `apps/backend/app/repositories/`. - -**Implementation Notes.** Use UUID primary keys for users. Hash passwords with `argon2` or `bcrypt` (never store plaintext). Decide single-user-ownership vs. workspaces up front — changing later is a painful migration. Backfill existing rows to a system/seed user. - -**Acceptance Criteria.** -- `User` model + migration merged; `upgrade`/`downgrade` both run clean. -- `Repository` (and other user data) carry an `owner_id` FK. -- Repository queries filter by owner; a repo request test proves cross-user access returns 404/403. -- Tests added; CI green. - ---- - -#### E1.2 — Implement JWT issue/verify with refresh-token rotation -**Type:** Task · **Area:** backend · **Priority:** P0 - -**Task.** Add auth endpoints (`/auth/register`, `/auth/login`, `/auth/refresh`, `/auth/logout`) issuing short-lived access tokens and rotating refresh tokens. Add a `get_current_user` dependency. - -**Rationale.** Provides the session mechanism the frontend and all protected routes need. - -**References.** `apps/backend/app/api/routes/`, `apps/backend/app/api/deps.py`, `apps/backend/app/core/config.py`. - -**Implementation Notes.** Short access-token TTL (~15 min) + rotating refresh token stored hashed and revocable. Sign with a secret from config/env (never hardcoded). Consider httpOnly cookies for the web client to avoid XSS token theft. If we chose Auth0 in E1, this task becomes "validate Auth0 JWTs + map to local user" instead. - -**Acceptance Criteria.** -- Register/login/refresh/logout work end to end with tests. -- `get_current_user` rejects missing/expired/invalid tokens with 401. -- Refresh rotation invalidates the used refresh token; reuse is detected and rejected. -- No secrets committed; CI green. - ---- - -#### E1.3 — Protect existing routes and scope data to the current user -**Type:** Task · **Area:** backend · **Priority:** P0 - -**Task.** Require authentication on all repository/analysis/ai/documentation/reports routes and filter every query by `owner_id`. - -**Rationale.** Auth is worthless if existing endpoints still serve everyone's data. - -**References.** `apps/backend/app/api/routes/*.py`, `apps/backend/app/services/*.py`. - -**Implementation Notes.** Add the `get_current_user` dependency at the router level where possible. Push ownership filtering into the repository/service layer, not individual handlers, so it can't be forgotten. Return 404 (not 403) for other users' resources to avoid leaking existence. - -**Acceptance Criteria.** -- All non-public routes return 401 without a valid token. -- Tests prove user A cannot read/mutate user B's repositories, analyses, docs, or reports. -- CI green. - ---- - -#### E1.4 — Frontend auth flow (login/register, token handling, guarded routes) -**Type:** Task · **Area:** frontend · **Priority:** P0 - -**Task.** Add login/register pages, token/session handling in the API client, an auth store, and route guards that redirect unauthenticated users. - -**Rationale.** Users need a way to actually sign in; protected pages must not render for anonymous users. - -**References.** `apps/frontend/src/app/routes/router.tsx`, `apps/frontend/src/app/store/useAppStore.ts`, `apps/frontend/src/shared/services/api/client.ts`. - -**Implementation Notes.** Centralize auth in the API client (attach token, refresh on 401, redirect on refresh failure). Prefer httpOnly cookies if E1.2 uses them. Wire the existing Settings "account" placeholders to real user data. - -**Acceptance Criteria.** -- Unauthenticated users are redirected to login from guarded routes. -- Login persists a session across refresh; logout clears it. -- 401 triggers a silent refresh, then redirect if that fails. -- A component/integration test covers the guard. - ---- - -#### E1.5 — Encrypt and store AI provider keys per user -**Type:** Task · **Area:** backend, ai · **Priority:** P1 - -**Task.** Persist each user's AI provider API keys encrypted at rest and inject them per-request instead of relying on a single global env key. - -**Rationale.** Multi-tenant AI requires per-user keys; storing them in plaintext or sharing one global key is a security and billing problem. Directly unblocks AI epic #32 (Provider Configuration & Secret Management). - -**References.** `apps/backend/app/ai/providers/`, `apps/backend/app/core/config.py`, issue #32. - -**Implementation Notes.** Encrypt with a KMS or a Fernet key sourced from env; never return keys in API responses (write-only field, show last-4 only). Scope key lookup by `owner_id`. - -**Acceptance Criteria.** -- Provider keys are stored encrypted and never serialized back to the client in full. -- AI requests use the current user's key; missing key returns a clear, actionable error. -- Tests cover encrypt/decrypt round-trip and the no-leak contract; CI green. - ---- - -### EPIC E2 — Security Baseline -**Type:** Epic · **Area:** backend, infra · **Priority:** P0 · **Milestone:** M1 - -**Goal.** Establish the minimum security posture before exposing PARTHA to real traffic: rate limiting, security headers, locked-down CORS, and automated dependency/secret scanning. - -**Why now.** "Security is one of the fastest ways to lose production trust." These are cheap to add now and expensive to retrofit after an incident. - -**Child issues:** E2.1–E2.3. - ---- - -#### E2.1 — Add rate limiting (Redis-backed) -**Type:** Task · **Area:** backend, infra · **Priority:** P0 - -**Task.** Add per-IP and per-user rate limiting, with tighter budgets on expensive routes (ingestion, AI). - -**Rationale.** Prevents abuse and runaway AI cost; a baseline production requirement. - -**References.** `apps/backend/app/main.py`, `apps/backend/app/core/redis.py` (Redis already present). - -**Implementation Notes.** `slowapi` (or an ASGI middleware) backed by the existing Redis. Sensible defaults globally, stricter on `/analyze`, `/ai`, and archive upload. Return `429` with `Retry-After`. - -**Acceptance Criteria.** -- Exceeding the limit returns 429 with `Retry-After`; a test proves it. -- AI and ingestion endpoints have stricter, separately-configured budgets. -- Limits are configurable via env; CI green. - ---- - -#### E2.2 — Security headers + CORS lockdown -**Type:** Task · **Area:** backend · **Priority:** P1 - -**Task.** Add a security-headers middleware and replace permissive CORS with an explicit allowlist from config. - -**Rationale.** Missing headers and open CORS are common, easily-scanned production failures. - -**References.** `apps/backend/app/main.py`, `apps/backend/app/core/config.py`. - -**Implementation Notes.** Set `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`, `Content-Security-Policy`, and `Strict-Transport-Security`. CORS origins come from an env allowlist; no `*` with credentials. - -**Acceptance Criteria.** -- Responses carry the security headers (asserted in a test). -- CORS rejects unlisted origins; allowed origins come from config. -- CI green. - ---- - -#### E2.3 — Dependency, secret, and code scanning in CI -**Type:** Task · **Area:** infra · **Priority:** P1 - -**Task.** Enable Dependabot, CodeQL, and secret scanning; make them required checks on `main`/`dev`. - -**Rationale.** Automated supply-chain and secret detection is table stakes and part of the merge gate. - -**References.** `.github/workflows/ci.yml`, `.github/` config. - -**Implementation Notes.** Add `dependabot.yml` for npm + pip. Add a CodeQL workflow for JS/TS + Python. Enable push-protection secret scanning in repo settings. Triage the initial findings backlog as follow-up issues. - -**Acceptance Criteria.** -- Dependabot opens update PRs; CodeQL runs on PRs; secret scanning is on. -- These checks are required before merge (branch protection). -- Initial critical/high findings are triaged into issues. - ---- - -### EPIC E3 — Repo Hygiene & Frontend Test Harness -**Type:** Epic · **Area:** frontend, infra · **Priority:** P1 · **Milestone:** M1 - -**Goal.** Make `main` clean and trustworthy for new contributors, and stand up the missing frontend test capability. - -**Why now.** The frontend has zero tests and the working tree/build artifacts are messy. Both undermine confidence exactly when new people join. - -**Child issues:** E3.1–E3.2. - ---- - -#### E3.1 — Establish `dev`→`main` release cadence + green fresh clone -**Type:** Task · **Area:** infra · **Priority:** P1 · _(build-artifact hygiene already done on `dev`)_ - -**Task.** `dist/`/`tsbuildinfo` are no longer tracked and the monorepo `.gitignore` is improved — that part is **done**. Remaining work: fix that `main` is ~158 files behind `dev`, and verify a fresh clone of the trunk builds and tests green with documented steps. - -**Rationale.** New contributors judge a project by whether the default branch runs on day one. Right now cloning `main` gets almost none of the platform. - -**References.** `.gitignore`, `README.md` Getting Started, `.github/workflows/release.yml`. - -**Implementation Notes.** Decide: either make `dev` the default branch, or open a `dev`→`main` promotion PR now and repeat it every milestone. Then confirm README setup steps work from a clean clone on a fresh machine/container. - -**Acceptance Criteria.** -- Default/trunk branch reflects the current platform (no large unexplained gap between it and `dev`). -- A documented `dev`→`main` promotion ritual exists (or `dev` is the default branch). -- Fresh clone → documented setup → frontend build + backend tests pass. - ---- - -#### E3.2 — Stand up Vitest + React Testing Library with coverage gate -**Type:** Task · **Area:** frontend · **Priority:** P1 - -**Task.** Add Vitest + RTL, write first real tests (an API client behavior + a guarded route or a core hook), and add the frontend test job to CI with a coverage floor. - -**Rationale.** The frontend currently has no safety net; as more people touch it, regressions will ship silently. - -**References.** `apps/frontend/`, `.github/workflows/ci.yml`. - -**Implementation Notes.** Start the coverage floor low (e.g. 20%) and ratchet up per milestone. Prioritize testing the API client, auth guard, and feature hooks over presentational components. - -**Acceptance Criteria.** -- `npm --prefix apps/frontend run test` runs in CI and is required. -- At least 3 meaningful tests exist (client/hook/guard). -- Coverage threshold enforced and documented. - ---- - -### EPIC E4 — Persisted Knowledge Graph _(M2 — start of "real intelligence")_ -**Type:** Epic · **Area:** backend, db, ai · **Priority:** P0 · **Milestone:** M2 - -**Goal.** Deliver the persisted knowledge graph that `CONTRIBUTING.md` and the README describe as the single source of truth — a real model of nodes (modules, files, services), edges (imports, calls, dependencies), and artifacts, persisted and queryable, that architecture/dependency/review/docs/AI/search all read from. - -**Why now.** It's the core in-progress item and the reason the current outputs are "heuristic." Every "Partial" feature upgrades to "reliable" once it reads from a shared graph instead of re-deriving structure. This is what actually takes PARTHA to the next level technically — do it right after the M1 auth/security boundary exists so the graph is user-scoped from day one. - -**Suggested child issues.** (spec these in the epic before starting) -- E4.1 Define the graph schema + persistence (Postgres tables or a graph store) and migration. -- E4.2 Populate the graph from the existing parser/intelligence engine during ingestion. -- E4.3 Migrate the architecture view to read modules/edges from the graph. -- E4.4 Migrate engineering review to cite graph-backed evidence (feeds E6 and issue #36 citations). -- E4.5 Expose a graph query API the AI context retrieval (#33) consumes. - -**Acceptance Criteria (epic-level).** -- A persisted, user-scoped graph is produced on ingestion and survives restarts. -- At least two product surfaces (architecture + review) read from the graph rather than recomputing. -- Documented schema + query API; tests cover graph build and a cross-feature read. - ---- - -## 5. Suggested first two weeks - -1. **Day 1–2:** E3.1 (fix `main`↔`dev`: promote or switch default) + set up branch protection, CODEOWNERS, the Project board, issue types, and milestones. Lift the issue-creation restriction. Onboarding surface ready before anyone joins. -2. **Decide build-vs-buy on auth** (E1 note) — this unblocks the whole M1 critical path. -3. **Parallelize M1:** one owner on E1 (auth), one on E2 (security), a new hire on E3.2 (frontend tests) as a scoped, self-contained on-ramp. -4. Only after the auth boundary lands, open E4 (knowledge graph) design discussion on its epic issue. - -_Progress already banked (don't re-scope): build-artifact hygiene, observability + deployment/release **docs and scaffolding**, the AI provider architecture, and reports/export. The gap to production is the security boundary (E1/E2) and turning the shipped ops scaffolding into live metrics/alerts/staging (E7/E8)._ - ---- - -## Sources - -- [Evolving GitHub Issues and Projects (GA)](https://github.blog/changelog/2025-04-09-evolving-github-issues-and-projects/) · [Adding sub-issues](https://docs.github.com/en/issues/tracking-your-work-with-issues/using-issues/adding-sub-issues) · [Best practices for Projects](https://docs.github.com/en/issues/planning-and-tracking-with-projects/learning-about-projects/best-practices-for-projects) -- [A Practical Guide to FastAPI Security](https://davidmuraya.com/blog/fastapi-security-guide/) · [FastAPI production deployment best practices (Render)](https://render.com/articles/fastapi-production-deployment-best-practices) · [API Security Best Practices for Production (OneUptime)](https://oneuptime.com/blog/post/2026-02-20-api-security-best-practices/view) -- [Production Readiness Checklist for Web Applications — 2026 (Rootcode)](https://www.rootcode.in/blog/production-readiness-checklist-for-web-applications-the-2026-guide-mr33mw4l) · [Production readiness checklist (getDX)](https://getdx.com/blog/production-readiness-checklist/) · [The Ultimate SRE Reliability Checklist (OneUptime)](https://oneuptime.com/blog/post/2025-09-10-sre-checklist/view) -- Internal: `README.md`, `CONTRIBUTING.md`, `docs/audit/`, repository issues #14, #32–#37. 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/scripts/README.md b/scripts/README.md index f3d3ce5c..1471ff4b 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -1,8 +1,12 @@ # 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` | +| `validate-compose.mjs` | Validates the Docker Compose config, starts the stack, waits for `/ready`, then tears it down. | `npm run docker:validate` | +| `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 container/process manager | +| `check-backend.sh` | Runs the backend test suite (`pytest`). Same venv preference; override with `PYTHON=…`. | Directly | + +The two shell scripts are standalone equivalents of the Node helpers, for environments where invoking `node` first is inconvenient. From 5b2c3b7b864a7976a927787c2d3406c02e516fd6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 12 Jul 2026 14:19:05 +0000 Subject: [PATCH 044/347] chore(deps): bump sonner from 1.7.4 to 2.0.7 in /apps/frontend Bumps [sonner](https://github.com/emilkowalski/sonner) from 1.7.4 to 2.0.7. - [Release notes](https://github.com/emilkowalski/sonner/releases) - [Commits](https://github.com/emilkowalski/sonner/commits/v2.0.7) --- updated-dependencies: - dependency-name: sonner dependency-version: 2.0.7 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- apps/frontend/package-lock.json | 8 ++++---- apps/frontend/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/frontend/package-lock.json b/apps/frontend/package-lock.json index 8ed34620..4f8e046d 100644 --- a/apps/frontend/package-lock.json +++ b/apps/frontend/package-lock.json @@ -19,7 +19,7 @@ "react-dom": "^18.3.1", "react-dropzone": "^14.2.9", "react-router-dom": "^6.26.0", - "sonner": "^1.5.0", + "sonner": "^2.0.7", "tailwind-merge": "^2.5.2", "tailwindcss-animate": "^1.0.7", "zustand": "^4.5.5" @@ -4572,9 +4572,9 @@ "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.7", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", + "integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==", "license": "MIT", "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 490c8113..1d109426 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -23,7 +23,7 @@ "react-dom": "^18.3.1", "react-dropzone": "^14.2.9", "react-router-dom": "^6.26.0", - "sonner": "^1.5.0", + "sonner": "^2.0.7", "tailwind-merge": "^2.5.2", "tailwindcss-animate": "^1.0.7", "zustand": "^4.5.5" From b77f07d997b150f29d47f9501ca66bcf07036c97 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 13 Jul 2026 11:39:20 +0000 Subject: [PATCH 045/347] chore(deps-dev): bump the frontend-minor-and-patch group across 1 directory with 3 updates Bumps the frontend-minor-and-patch group with 3 updates in the /apps/frontend directory: [eslint](https://github.com/eslint/eslint), [postcss](https://github.com/postcss/postcss) and [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite). Updates `eslint` from 10.6.0 to 10.7.0 - [Release notes](https://github.com/eslint/eslint/releases) - [Commits](https://github.com/eslint/eslint/compare/v10.6.0...v10.7.0) Updates `postcss` from 8.5.16 to 8.5.19 - [Release notes](https://github.com/postcss/postcss/releases) - [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md) - [Commits](https://github.com/postcss/postcss/compare/8.5.16...8.5.19) Updates `vite` from 8.1.3 to 8.1.4 - [Release notes](https://github.com/vitejs/vite/releases) - [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md) - [Commits](https://github.com/vitejs/vite/commits/v8.1.4/packages/vite) --- updated-dependencies: - dependency-name: eslint dependency-version: 10.7.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: frontend-minor-and-patch - dependency-name: postcss dependency-version: 8.5.17 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: frontend-minor-and-patch - dependency-name: vite dependency-version: 8.1.4 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: frontend-minor-and-patch ... Signed-off-by: dependabot[bot] --- apps/frontend/package-lock.json | 28 ++++++++++++++-------------- apps/frontend/package.json | 6 +++--- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/apps/frontend/package-lock.json b/apps/frontend/package-lock.json index 4f8e046d..78b007c4 100644 --- a/apps/frontend/package-lock.json +++ b/apps/frontend/package-lock.json @@ -33,16 +33,16 @@ "@vitejs/plugin-react": "^6.0.3", "@vitest/coverage-v8": "^4.1.10", "autoprefixer": "^10.4.20", - "eslint": "^10.6.0", + "eslint": "^10.7.0", "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.3", "globals": "^17.7.0", "jsdom": "^29.1.1", - "postcss": "^8.4.45", + "postcss": "^8.5.19", "tailwindcss": "^3.4.10", "typescript": "^5.5.4", "typescript-eslint": "^8.63.0", - "vite": "^8.1.3", + "vite": "^8.1.4", "vitest": "^4.1.10" } }, @@ -2579,9 +2579,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.7.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.7.0.tgz", + "integrity": "sha512-GVTD7s1vdIl6UYvAfriOPeY1Df8LIZjfofLvHwde+erDHGGuHyuM6xoxRxmHiebhYuD2p1vN4wWh0XzPARSGDQ==", "dev": true, "license": "MIT", "workspaces": [ @@ -4066,9 +4066,9 @@ } }, "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.19", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz", + "integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==", "funding": [ { "type": "opencollective", @@ -5027,16 +5027,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.1.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.4.tgz", + "integrity": "sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", - "picomatch": "^4.0.4", + "picomatch": "^4.0.5", "postcss": "^8.5.16", - "rolldown": "~1.1.3", + "rolldown": "~1.1.4", "tinyglobby": "^0.2.17" }, "bin": { diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 1d109426..37cf5376 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -37,16 +37,16 @@ "@vitejs/plugin-react": "^6.0.3", "@vitest/coverage-v8": "^4.1.10", "autoprefixer": "^10.4.20", - "eslint": "^10.6.0", + "eslint": "^10.7.0", "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.3", "globals": "^17.7.0", "jsdom": "^29.1.1", - "postcss": "^8.4.45", + "postcss": "^8.5.19", "tailwindcss": "^3.4.10", "typescript": "^5.5.4", "typescript-eslint": "^8.63.0", - "vite": "^8.1.3", + "vite": "^8.1.4", "vitest": "^4.1.10" }, "overrides": { From bacb9514c09b2a9165106be153fc3abc36fa1a4c Mon Sep 17 00:00:00 2001 From: parthrohit22 Date: Wed, 15 Jul 2026 09:11:29 +0100 Subject: [PATCH 046/347] feat(backend): enforce auth + owner scoping and encrypt provider keys per user (#63, #65) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #63 (E1.3) — protect routes and scope data to the current user: - Add get_current_user at the router level for repositories/analysis/ai/ documentation/export, so every non-public route requires a valid token and a newly added route is protected by default. - Push owner_id into AnalysisService, DocumentationService, ExportService and the AI orchestrator; all repository lookups now go through the owner-scoped accessors and return 404 (not 403) for another user's resource. - Remove the pre-auth get_current_user_or_default fallback, the X-Dev-User header, and the unscoped RepositoryRepository accessors so ownership can't be forgotten. - resolve_rate_key now keys on the validated authenticated user id (verified via the server's signing key) and falls back to the direct client IP for unauthenticated/forged-token requests; X-Forwarded-For stays ignored. #65 (E1.5) — encrypt and store AI provider keys per user: - New ai_provider_configs table (Alembic 0004, downgrades cleanly): one row per owner, API key stored only as Fernet ciphertext plus its last four chars. - ProviderKeyCipher (Fernet) with AI_ENCRYPTION_KEY from config; required outside development/test, derived deterministically in dev/test. - EncryptedProviderConfigStore is owner-scoped; keys are injected per request and never serialised back to the client in full (last-4 only). Missing key returns a clear error. The old global ai-provider.json store is removed. Tests: cross-route 401/404/200 authorization sweep derived from the router table, per-user rate-limit identity, and the encrypt/decrypt round-trip + no-leak contract. Existing route tests updated to authenticate. Co-Authored-By: Claude Opus 4.8 --- apps/backend/.env.example | 4 + .../versions/0004_ai_provider_configs.py | 46 ++++ apps/backend/app/ai/__init__.py | 11 +- apps/backend/app/ai/orchestrator.py | 72 ++----- apps/backend/app/ai/providers/config_store.py | 142 +++++++++++++ apps/backend/app/api/deps.py | 83 +++----- apps/backend/app/api/routes/ai.py | 7 +- apps/backend/app/api/routes/analysis.py | 5 +- apps/backend/app/api/routes/documentation.py | 5 +- apps/backend/app/api/routes/reports.py | 6 +- apps/backend/app/api/routes/repositories.py | 7 +- apps/backend/app/core/config.py | 36 ++++ apps/backend/app/core/crypto.py | 40 ++++ apps/backend/app/core/rate_limit.py | 58 ++++-- apps/backend/app/models/__init__.py | 3 +- apps/backend/app/models/ai_provider_config.py | 34 +++ apps/backend/app/models/user.py | 9 +- apps/backend/app/reports/export_service.py | 4 +- .../app/repositories/repository_repository.py | 28 +-- apps/backend/app/schemas/ai.py | 4 + apps/backend/app/services/analysis_service.py | 7 +- .../app/services/documentation_service.py | 6 +- apps/backend/pyproject.toml | 1 + apps/backend/tests/conftest.py | 48 ++++- apps/backend/tests/test_ai_api_contract.py | 12 +- apps/backend/tests/test_ai_architecture.py | 8 +- apps/backend/tests/test_ai_providers.py | 1 + apps/backend/tests/test_auth.py | 30 +-- apps/backend/tests/test_documentation_api.py | 20 +- apps/backend/tests/test_export_api.py | 58 +++--- apps/backend/tests/test_ingestion_pipeline.py | 48 ++--- .../tests/test_provider_key_encryption.py | 196 ++++++++++++++++++ apps/backend/tests/test_rate_limit.py | 102 +++++++++ apps/backend/tests/test_repositories_api.py | 8 +- .../backend/tests/test_repository_file_api.py | 58 +++--- .../tests/test_repository_ownership.py | 82 +++++--- .../backend/tests/test_route_authorization.py | 148 +++++++++++++ apps/backend/tests/test_system.py | 6 +- 38 files changed, 1124 insertions(+), 319 deletions(-) create mode 100644 apps/backend/alembic/versions/0004_ai_provider_configs.py create mode 100644 apps/backend/app/ai/providers/config_store.py create mode 100644 apps/backend/app/core/crypto.py create mode 100644 apps/backend/app/models/ai_provider_config.py create mode 100644 apps/backend/tests/test_provider_key_encryption.py create mode 100644 apps/backend/tests/test_route_authorization.py diff --git a/apps/backend/.env.example b/apps/backend/.env.example index 7c6188f1..7f7d3f11 100644 --- a/apps/backend/.env.example +++ b/apps/backend/.env.example @@ -10,6 +10,10 @@ AUTO_CREATE_TABLES=true 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= # Rate limiting: fixed-window budgets per client (per minute). Backend # "memory" is per-process; use "redis" when running multiple workers. RATE_LIMIT_ENABLED=true 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/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 bc979d9e..36c54194 100644 --- a/apps/backend/app/ai/orchestrator.py +++ b/apps/backend/app/ai/orchestrator.py @@ -1,15 +1,15 @@ -import os from datetime import UTC, datetime from app.ai.prompt_builder import PromptBuilder +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.repository_repository import RepositoryRepository from app.schemas.ai import ( AiMessage, + AiProviderConfig, AiProviderPublicConfig, AiProviderTestRequest, AiProviderTestResponse, @@ -18,70 +18,22 @@ ) -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, + 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.owner_id = owner_id def get_config(self) -> AiProviderPublicConfig: return self.config_store.get_public_config() @@ -97,12 +49,20 @@ async def test_connection(self, request: AiProviderTestRequest) -> AiProviderTes return AiProviderTestResponse(ok=True, message=f"{config.provider} connection succeeded.", checked_at=datetime.now(UTC)) 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}) 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 config.provider != "ollama" 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) 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..728e91ed --- /dev/null +++ b/apps/backend/app/ai/providers/config_store.py @@ -0,0 +1,142 @@ +"""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.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: ... + + def save_config(self, config: AiProviderConfig) -> AiProviderPublicConfig: ... + + def read_config(self) -> AiProviderConfig | None: ... + + def config_for_test(self, request: AiProviderTestRequest) -> AiProviderConfig: ... + + +class EncryptedProviderConfigStore: + """Persists one provider configuration per user, key encrypted at rest.""" + + def __init__(self, db: Session, cipher: ProviderKeyCipher, owner_id: str) -> None: + self.db = db + self.cipher = cipher + self.owner_id = owner_id + + 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: + 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 ollama may be saved with no key at all. + """ + if config.api_key: + return self.cipher.encrypt(config.api_key), config.api_key[-4:] + if config.provider != "ollama" 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 config.provider == "ollama": + 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/api/deps.py b/apps/backend/app/api/deps.py index cdb1ebe7..b51ac424 100644 --- a/apps/backend/app/api/deps.py +++ b/apps/backend/app/api/deps.py @@ -1,12 +1,10 @@ -from uuid import uuid4 - -from fastapi import Depends, Header +from fastapi import Depends from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer -from sqlalchemy import select from sqlalchemy.orm import Session -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 from app.ai.providers.anthropic import AnthropicProvider from app.ai.providers.factory import ProviderFactory from app.ai.providers.gemini import GeminiProvider @@ -19,12 +17,13 @@ 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 UnauthorizedError from app.github.client import GitHubClient from app.graph.dependency_graph import DependencyGraphBuilder from app.intelligence.engine import RepositoryIntelligenceEngine -from app.models.user import SEED_USER_EMAIL, SEED_USER_ID, User +from app.models.user import User from app.parsers.repository_parser import RepositoryParser from app.repositories.repository_repository import RepositoryRepository from app.reports.export_service import ExportService @@ -40,27 +39,6 @@ def get_repository_repository(db: Session = Depends(get_db)) -> RepositoryReposi return RepositoryRepository(db) -def _ensure_seed_user(db: Session) -> User: - user = db.get(User, SEED_USER_ID) - if user is None: - user = User(id=SEED_USER_ID, email=SEED_USER_EMAIL) - db.add(user) - db.commit() - db.refresh(user) - return user - - -def _resolve_dev_user(db: Session, email: str) -> User: - normalized = email.strip().lower() - user = db.scalars(select(User).where(User.email == normalized)).first() - if user is None: - user = User(id=str(uuid4()), email=normalized) - db.add(user) - db.commit() - db.refresh(user) - return user - - _bearer_scheme = HTTPBearer(auto_error=False) @@ -90,28 +68,6 @@ def get_current_user( return _user_from_bearer(credentials.credentials, db, settings) -def get_current_user_or_default( - credentials: HTTPAuthorizationCredentials | None = Depends(_bearer_scheme), - db: Session = Depends(get_db), - settings: Settings = Depends(get_settings), - x_dev_user: str | None = Header(default=None, alias="X-Dev-User"), -) -> User: - """Resolve the user for routes that still tolerate anonymous access. - - A presented Bearer token is always validated strictly — sending a bad - token is an authentication attempt and gets a 401, never a silent - fallback. Without a token, the temporary pre-auth behaviour applies: the - ``X-Dev-User`` header selects a user in development/test, and everything - else is attributed to the seed user. E1.3 deletes this fallback (and the - header) once the frontend can sign in, leaving only ``get_current_user``. - """ - if credentials is not None: - return _user_from_bearer(credentials.credentials, db, settings) - if x_dev_user and settings.app_env in {"development", "test"}: - return _resolve_dev_user(db, x_dev_user) - return _ensure_seed_user(db) - - def get_local_storage(settings: Settings = Depends(get_settings)) -> LocalStorage: return LocalStorage(settings) @@ -135,7 +91,7 @@ def get_repository_service( 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_or_default), + current_user: User = Depends(get_current_user), ) -> RepositoryService: return RepositoryService( repository=repository, @@ -166,8 +122,16 @@ def get_engineering_review_builder( return EngineeringReviewBuilder(intelligence) -def get_ai_config_store(settings: Settings = Depends(get_settings)) -> AiProviderConfigStore: - return AiProviderConfigStore(settings) +def get_provider_cipher(settings: Settings = Depends(get_settings)) -> ProviderKeyCipher: + return build_provider_cipher(settings) + + +def get_ai_config_store( + db: Session = Depends(get_db), + cipher: ProviderKeyCipher = Depends(get_provider_cipher), + current_user: User = Depends(get_current_user), +) -> EncryptedProviderConfigStore: + return EncryptedProviderConfigStore(db, cipher, current_user.id) def get_repository_context_builder( @@ -202,6 +166,7 @@ def get_analysis_service( dependencies: DependencyGraphBuilder = Depends(get_dependency_graph_builder), review: EngineeringReviewBuilder = Depends(get_engineering_review_builder), intelligence: RepositoryIntelligenceEngine = Depends(get_repository_intelligence_engine), + current_user: User = Depends(get_current_user), ) -> AnalysisService: return AnalysisService( repository=repository, @@ -209,15 +174,17 @@ def get_analysis_service( dependencies=dependencies, review=review, intelligence=intelligence, + owner_id=current_user.id, ) 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), + current_user: User = Depends(get_current_user), ) -> AiService: orchestrator = AiOrchestrator( repository=repository, @@ -225,6 +192,7 @@ def get_ai_service( context_builder=context_builder, prompt_builder=prompt_builder, provider_factory=provider_factory, + owner_id=current_user.id, ) return AiService(orchestrator) @@ -234,8 +202,15 @@ def get_documentation_service( architecture: ArchitectureAnalyzer = Depends(get_architecture_analyzer), dependencies: DependencyGraphBuilder = Depends(get_dependency_graph_builder), intelligence: RepositoryIntelligenceEngine = Depends(get_repository_intelligence_engine), + current_user: User = Depends(get_current_user), ) -> DocumentationService: - return DocumentationService(repository=repository, architecture=architecture, dependencies=dependencies, intelligence=intelligence) + return DocumentationService( + repository=repository, + architecture=architecture, + dependencies=dependencies, + intelligence=intelligence, + owner_id=current_user.id, + ) def get_export_service( diff --git a/apps/backend/app/api/routes/ai.py b/apps/backend/app/api/routes/ai.py index 2300c045..b5382092 100644 --- a/apps/backend/app/api/routes/ai.py +++ b/apps/backend/app/api/routes/ai.py @@ -3,11 +3,14 @@ from fastapi import APIRouter, Depends from fastapi.responses import StreamingResponse -from app.api.deps import get_ai_service +from app.api.deps import get_ai_service, get_current_user from app.schemas.ai import 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)]) @router.get("/config", response_model=AiProviderPublicConfig) diff --git a/apps/backend/app/api/routes/analysis.py b/apps/backend/app/api/routes/analysis.py index 1912d02c..ad4cc267 100644 --- a/apps/backend/app/api/routes/analysis.py +++ b/apps/backend/app/api/routes/analysis.py @@ -1,13 +1,14 @@ from fastapi import APIRouter, Depends -from app.api.deps import get_analysis_service +from app.api.deps import get_analysis_service, get_current_user from app.schemas.analysis import AnalysisStartResponse, AnalysisStatusResponse from app.schemas.architecture import ArchitectureResponse from app.schemas.dependencies import DependencyGraphResponse from app.schemas.review import EngineeringReviewResponse from app.services.analysis_service import AnalysisService -router = APIRouter(prefix="/analysis", tags=["analysis"]) +# Every analysis route requires auth; records are owner-scoped in AnalysisService. +router = APIRouter(prefix="/analysis", tags=["analysis"], dependencies=[Depends(get_current_user)]) @router.post("/{repository_id}/start", response_model=AnalysisStartResponse) diff --git a/apps/backend/app/api/routes/documentation.py b/apps/backend/app/api/routes/documentation.py index 9c13fbae..37d4173a 100644 --- a/apps/backend/app/api/routes/documentation.py +++ b/apps/backend/app/api/routes/documentation.py @@ -1,10 +1,11 @@ from fastapi import APIRouter, Depends -from app.api.deps import get_documentation_service +from app.api.deps import get_current_user, get_documentation_service 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)]) @router.post("/generate", response_model=GenerateDocResponse) diff --git a/apps/backend/app/api/routes/reports.py b/apps/backend/app/api/routes/reports.py index 8becc5f6..c5df306b 100644 --- a/apps/backend/app/api/routes/reports.py +++ b/apps/backend/app/api/routes/reports.py @@ -1,10 +1,12 @@ from fastapi import APIRouter, Depends -from app.api.deps import get_export_service +from app.api.deps import get_current_user, get_export_service 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)]) @router.post("/export", response_model=ExportResponse) diff --git a/apps/backend/app/api/routes/repositories.py b/apps/backend/app/api/routes/repositories.py index 5ba8b676..1fd914a3 100644 --- a/apps/backend/app/api/routes/repositories.py +++ b/apps/backend/app/api/routes/repositories.py @@ -1,6 +1,6 @@ from fastapi import APIRouter, Depends, Query, Response, UploadFile, status -from app.api.deps import get_repository_service +from app.api.deps import get_current_user, get_repository_service from app.schemas.repository import ( GitHubImportRequest, RepositoryFileResponse, @@ -9,7 +9,10 @@ ) 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)]) @router.post("/upload", response_model=RepositoryResponse, status_code=status.HTTP_201_CREATED) diff --git a/apps/backend/app/core/config.py b/apps/backend/app/core/config.py index 15e9d4c1..33f6a836 100644 --- a/apps/backend/app/core/config.py +++ b/apps/backend/app/core/config.py @@ -1,3 +1,6 @@ +import base64 +import binascii +import hashlib import logging from functools import lru_cache from pathlib import Path @@ -14,6 +17,15 @@ # 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" @@ -37,6 +49,12 @@ class Settings(BaseSettings): 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 @@ -149,6 +167,24 @@ def resolve_auto_create_tables(self) -> "Settings": 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"} 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/rate_limit.py b/apps/backend/app/core/rate_limit.py index b0b38f75..c2734d93 100644 --- a/apps/backend/app/core/rate_limit.py +++ b/apps/backend/app/core/rate_limit.py @@ -9,8 +9,9 @@ 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 +from app.core.exceptions import ErrorResponse, UnauthorizedError from app.core.observability import get_request_id, runtime_metrics logger = logging.getLogger(__name__) @@ -44,23 +45,50 @@ def classify(method: str, path: str) -> str | None: return "default" -def resolve_rate_key(request: Request) -> str: - """Identity a budget is charged against: the socket peer address for now. +def _authenticated_user_id(request: Request, settings: Settings) -> str | None: + """The user id from a valid Bearer access token, or None. - E1.3 upgrades this to the authenticated user id when a valid Bearer token - is present, so signed-in users get per-user budgets instead of sharing a - NAT'd address. + 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: this reads ``request.client.host``, the direct TCP - peer. Behind a reverse proxy or load balancer that is the proxy's address, - so every 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. + 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 client.host if client else "unknown" + return f"ip:{client.host if client else 'unknown'}" class StoreUnavailableError(Exception): @@ -178,7 +206,7 @@ async def dispatch( "default": settings.rate_limit_default_per_minute, } limit = budgets[budget_class] - key = f"{budget_class}:{resolve_rate_key(request)}" + key = f"{budget_class}:{resolve_rate_key(request, settings)}" store: RateLimitStore = request.app.state.rate_limit_store try: diff --git a/apps/backend/app/models/__init__.py b/apps/backend/app/models/__init__.py index de8f4342..06da1000 100644 --- a/apps/backend/app/models/__init__.py +++ b/apps/backend/app/models/__init__.py @@ -1,5 +1,6 @@ +from app.models.ai_provider_config import AiProviderConfigRecord from app.models.refresh_token import RefreshToken from app.models.repository import RepositoryRecord from app.models.user import User -__all__ = ["RefreshToken", "RepositoryRecord", "User"] +__all__ = ["AiProviderConfigRecord", "RefreshToken", "RepositoryRecord", "User"] 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..0d15c5f6 --- /dev/null +++ b/apps/backend/app/models/ai_provider_config.py @@ -0,0 +1,34 @@ +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"), 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/user.py b/apps/backend/app/models/user.py index 7fb5b968..1bbf7c58 100644 --- a/apps/backend/app/models/user.py +++ b/apps/backend/app/models/user.py @@ -5,10 +5,11 @@ from app.models.base import Base -# The system/seed user that owns all data created before authentication existed. -# Requests without an authenticated identity fall back to this owner until E1.2 -# introduces real sign-in. The id is fixed so the 0002 migration backfill and the -# current-user seam agree on it; keep both in sync with these constants. +# 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" diff --git a/apps/backend/app/reports/export_service.py b/apps/backend/app/reports/export_service.py index dde25fc6..545d0702 100644 --- a/apps/backend/app/reports/export_service.py +++ b/apps/backend/app/reports/export_service.py @@ -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/repositories/repository_repository.py b/apps/backend/app/repositories/repository_repository.py index b2af6298..ef3752e6 100644 --- a/apps/backend/app/repositories/repository_repository.py +++ b/apps/backend/app/repositories/repository_repository.py @@ -8,10 +8,12 @@ class RepositoryRepository: def __init__(self, db: Session) -> None: self.db = db - # Owner-scoped access. These are the methods user-facing routes must use so a - # request can only ever see its own repositories. The unscoped methods below - # remain for internal callers (analysis/ai/documentation) until E1.3 moves - # them onto the current user, at which point the unscoped variants go away. + # 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) @@ -41,24 +43,6 @@ def find_by_source_for_owner(self, source_url: str, branch: str | None, owner_id ) return self.db.scalars(statement).first() - def list(self) -> list[RepositoryRecord]: - statement = select(RepositoryRecord).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 find_by_name(self, name: str) -> RepositoryRecord | None: - statement = select(RepositoryRecord).where(RepositoryRecord.name == name) - return self.db.scalars(statement).first() - - def find_by_source(self, source_url: str, branch: str | None) -> RepositoryRecord | None: - statement = select(RepositoryRecord).where( - RepositoryRecord.source_url == source_url, - RepositoryRecord.branch == branch, - ) - return self.db.scalars(statement).first() - def add(self, record: RepositoryRecord) -> RepositoryRecord: self.db.add(record) self.db.commit() diff --git a/apps/backend/app/schemas/ai.py b/apps/backend/app/schemas/ai.py index afdc1c62..daad4905 100644 --- a/apps/backend/app/schemas/ai.py +++ b/apps/backend/app/schemas/ai.py @@ -49,6 +49,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): diff --git a/apps/backend/app/services/analysis_service.py b/apps/backend/app/services/analysis_service.py index 9b1bf2f3..d5300ba2 100644 --- a/apps/backend/app/services/analysis_service.py +++ b/apps/backend/app/services/analysis_service.py @@ -20,12 +20,14 @@ def __init__( dependencies: DependencyGraphBuilder, review: EngineeringReviewBuilder, intelligence: RepositoryIntelligenceEngine, + owner_id: str, ) -> None: self.repository = repository self.architecture = architecture self.dependencies = dependencies self.review = review self.intelligence = intelligence + self.owner_id = owner_id def start(self, repository_id: str) -> AnalysisStartResponse: record = self._get_record(repository_id) @@ -84,7 +86,10 @@ def engineering_review(self, repository_id: str) -> EngineeringReviewResponse: return self.review.build(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..35cbed92 100644 --- a/apps/backend/app/services/documentation_service.py +++ b/apps/backend/app/services/documentation_service.py @@ -18,11 +18,13 @@ def __init__( repository: RepositoryRepository, architecture: ArchitectureAnalyzer, dependencies: DependencyGraphBuilder, + owner_id: str, intelligence: RepositoryIntelligenceEngine | None = None, ) -> None: self.repository = repository self.architecture = architecture self.dependencies = dependencies + self.owner_id = owner_id self.intelligence = intelligence or RepositoryIntelligenceEngine() def generate(self, request: GenerateDocRequest) -> GenerateDocResponse: @@ -34,7 +36,9 @@ def build_document(self, repository_id: str) -> ReportDocument: return self._document(self._get_record(repository_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 diff --git a/apps/backend/pyproject.toml b/apps/backend/pyproject.toml index de2fd6b6..b679efe4 100644 --- a/apps/backend/pyproject.toml +++ b/apps/backend/pyproject.toml @@ -14,6 +14,7 @@ dependencies = [ "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", diff --git a/apps/backend/tests/conftest.py b/apps/backend/tests/conftest.py index 0f92dbb4..f21bb495 100644 --- a/apps/backend/tests/conftest.py +++ b/apps/backend/tests/conftest.py @@ -1,10 +1,27 @@ import os -from collections.abc import Generator +from collections.abc import Callable, Generator from pathlib import Path import pytest from fastapi.testclient import TestClient +DEFAULT_TEST_PASSWORD = "correct-horse-battery-staple" + + +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). + """ + 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() def client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Generator[TestClient, None, None]: @@ -40,3 +57,32 @@ def client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Generator[TestCli os.environ.pop("AUTO_CREATE_TABLES", None) os.environ.pop("CORS_ORIGINS", 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/test_ai_api_contract.py b/apps/backend/tests/test_ai_api_contract.py index 3d44fa44..080fa321 100644 --- a/apps/backend/tests/test_ai_api_contract.py +++ b/apps/backend/tests/test_ai_api_contract.py @@ -22,14 +22,14 @@ 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): +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": ( @@ -47,13 +47,13 @@ def test_ai_query_endpoint_preserves_public_response_contract(client): assert upload_response.status_code == 201 repository_id = upload_response.json()["id"] - 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, @@ -80,4 +80,4 @@ def test_ai_query_endpoint_preserves_public_response_contract(client): # 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) diff --git a/apps/backend/tests/test_ai_architecture.py b/apps/backend/tests/test_ai_architecture.py index dc7c008e..da265f03 100644 --- a/apps/backend/tests/test_ai_architecture.py +++ b/apps/backend/tests/test_ai_architecture.py @@ -29,6 +29,7 @@ def _record(root: Path) -> RepositoryRecord: 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), @@ -49,8 +50,10 @@ 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: @@ -136,6 +139,7 @@ async def _query_with_fake_provider(tmp_path: Path): context_builder=RepositoryContextBuilder(RepositoryIntelligenceEngine()), prompt_builder=PromptBuilder(), provider_factory=ProviderFactory(registry), + owner_id="owner-1", ) response = await orchestrator.query(AiQueryRequest(repository_id="repo-1", query="Explain this repo")) diff --git a/apps/backend/tests/test_ai_providers.py b/apps/backend/tests/test_ai_providers.py index 8e17729f..827ad545 100644 --- a/apps/backend/tests/test_ai_providers.py +++ b/apps/backend/tests/test_ai_providers.py @@ -216,6 +216,7 @@ def test_connection_testing_uses_resolved_dedicated_provider(monkeypatch: pytest context_builder=RepositoryContextBuilder(object()), # type: ignore[arg-type] prompt_builder=PromptBuilder(), provider_factory=ProviderFactory(registry), + owner_id="owner-1", ) RecordingAsyncClient.calls = [] diff --git a/apps/backend/tests/test_auth.py b/apps/backend/tests/test_auth.py index 42634203..f6291e24 100644 --- a/apps/backend/tests/test_auth.py +++ b/apps/backend/tests/test_auth.py @@ -270,20 +270,26 @@ def test_auth_secret_key_is_required_and_strong_outside_dev(): 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") + 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") + 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).auth_secret_key == strong + 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 pre-auth routes ----------------------------------------- +# --- interaction with protected routes ---------------------------------------- def test_bearer_token_attributes_repository_routes_to_that_user(client): @@ -313,19 +319,17 @@ def test_bearer_token_attributes_repository_routes_to_that_user(client): assert with_token.status_code == 200 assert with_token.json()["total"] == 1 - anonymous = client.get("/repositories") - assert anonymous.status_code == 200 - assert anonymous.json()["total"] == 0 # seed user owns nothing of alice's - -def test_invalid_bearer_on_tolerant_routes_is_rejected_not_ignored(client): +def test_invalid_bearer_on_protected_routes_is_rejected(client): # Presenting a bad token is an authentication attempt; it must never fall - # back silently to the seed user. + # 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_still_works(client): +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 == 200 - assert response.json() == {"data": [], "total": 0} + assert response.status_code == 401 diff --git a/apps/backend/tests/test_documentation_api.py b/apps/backend/tests/test_documentation_api.py index 7250aa3f..d1f17dd0 100644 --- a/apps/backend/tests/test_documentation_api.py +++ b/apps/backend/tests/test_documentation_api.py @@ -10,8 +10,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) -> str: + response = auth_client.post( "/repositories/upload", files={ "file": ( @@ -31,14 +31,14 @@ def _import_sample(client) -> str: return response.json()["id"] -def _generate(client, repository_id: str, fmt: str): - return client.post("/documentation/generate", json={"repositoryId": repository_id, "format": fmt}) +def _generate(auth_client, repository_id: str, fmt: str): + return auth_client.post("/documentation/generate", json={"repositoryId": repository_id, "format": fmt}) -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"] @@ -46,10 +46,10 @@ def test_documentation_markdown_has_structured_headings(client): assert "## Architecture" 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"] diff --git a/apps/backend/tests/test_export_api.py b/apps/backend/tests/test_export_api.py index bf4aa94c..f0111caf 100644 --- a/apps/backend/tests/test_export_api.py +++ b/apps/backend/tests/test_export_api.py @@ -12,8 +12,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) -> str: + response = auth_client.post( "/repositories/upload", files={ "file": ( @@ -33,17 +33,17 @@ def _import_sample(client) -> str: return response.json()["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() @@ -55,10 +55,10 @@ def test_export_review_json_parses(client): assert isinstance(payload["summary"]["overallScore"], int) -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() @@ -69,10 +69,10 @@ def test_export_review_markdown_has_headings(client): assert "| Overall Score |" 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 +84,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 +97,50 @@ 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_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_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" -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" diff --git a/apps/backend/tests/test_ingestion_pipeline.py b/apps/backend/tests/test_ingestion_pipeline.py index bca15214..b902d900 100644 --- a/apps/backend/tests/test_ingestion_pipeline.py +++ b/apps/backend/tests/test_ingestion_pipeline.py @@ -28,16 +28,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( { @@ -59,11 +59,11 @@ def test_zip_upload_persists_repository_and_analysis_completes(client): # commit-addressability identifier (T9 / F2). assert repository["commitSha"].startswith("sha256:") - start_response = client.post(f"/analysis/{repository['id']}/start") + 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"} - 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" @@ -71,16 +71,16 @@ 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") + 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_tar_gz_upload_is_supported(auth_client): response = _upload( - client, + auth_client, "python-service.tar.gz", _tar_gz_bytes( { @@ -97,8 +97,8 @@ 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() @@ -106,8 +106,8 @@ def test_invalid_archive_returns_backend_validation_error(client): assert body["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() @@ -115,11 +115,11 @@ def test_empty_archive_is_rejected(client): assert body["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 @@ -129,7 +129,7 @@ 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_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") @@ -138,9 +138,9 @@ def fake_clone(_: GitHubClient, __: str, destination: Path, ___: str | None = No 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( + 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"}) + malformed_branch = auth_client.post( "/repositories/github", json={"url": "https://github.com/example/other", "branch": "../main"}, ) @@ -162,9 +162,9 @@ 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_over_size_limit_aborts_and_cleans_up(monkeypatch: pytest.MonkeyPatch, tmp_path: Path): @@ -182,9 +182,9 @@ def fake_run(*args, **kwargs): monkeypatch.setattr(subprocess, "run", fake_run) - client = GitHubClient(Settings(max_clone_size_bytes=1024)) + auth_client = GitHubClient(Settings(max_clone_size_bytes=1024)) with pytest.raises(ValidationServiceError): - client.clone_public_repository("https://github.com/example/demo", destination) + 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_provider_key_encryption.py b/apps/backend/tests/test_provider_key_encryption.py new file mode 100644 index 00000000..8a7e9a2e --- /dev/null +++ b/apps/backend/tests/test_provider_key_encryption.py @@ -0,0 +1,196 @@ +"""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.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_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) + + got = client.get("/ai/config", headers=auth["headers"]).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"]) + assert response.status_code == 422 + body = response.json() + assert body["code"] == "validation_error" + assert "API key is required" in body["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"]) + + response = client.post( + "/ai/query", + json={"repositoryId": repository_id, "query": "Summarize this repo"}, + headers=auth["headers"], + ) + assert response.status_code == 422 + assert "not configured" in response.json()["message"].lower() diff --git a/apps/backend/tests/test_rate_limit.py b/apps/backend/tests/test_rate_limit.py index 5427783f..3e716cf3 100644 --- a/apps/backend/tests/test_rate_limit.py +++ b/apps/backend/tests/test_rate_limit.py @@ -10,10 +10,29 @@ 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)) @@ -77,6 +96,12 @@ def limited_client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Generator Base.metadata.create_all(bind=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): @@ -106,6 +131,83 @@ def test_classify_maps_routes_to_budget_classes(): 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 -------------------------------------------------------------- diff --git a/apps/backend/tests/test_repositories_api.py b/apps/backend/tests/test_repositories_api.py index e95c6670..fe29ae8a 100644 --- a/apps/backend/tests/test_repositories_api.py +++ b/apps/backend/tests/test_repositories_api.py @@ -1,12 +1,12 @@ -def test_list_repositories_starts_empty(client): - response = client.get("/repositories") +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" diff --git a/apps/backend/tests/test_repository_file_api.py b/apps/backend/tests/test_repository_file_api.py index dbfbec0f..eaa90b5b 100644 --- a/apps/backend/tests/test_repository_file_api.py +++ b/apps/backend/tests/test_repository_file_api.py @@ -19,8 +19,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 +34,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 +46,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 +68,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 +81,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 +92,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 +107,17 @@ 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" -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,23 @@ 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" -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_ownership.py b/apps/backend/tests/test_repository_ownership.py index 59faf1db..160d7d83 100644 --- a/apps/backend/tests/test_repository_ownership.py +++ b/apps/backend/tests/test_repository_ownership.py @@ -1,32 +1,29 @@ +"""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 -ALICE = "alice@example.com" -BOB = "bob@example.com" +from tests.conftest import register_user -def _seed_repository(owner_email: str, name: str = "sample-repo") -> str: - """Insert a repository owned by ``owner_email`` directly, bypassing the - import pipeline, and return its id. The current-user seam resolves the same - user by email, so requests carrying that X-Dev-User header own this row.""" +def _seed_repository(owner_id: str, name: str = "sample-repo") -> str: from app.core.database import SessionLocal from app.models.repository import RepositoryRecord - from app.models.user import User db = SessionLocal() try: - owner = db.scalars(select(User).where(User.email == owner_email)).first() - if owner is None: - owner = User(id=str(uuid.uuid4()), email=owner_email) - db.add(owner) - db.commit() - db.refresh(owner) repository_id = str(uuid.uuid4()) db.add( RepositoryRecord( id=repository_id, - owner_id=owner.id, + owner_id=owner_id, name=name, source="upload", local_path=f"/tmp/{repository_id}", @@ -39,44 +36,65 @@ def _seed_repository(owner_email: str, name: str = "sample-repo") -> str: db.close() -def test_get_returns_404_for_another_users_repository(client): - repository_id = _seed_repository(ALICE) +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={"X-Dev-User": BOB}) + denied = client.get(f"/repositories/{repository_id}", headers=bob["headers"]) assert denied.status_code == 404 - allowed = client.get(f"/repositories/{repository_id}", headers={"X-Dev-User": ALICE}) + 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): - _seed_repository(ALICE) +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={"X-Dev-User": ALICE}) + 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={"X-Dev-User": BOB}) + 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): - repository_id = _seed_repository(ALICE) +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={"X-Dev-User": BOB}) + 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={"X-Dev-User": ALICE}) + 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_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_default_seed_user_does_not_see_dev_users_repositories(client): - _seed_repository(ALICE) - # No X-Dev-User header -> the seed user, which owns none of Alice's data. - seed_view = client.get("/repositories") - assert seed_view.status_code == 200 - assert seed_view.json() == {"data": [], "total": 0} +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_route_authorization.py b/apps/backend/tests/test_route_authorization.py new file mode 100644 index 00000000..75af62ee --- /dev/null +++ b/apps/backend/tests/test_route_authorization.py @@ -0,0 +1,148 @@ +"""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.conftest import register_user + +# 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"}) + if response.status_code != 401: + failures.append((method, path, response.status_code)) + + assert not failures, f"routes reachable without authentication: {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), + ("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 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_system.py b/apps/backend/tests/test_system.py index b6cc6388..96b5b8ec 100644 --- a/apps/backend/tests/test_system.py +++ b/apps/backend/tests/test_system.py @@ -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() From e6c0dda09c0c2c100a43db64e0e80ec517e1a5e2 Mon Sep 17 00:00:00 2001 From: parthrohit22 Date: Wed, 15 Jul 2026 09:11:48 +0100 Subject: [PATCH 047/347] feat(frontend): surface provider key last-4 in settings (#65) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add apiKeyLast4 to the AiProviderPublicConfig type and show "Saved key •••• 1234" in the Settings API-key placeholder, matching the backend's write-only key contract (the full key is never returned). Co-Authored-By: Claude Opus 4.8 --- apps/frontend/src/app/pages/SettingsPage.tsx | 8 +++++++- apps/frontend/src/shared/services/api/types.ts | 3 +++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/apps/frontend/src/app/pages/SettingsPage.tsx b/apps/frontend/src/app/pages/SettingsPage.tsx index d500c5ea..d5e5ffed 100644 --- a/apps/frontend/src/app/pages/SettingsPage.tsx +++ b/apps/frontend/src/app/pages/SettingsPage.tsx @@ -126,7 +126,13 @@ export function SettingsPage() { type="password" value={settings.apiKey} onChange={(event) => 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" />
diff --git a/apps/frontend/src/shared/services/api/types.ts b/apps/frontend/src/shared/services/api/types.ts index e73f89c6..fb930c50 100644 --- a/apps/frontend/src/shared/services/api/types.ts +++ b/apps/frontend/src/shared/services/api/types.ts @@ -168,6 +168,9 @@ export interface AiProviderPublicConfig { model: string | null; baseUrl: string | null; hasApiKey: boolean; + // Write-only key contract: the backend returns only the last four characters, + // never the full key. Null when no key is stored. + apiKeyLast4: string | null; } export interface AiProviderTestRequest { From 11a95f984dc85a06de30d71d6571e650a03bd9f8 Mon Sep 17 00:00:00 2001 From: parthrohit22 Date: Wed, 15 Jul 2026 09:11:48 +0100 Subject: [PATCH 048/347] docs: reflect enforced auth, owner scoping, and per-user encrypted keys (#63, #65) Update README status table, SYSTEM_OVERVIEW (storage, auth enforcement, trust boundaries, limitations), and .env.example (AI_ENCRYPTION_KEY) to describe the system as now built: all routes authenticated and owner-scoped, provider keys encrypted per user, rate-limit budgets keyed per authenticated user. Co-Authored-By: Claude Opus 4.8 --- README.md | 8 ++++---- docs/architecture/SYSTEM_OVERVIEW.md | 29 ++++++++++++++-------------- 2 files changed, 18 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 7abd74cf..ea17f47f 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ PARTHA parses a repository once into a shared layer of repository facts, and has The purpose, stated at the level it is actually being pursued: build *persistent* understanding of a repository as it evolves, reuse that understanding across every surface, and make repository claims progressively more checkable. PARTHA is early. What follows describes what exists, not what is intended. -> **Status: early development.** PARTHA runs locally and is useful for exploring a repository. It is **not production-ready**. Multi-user authentication and owner isolation are not consistently enforced across every backend surface — PARTHA should currently be used only in a trusted local environment. +> **Status: early development.** PARTHA runs locally and is useful for exploring a repository. It is **not production-ready**. Authentication and owner isolation are now enforced across the backend routes, but the system has not been hardened or operated as a multi-tenant deployment, so it should still be run in a trusted environment. ## The problem @@ -64,8 +64,8 @@ Statuses below were checked against the implementation, not against prior docume | Architecture output | **Implemented but limited** | Modules, layers, relationships, and an interactive graph — with heuristic module and layer assignment. | | Dependency inventory | **Implemented but limited** | Reads `package.json`, `requirements.txt`, and `pyproject.toml`. Other ecosystems and lockfiles are not parsed. | | Engineering review | **Implemented but limited** | A fixed set of heuristic checks with derived category scores. Scores are arithmetic over finding severities, not a measured quality metric. | -| AI provider integration | **Implemented but limited** | Several providers behind one abstraction. Provider configuration is global rather than per-user. | -| Authorization and owner isolation | **Partially implemented** | Repository routes are owner-scoped. Analysis, AI, documentation, and export routes are not, and the backend still accepts unauthenticated requests. | +| AI provider integration | **Implemented but limited** | Several providers behind one abstraction. Provider configuration is per-user, with the API key encrypted at rest and injected per request. | +| Authorization and owner isolation | **Implemented** | All repository, analysis, AI, documentation, and export routes require authentication and are owner-scoped in the service layer; a non-owner request returns 404. Rate-limit budgets are keyed per authenticated user. | | Citations and grounded AI answers | **Not implemented** | No source content or line numbers are sent to providers, and no citations are returned. | | Asynchronous / incremental processing | **Not implemented** | Ingestion and analysis run synchronously in the request; there is no background job system and no incremental re-analysis. | | Change-impact analysis | **Not implemented** | — | @@ -232,7 +232,7 @@ Backend coverage is the stronger of the two. Frontend coverage is thin and there ## Limitations -- **Use only in a trusted local environment.** Multi-user authentication and owner isolation are not consistently enforced across every backend surface. PARTHA is not production-ready and should not be exposed to untrusted users or the public internet. +- **Not yet hardened for public multi-tenant use.** Authentication and owner isolation are enforced across the backend routes, and provider keys are encrypted at rest, but PARTHA has not been operated as a hardened multi-tenant deployment. It is not production-ready and should not be exposed to the public internet without further review. Outside `development`/`test`, set `AUTH_SECRET_KEY` and `AI_ENCRYPTION_KEY` (a Fernet key); the backend refuses to start without them. - **Extraction is heuristic.** File roles, modules, and layers are inferred from paths and filenames; symbols come from regular expressions. Expect wrong answers on projects that do not follow common conventions, and do not treat heuristic output as guaranteed fact. - **Evidence and provenance are partial.** File-level only — no line spans, no per-fact extraction method, no revision-addressed facts. - **No persistent semantic graph.** Repository facts are serialized as JSON onto the repository row rather than into a queryable graph store. diff --git a/docs/architecture/SYSTEM_OVERVIEW.md b/docs/architecture/SYSTEM_OVERVIEW.md index 428d6349..88307d51 100644 --- a/docs/architecture/SYSTEM_OVERVIEW.md +++ b/docs/architecture/SYSTEM_OVERVIEW.md @@ -113,12 +113,13 @@ This runs **synchronously inside the HTTP request**. A large repository blocks a | Store | Holds | Notes | | --- | --- | --- | -| Relational DB | `users`, `refresh_tokens`, `repositories` | SQLite by default for local development; PostgreSQL under Docker Compose. Three Alembic migrations. | +| Relational DB | `users`, `refresh_tokens`, `repositories`, `ai_provider_configs` | SQLite by default for local development; PostgreSQL under Docker Compose. Four Alembic migrations. | | `repositories.repo_metadata` (JSON column) | Parser metadata, `commitSha`, and the **entire serialized Repository Intelligence** under the `intelligence` key. | There are **no graph tables**. The knowledge graph is a JSON blob on this column. | | `repositories.file_tree` (JSON column) | The parsed file tree. | Serves the explorer. | -| Filesystem (`STORAGE_PATH`) | Extracted archives and cloned repositories; uploaded archives (deleted after extraction); `ai-provider.json`. | Repository source is read from here on demand for file preview. | +| `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 and never returned to the client. | +| 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.json` is a **single global file** (mode `0600`), not per-user. Whichever provider config was saved last is the one every user's queries run against. +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. --- @@ -143,7 +144,7 @@ sequenceDiagram 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. -**The enforcement gap.** The frontend requires a session for every route. The backend does not. `get_current_user_or_default` validates a presented Bearer token strictly (a bad token is a 401, never a silent downgrade), but when **no** token is presented it falls back to a fixed seed user (`00000000-…-0000`). Only `/auth/me` uses the strict `get_current_user`. So the API remains open to unauthenticated callers, and all anonymous traffic shares one owner bucket. +**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. --- @@ -183,7 +184,7 @@ Everything else calls `RepositoryIntelligenceEngine.from_record(record)` and tra | --- | --- | --- | | `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 deployment; none required. | AI workspace is unusable until a provider is configured; the rest of the system is unaffected. | +| AI providers | Answering repository questions. Configured per user with an encrypted API key; none required. | AI workspace is unusable until the user configures a provider; the rest of the system is unaffected. | | PostgreSQL, Redis | Compose and CI only. Redis backs the rate limiter when `RATE_LIMIT_BACKEND=redis`. | Local development uses SQLite and the in-memory rate limiter; neither service is required. | --- @@ -219,7 +220,7 @@ flowchart TB - **Uploaded archives and cloned repositories are untrusted input.** Extraction rejects path traversal and symlink escape; 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. - **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 not fully enforced.** See the limitations below — this is the most important trust gap in the system today. +- **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. 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. --- @@ -227,14 +228,12 @@ flowchart TB These are properties of the system as built, not a wish list. -1. **Owner isolation is not enforced across every surface.** Only the `/repositories` routes are owner-scoped (`get_for_owner` / `list_for_owner`). `AnalysisService`, `AiOrchestrator`, `DocumentationService`, and `ExportService` resolve a repository through the unscoped `RepositoryRepository.get(id)`, so there is no owner check on those paths and no tenant isolation. Contributors touching these services must use the owner-scoped accessors. -2. **Authentication is not enforced at the API.** Requests without a token are attributed to a shared seed user rather than rejected. PARTHA should therefore be run only in a trusted local environment. -3. **Extraction is heuristic, not language-aware.** File roles, modules, and layers are inferred from path segments and filenames. Symbols come from regular expressions. `TreeSitterParser` returns nothing, even though `tree-sitter` is a declared dependency. -4. **No line-level provenance.** Facts carry a file path and nothing finer. Revision identity (`commitSha`) lives on the repository row, not on the facts. -5. **The knowledge graph is not persisted as a graph.** It is a JSON blob on `repo_metadata`. It cannot be queried, indexed, or joined. Four of the eight declared relationship types are never emitted. -6. **Processing is synchronous and whole-repository.** No background jobs, no incremental re-analysis, no cancellation. -7. **AI provider configuration is global rather than per-user.** A single stored configuration serves every caller. -8. **Dependency coverage is narrow.** Three manifest formats, no lockfiles, no transitive resolution; the vulnerability and outdated fields in the API are constants, not scan results. -9. **Frontend assurance is thin.** Coverage is limited and there is no end-to-end suite. +1. **Extraction is heuristic, not language-aware.** File roles, modules, and layers are inferred from path segments and filenames. Symbols come from regular expressions. `TreeSitterParser` returns nothing, even though `tree-sitter` is a declared dependency. +2. **No line-level provenance.** Facts carry a file path and nothing finer. Revision identity (`commitSha`) lives on the repository row, not on the facts. +3. **The knowledge graph is not persisted as a graph.** It is a JSON blob on `repo_metadata`. It cannot be queried, indexed, or joined. Four of the eight declared relationship types are never emitted. +4. **Processing is synchronous and whole-repository.** No background jobs, no incremental re-analysis, no cancellation. +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, no lockfiles, no transitive resolution; the vulnerability and outdated fields in the API are constants, not scan results. +7. **Frontend assurance is thin.** Coverage is limited and there is no end-to-end suite. 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. From c89cd5f79cd900a271c6be4e8852babe922781e0 Mon Sep 17 00:00:00 2001 From: parthrohit22 Date: Wed, 15 Jul 2026 09:17:27 +0100 Subject: [PATCH 049/347] test(backend): derive cross-owner 404 sweep from the router table (#63) Per the issue comments, the ownership guard must derive its route list from the app's own router so a newly added route fails by omission rather than being missed by a hand-maintained list. Adds a 404 sweep over every {repository_id} route discovered from the router, plus a drift guard asserting the explicit body-carrying-route list still matches the live router. Co-Authored-By: Claude Opus 4.8 --- .../backend/tests/test_route_authorization.py | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/apps/backend/tests/test_route_authorization.py b/apps/backend/tests/test_route_authorization.py index 75af62ee..850f2144 100644 --- a/apps/backend/tests/test_route_authorization.py +++ b/apps/backend/tests/test_route_authorization.py @@ -139,6 +139,64 @@ def test_repository_scoped_routes_return_404_for_a_non_owner(client, make_auth_h 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"]) From 99d48da162c2fb683962be85b66cdbd2174f0804 Mon Sep 17 00:00:00 2001 From: parthrohit22 Date: Wed, 15 Jul 2026 09:28:14 +0100 Subject: [PATCH 050/347] chore(backend): address code-quality findings (#63, #65) - config_store.py: replace ellipsis bodies in the ProviderConfigStore Protocol with `raise NotImplementedError` so the stubs are explicit contract methods rather than no-op statements. - test_route_authorization.py: drop the unused `register_user` import (the file uses the make_auth_headers fixture). Co-Authored-By: Claude Opus 4.8 --- apps/backend/app/ai/providers/config_store.py | 12 ++++++++---- apps/backend/tests/test_route_authorization.py | 2 -- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/apps/backend/app/ai/providers/config_store.py b/apps/backend/app/ai/providers/config_store.py index 728e91ed..4b3b3b37 100644 --- a/apps/backend/app/ai/providers/config_store.py +++ b/apps/backend/app/ai/providers/config_store.py @@ -25,13 +25,17 @@ class ProviderConfigStore(Protocol): """The surface the orchestrator depends on, independent of storage.""" - def get_public_config(self) -> AiProviderPublicConfig: ... + def get_public_config(self) -> AiProviderPublicConfig: + raise NotImplementedError - def save_config(self, config: AiProviderConfig) -> AiProviderPublicConfig: ... + def save_config(self, config: AiProviderConfig) -> AiProviderPublicConfig: + raise NotImplementedError - def read_config(self) -> AiProviderConfig | None: ... + def read_config(self) -> AiProviderConfig | None: + raise NotImplementedError - def config_for_test(self, request: AiProviderTestRequest) -> AiProviderConfig: ... + def config_for_test(self, request: AiProviderTestRequest) -> AiProviderConfig: + raise NotImplementedError class EncryptedProviderConfigStore: diff --git a/apps/backend/tests/test_route_authorization.py b/apps/backend/tests/test_route_authorization.py index 850f2144..a3f599a7 100644 --- a/apps/backend/tests/test_route_authorization.py +++ b/apps/backend/tests/test_route_authorization.py @@ -14,8 +14,6 @@ from fastapi.routing import APIRoute -from tests.conftest import register_user - # The routers guarded by get_current_user at the router level. PROTECTED_PREFIXES = ("/repositories", "/analysis", "/ai", "/documentation", "/export") From f6fe5328becd3c294a422647a8b77d20240a8729 Mon Sep 17 00:00:00 2001 From: parthrohit22 Date: Wed, 15 Jul 2026 10:00:27 +0100 Subject: [PATCH 051/347] fix(backend): validate /ai/stream ownership and provider config before streaming (#63, #65) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit service.query ran inside the SSE generator, after StreamingResponse had already emitted 200 headers, so a cross-owner request (404) or a missing provider configuration (422) could only fail mid-stream — the caller saw a 200 with an empty/aborted body and the server logged "response already started". Await service.query in the route and build the event generator from the validated response, so ownership scoping and provider-config validation surface as ordinary 404/422 error responses before any stream starts. query already computed the full response before emitting words, so the success path is unchanged. Tests: add /ai/stream to the router-derived cross-owner sweep (and its drift guard so the route can't be omitted), plus a streaming matrix — 401 unauth, 404 cross-owner before streaming, 422 clear error on missing provider config (not a 200 empty stream), and a 200 success for the owner with valid prerequisites. Co-Authored-By: Claude Opus 4.8 --- apps/backend/app/api/routes/ai.py | 10 +- apps/backend/tests/test_ai_stream.py | 129 ++++++++++++++++++ .../backend/tests/test_route_authorization.py | 5 +- 3 files changed, 141 insertions(+), 3 deletions(-) create mode 100644 apps/backend/tests/test_ai_stream.py diff --git a/apps/backend/app/api/routes/ai.py b/apps/backend/app/api/routes/ai.py index b5382092..bc4e393a 100644 --- a/apps/backend/app/api/routes/ai.py +++ b/apps/backend/app/api/routes/ai.py @@ -35,8 +35,16 @@ async def query_ai(request: AiQueryRequest, service: AiService = Depends(get_ai_ @router.post("/stream") async def stream_ai(request: AiQueryRequest, service: AiService = Depends(get_ai_service)) -> StreamingResponse: + # Resolve the answer BEFORE returning StreamingResponse. service.query runs + # ownership scoping and provider-config validation, so a cross-owner request + # (404) or a missing provider key (422) must surface as a normal error + # response here — not inside the generator, where 200 headers would already + # have been sent and the failure could only abort a stream that "succeeded". + # query already computes the full response before any word is emitted, so + # awaiting it here changes nothing on the success path. + response = await service.query(request) + 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 []: diff --git a/apps/backend/tests/test_ai_stream.py b/apps/backend/tests/test_ai_stream.py new file mode 100644 index 00000000..5c3a94df --- /dev/null +++ b/apps/backend/tests/test_ai_stream.py @@ -0,0 +1,129 @@ +"""Authorization and error-ordering for POST /ai/stream (#63, #65). + +Regression cover for the defect where ``service.query`` ran inside the SSE +generator, after ``StreamingResponse`` had already emitted 200 headers: a +cross-owner request or a missing provider configuration could only fail +*mid-stream*, so the caller saw a 200 with an empty/aborted body instead of the +required 404 / 422. All failure-prone validation must now run before streaming +starts, so these errors surface as ordinary error responses. +""" + +import io +import json +import zipfile + +from app.ai.providers.registry import ProviderRegistry +from app.ai.types import AiProviderResponse +from app.api.deps import get_provider_registry +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() + + +class _StubProvider: + async def complete(self, config, prompt) -> AiProviderResponse: + return AiProviderResponse(content="Two words") + + +def _upload_repo(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 _configure_provider(client, headers: dict) -> None: + response = client.put( + "/ai/config", + json={"provider": "openai", "apiKey": "sk-test-1234", "model": "gpt-4o-mini"}, + headers=headers, + ) + assert response.status_code == 200, response.text + + +def _override_registry(client) -> None: + registry = ProviderRegistry() + registry.register("openai", _StubProvider()) + client.app.dependency_overrides[get_provider_registry] = lambda: registry + + +def _parse_events(text: str) -> list[dict]: + return [json.loads(line[len("data: "):]) for line in text.splitlines() if line.startswith("data: ")] + + +def _is_event_stream(response) -> bool: + return "text/event-stream" in response.headers.get("content-type", "") + + +def test_stream_succeeds_for_owner_with_valid_prerequisites(client): + auth = register_user(client, "owner@example.com") + _override_registry(client) + try: + repository_id = _upload_repo(client, auth["headers"]) + _configure_provider(client, auth["headers"]) + + response = client.post( + "/ai/stream", + json={"repositoryId": repository_id, "query": "hi"}, + headers=auth["headers"], + ) + + assert response.status_code == 200 + assert _is_event_stream(response) + events = _parse_events(response.text) + assert events[-1] == {"type": "done"} + content = "".join(event["content"] for event in events if event["type"] == "content") + assert "Two" in content and "words" in content + finally: + client.app.dependency_overrides.pop(get_provider_registry, None) + + +def test_stream_without_provider_config_returns_clear_error_not_empty_stream(client): + auth = register_user(client, "noconfig@example.com") + repository_id = _upload_repo(client, auth["headers"]) + + response = client.post( + "/ai/stream", + json={"repositoryId": repository_id, "query": "hi"}, + headers=auth["headers"], + ) + + # A clear 422, produced before streaming — not a 200 with an empty stream. + assert response.status_code == 422 + body = response.json() + assert body["code"] == "validation_error" + assert "not configured" in body["message"].lower() + assert not _is_event_stream(response) + + +def test_stream_cross_owner_returns_404_before_streaming(client, make_auth_headers): + alice = make_auth_headers("alice@example.com") + bob = make_auth_headers("bob@example.com") + repository_id = _upload_repo(client, alice["headers"]) + + response = client.post( + "/ai/stream", + json={"repositoryId": repository_id, "query": "hi"}, + headers=bob["headers"], + ) + + # 404, never 200 or 403: a non-owner's request is indistinguishable from a + # missing repository, and it fails before any stream starts (JSON error body, + # not an SSE stream). + assert response.status_code == 404 + assert response.json()["code"] == "not_found" + assert not _is_event_stream(response) + + +def test_stream_requires_authentication(client): + response = client.post("/ai/stream", json={"repositoryId": "any", "query": "hi"}) + assert response.status_code == 401 diff --git a/apps/backend/tests/test_route_authorization.py b/apps/backend/tests/test_route_authorization.py index a3f599a7..19f02f17 100644 --- a/apps/backend/tests/test_route_authorization.py +++ b/apps/backend/tests/test_route_authorization.py @@ -106,6 +106,7 @@ def _seed_repository(owner_id: str) -> str: ("GET", "/analysis/{id}/review", None), ("POST", "/documentation/generate", "repositoryId"), ("POST", "/ai/query", "repositoryId"), + ("POST", "/ai/stream", "repositoryId"), ("POST", "/export", "repositoryId"), ] @@ -124,7 +125,7 @@ def test_repository_scoped_routes_return_404_for_a_non_owner(client, make_auth_h json_body = {body_key: repository_id} if template == "/documentation/generate": json_body["format"] = "markdown" - elif template == "/ai/query": + elif template in {"/ai/query", "/ai/stream"}: json_body["query"] = "hello" elif template == "/export": json_body.update({"target": "review", "format": "json"}) @@ -185,7 +186,7 @@ def test_body_carrying_repository_routes_are_covered_by_the_cross_owner_sweep(cl live_body_routes = { (method, route.path) for route in found - if route.path in {"/documentation/generate", "/ai/query", "/export"} + if route.path in {"/documentation/generate", "/ai/query", "/ai/stream", "/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} From c9089c7b89652231f448323acdcac4784d4a7fdc Mon Sep 17 00:00:00 2001 From: parthrohit22 Date: Wed, 15 Jul 2026 11:33:24 +0100 Subject: [PATCH 052/347] docs(architecture): add Repository Intelligence v1 schema and evidence contract RFC (#86) Introduce RFC-0001 (Proposed) defining the Repository Intelligence v1 schema and evidence contract before any snapshot, extractor, resolver, query, job, or consumer implementation. Settles node/edge stable keys, the provenance record, truth classes, diagnostics, schema versioning, migration policy, snapshot immutability, the canonical graph hash, and security/ownership. Records the dependency gate for issues #87-#95. This is documentation only: no backend/frontend code, migrations, storage, extractors, or APIs are changed. Status remains Proposed until a maintainer approves; merge after approval constitutes ratification. Related to #86 --- docs/README.md | 1 + .../REPOSITORY_INTELLIGENCE_V1_RFC.md | 1161 +++++++++++++++++ 2 files changed, 1162 insertions(+) create mode 100644 docs/architecture/REPOSITORY_INTELLIGENCE_V1_RFC.md diff --git a/docs/README.md b/docs/README.md index 4aece958..357feef4 100644 --- a/docs/README.md +++ b/docs/README.md @@ -14,6 +14,7 @@ Every document listed here is maintained and describes the system as it currentl | [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 | **Proposed** architectural contract (RFC-0001, tracking [#86](https://github.com/Second-Origin/PARTHA/issues/86)) for the future snapshot/evidence schema: stable keys, provenance, truth classes, immutability, diagnostics, versioning, and the canonical graph hash. It describes an **approved-future contract, not current behaviour** — §17 states plainly what is unimplemented. Governs downstream issues #87–#95. | | [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. | diff --git a/docs/architecture/REPOSITORY_INTELLIGENCE_V1_RFC.md b/docs/architecture/REPOSITORY_INTELLIGENCE_V1_RFC.md new file mode 100644 index 00000000..2bc6086d --- /dev/null +++ b/docs/architecture/REPOSITORY_INTELLIGENCE_V1_RFC.md @@ -0,0 +1,1161 @@ +# 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) | +| **Schema version ratified** | `ri.v1` | +| **Author** | @parthrohit22 | +| **Decision owners (ratifiers)** | Maintainer(s) of `Second-Origin/PARTHA` — reviewer `@parthrohit22` per [CONTRIBUTING §6](../../CONTRIBUTING.md) | +| **Created** | 2026-07-15 | +| **Last updated** | 2026-07-15 | +| **Status** | **Proposed** | +| **Supersedes** | — | +| **Superseded by** | — | + +> **This RFC delivers an approved architectural document, not application code.** It does not +> implement snapshots, persistence, extractors, resolvers, queries, jobs, migrations, or consumer +> migration. Those are issues [#87–#95](https://github.com/Second-Origin/PARTHA/issues/87) and are +> gated on this RFC's approval (see [§16, Dependency gate](#16-dependency-gate)). + +--- + +## 1. Status and approval + +### 1.1 Status + +This RFC is **Proposed**. It is not approved, ratified, or accepted until a maintainer of +`Second-Origin/PARTHA` explicitly approves the pull request that introduces it and merges it into +`dev`. + +### 1.2 Approval / ratification rule + +- The RFC remains **Proposed** until a maintainer explicitly approves it in review. +- **Merge of this document into `dev` after explicit maintainer approval constitutes acceptance.** + On that event the `Status` field changes to `Accepted` and the `Last updated` date is set to the + merge date, in the same or an immediately following change. +- The author **must not** self-declare this RFC approved, and **must not** change `Status` to + `Accepted` without a maintainer's recorded approval. Per [CONTRIBUTING §6](../../CONTRIBUTING.md), + the author must not self-merge. +- 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 ratifies `ri.v2`. + +### 1.3 What approval unblocks + +Approval releases the dependency gate in [§16](#16-dependency-gate). Until then, no downstream +issue in the intelligence track (except #94 fixture construction) may begin, and none may be +assigned against an unapproved draft. + +--- + +## 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 set of extractor/resolver versions. The unit of reproducibility. | +| **Fact** | A single stored assertion in a snapshot. Every fact is either a **node** or an **edge**, carries a **truth class** ([§7](#7-truth-classes)), and — when its truth class requires it — carries **provenance** ([§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)). | +| **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. | +| **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)). | +| **Extractor/resolver version** | The independently incremented version of a specific extractor or resolver **implementation**. Distinct from schema version ([§9.4](#94-schema-version-vs-extractorresolver-version)). | + +--- + +## 3. Revision and snapshot identity + +### 3.1 Problem this settles + +Today revision identity is coarse and mutable: `RepositoryService._metadata_with_intelligence` +([`repository_service.py:275`](../../apps/backend/app/services/repository_service.py#L275)) stashes +a `commitSha` inside the mutable `repo_metadata` JSON blob, and its own comment says *"Stored in +metadata for now; promotion to a first-class column is M2 work."* A value inside a mutable blob is +not an identity — it cannot be indexed, uniquely constrained, made immutable, or joined against a +snapshot table. This section defines the identity that #87 and #88 make first-class. + +### 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, extractor_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)). +- `extractor_version_set` — the ordered, deduplicated set of `extractor@version` and + `resolver@version` identifiers that participated (e.g. `["python-ast@1.0.0", "typescript-ast@1.0.0", "import-resolver@1.0.0"]`). +- `config_hash` — a hash of the analysis configuration that affects output (support-matrix + selection, resource limits that change what is extracted). Configuration that cannot change + output MUST NOT be included. + +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 on sealed snapshots (#88). + +### 3.4 Reanalysis and reuse + +- **Reanalysis always produces a new snapshot.** A completed snapshot is immutable + ([§11](#11-snapshot-lifecycle-and-immutability)); re-running analysis never mutates it. Prior + snapshots remain queryable. +- **Reuse is permitted and RECOMMENDED for identical inputs.** If a **sealed** snapshot already + exists for the exact composite identity in [§3.3](#33-snapshot-identity), a new analysis request + MAY return that existing snapshot instead of building a duplicate. Because the canonical graph + hash ([§12](#12-canonical-graph-hash)) is deterministic for identical inputs, reuse cannot change + observable output. Reuse is an optimization, not a requirement; an implementation MAY always + rebuild. +- If *any* component of the composite differs — a new revision, a bumped extractor version, a + schema bump, or a config change that affects output — the result is a **distinct** snapshot. + +### 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 + +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. Given the same revision and schema version, the same entity MUST always receive +the same stable key. This is what makes facts comparable across snapshots and revisions and is the +identity every downstream issue (#88–#92, #94) is keyed on. + +Stable keys are UTF-8 strings. The general grammar is `:`. + +### 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 repository id | `repo:repo_7f3a…` | +| **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: + +- 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. +- **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. +- **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 + +`ri.v1` defines exactly these predicate names (lowercase `snake_case`). This set is closed for +`ri.v1`; adding a predicate is a **compatible addition** ([§9.1](#91-compatible-additions)). + +| 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 `KnowledgeGraphRelationship` type union +> ([`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` renames to the closed set +> above; `extends` folds into `implements`-adjacent modeling for v1 and `exports`/`references` are +> represented via `defines` + node properties. Reviving them is a compatible addition when a +> resolver actually produces them. + +### 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, **except** an object +that is an external `dependency` node or an explicitly *unresolved* target (which is not stored as +an edge at all — see [§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 + `(path, start_line, end_line, extractor, extractor_version)` — [§12.3](#123-ordering)). +- 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. + +--- + +## 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`. +- **Validation.** An evidence record is **valid** iff `1 ≤ start_line ≤ end_line ≤ (line count of + the file at the stored revision)`. A record that is reversed (`end_line < start_line`), zero or + negative, or out of range against the stored revision 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:` the file's last line, + 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." +- **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 reference their supporting observed evidence.** A `resolved` or + `inferred` fact carries, in addition to (or instead of) its own resolver evidence, a + `derived_from` list naming the `observed` facts/evidence it was computed from: + + ```json + "truth_class": "resolved", + "evidence": [ { "path": "src/auth/service.ts", "start_line": 3, "end_line": 3, + "extractor": "import-resolver", "extractor_version": "1.0.0" } ], + "derived_from": [ + { "kind": "edge", "stable_key": "file:src/auth/service.ts|imports|" } + ] + ``` + + A `resolved` fact's own evidence points at the syntax that triggered resolution (e.g. the import + statement line); `derived_from` links the observed inputs so a reader can audit the deterministic + step. An `inferred` fact MUST cite the observed facts that support it via `derived_from`; it MUST + NOT 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. + +--- + +## 7. Truth classes + +### 7.1 Definitions and emission rules + +| Truth class | Definition | Emission rule | +| --- | --- | --- | +| **observed** | A direct syntax 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 parsing that exact source. No extractor may emit `observed` without a valid span ([§6.2](#62-line-and-span-rules-decided-not-optional)). | +| **resolved** | A relationship produced by a **documented, deterministic resolution algorithm** over stored observed facts. | MAY be emitted **only** by a **resolver**. The algorithm MUST be documented (per #91) and deterministic: same inputs → same output. Carries `derived_from` linking its observed inputs ([§6.3](#63-multiple-evidence-resolvedinferred-evidence-and-the-revision-tie)). | +| **inferred** | A **heuristic** conclusion supported by evidence but **not guaranteed by syntax** (e.g. "this module is the authentication layer"). | MAY be emitted **only** by an **inference/classifier** component. MUST cite supporting observed/resolved facts via `derived_from`. MUST be labeled as inferred everywhere it surfaces. | +| **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) | | | ✔ | | +| **Narrative generator** (AI answer, doc prose) | | | | ✔ (never in graph) | +| **Legacy regex engine** (today's [`engine.py`](../../apps/backend/app/intelligence/engine.py)) | | | | 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. + +### 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 is retained.** Downgrading is also not an in-place operation; every fact + keeps its evidence/`derived_from` for the life of the snapshot. +- **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 | The extractor/resolver identifier and version, e.g. `typescript-ast@1.0.0`. REQUIRED. | +| `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). | +| 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. today's 512 KB cap in [`engine.py`](../../apps/backend/app/intelligence/engine.py#L168) 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-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 **ratifies `schema_version: "ri.v1"`** as the value carried on every snapshot and every +fact-bearing API response. + +### 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, evidence, or diagnostics. +- New **node kinds**, **predicates**, or **diagnostic codes**. +- New extractors/resolvers (they bump their own version, not the schema — [§9.4](#94-schema-version-vs-extractorresolver-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 unknown fields, unknown node kinds, unknown predicates, 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)). +- 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 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 ratifies `ri.v2`; 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 extractor/resolver version + +- **Schema version** (`ri.v1`) versions the *contract*. +- **Extractor/resolver version** versions an *implementation*. Bumping `typescript-ast` from + `1.0.0` to `1.1.0` (it now extracts a new construct) does **not** change the schema version; it + changes the snapshot's `extractor_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 current `repo_metadata["intelligence"]` blob (produced by today's regex +[`engine.py`](../../apps/backend/app/intelligence/engine.py), 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. 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. The canonical hash is reproducible (computing it twice yields the same value). + +### 11.3 Immutability + +- **A completed snapshot is immutable.** Its nodes, edges, evidence, 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 create a NEW snapshot** ([§3.4](#34-reanalysis-and-reuse)). There is + no edit-in-place path. +- **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` snapshot; it never resumes and seals a previously + abandoned one. + +### 11.5 Concurrency and idempotency + +- **Idempotent submission** (#93): submitting the same analysis (same composite identity, + [§3.3](#33-snapshot-identity)) twice MUST NOT produce two sealed snapshots. Implementations reuse + the existing sealed snapshot ([§3.4](#34-reanalysis-and-reuse)) or coordinate so exactly one + `building` snapshot seals for a given identity. +- Concurrent builds for the **same** composite identity resolve to a single sealed snapshot; the + uniqueness constraint on sealed snapshots ([§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, +extractor/resolver versions, and configuration MUST produce the same canonical hash.** + +### 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 three ordered arrays plus scalar inputs: + +``` +{ + "schema_version": "ri.v1", + "revision": { "kind": "...", "value": "..." }, + "extractor_version_set": ["python-ast@1.0.0", "typescript-ast@1.0.0", "import-resolver@1.0.0"], + "config_hash": "sha256:...", + "nodes": [ ...sorted... ], + "edges": [ ...sorted, each with sorted evidence... ], + "diagnostics": [ ...sorted... ] +} +``` + +### 12.3 Ordering + +- **Nodes** sorted ascending by `stable_key` (byte order of the NFC-normalized UTF-8 string). +- **Edges** sorted ascending by the tuple `(subject.stable_key, predicate, object.stable_key)`. +- **Evidence** within each fact sorted ascending by `(path, start_line, end_line, extractor, + extractor_version)`. +- **Diagnostics** sorted ascending by `(code, path or "", start_line or 0, subject or "", object or + "", message)`. +- **`extractor_version_set`** sorted ascending by identifier. + +### 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**. +- Reserved-but-absent optional fields (e.g. columns in `ri.v1`) are **omitted**, and their omission + is canonical for `ri.v1`. + +### 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 row id, host/environment identifiers, and the surrogate `edge_id` (which is a pure +function of already-hashed content). `revision.ref` (a moving branch name) is **excluded**; +`revision.value` (the immutable SHA/content hash) is **included**. + +### 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` over `nodes`+`edges` alone, but the **primary** `canonical_graph_hash` +covers all three arrays and is the one stored and compared for determinism. + +--- + +## 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 + [`get_for_owner`](../../apps/backend/app/services/repository_service.py#L250)); 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:…", + "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", + "evidence": [ + { "path": "app/api/routes/auth.py", "start_line": 21, "end_line": 21, + "extractor": "route-resolver", "extractor_version": "1.0.0" } + ], + "derived_from": [ + { "kind": "node", "stable_key": "app/api/routes/auth.py::login" } + ], + "schema_version": "ri.v1" +} +``` + +### 14.4 Inferred architecture classification (must be labeled inferred) + +```json +{ + "kind": "node", + "node_kind": "module", + "stable_key": "mod:app/services", + "name": "services", + "truth_class": "inferred", + "properties": { "classification": "business-logic-layer", "confidence": "heuristic" }, + "derived_from": [ + { "kind": "node", "stable_key": "mod:app/services" }, + { "kind": "node", "stable_key": "app/services/auth_service.py::AuthService" } + ], + "schema_version": "ri.v1" +} +``` + +### 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": { "candidates": ["src/shared/utils/index.ts", "src/app/utils.ts"] } +} +``` + +### 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:3b1f…c9", "ref": null }, + "schema_version": "ri.v1" +} +``` + +### 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" +} +``` + +### 14.9 Multiple evidence occurrences on one edge + +```json +{ + "kind": "edge", + "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", + "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" } + ], + "schema_version": "ri.v1" +} +``` + +### 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": [ + { "stable_key": "src/auth/service.ts::AuthService.login", "evidence_ref": "…" }, + { "stable_key": "src/auth/tokens.ts::issueToken", "evidence_ref": "…" } + ] +} +``` + +> 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. | +| **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. | + +### 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 extractors.** Extractors/resolvers 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). +- **This RFC changes no runtime behavior.** Until #87–#95 land, the system behaves exactly as + documented today; this document describes the *approved future contract*, not current capability. + +--- + +## 16. Dependency gate + +This RFC records the exact sequencing rule for the intelligence track (the comment on #86 is +binding): + +- **#87, #88, #89, #90, #91, #92, #93, and #95 MUST NOT begin until this RFC is approved.** +- **#94 fixture construction MAY begin before approval** — writing expected facts down first is a + genuine test of whether the support matrix is coherent. +- **#94 scoring and provenance validation still depend on the approved contract** (they need the + evidence-record definition in [§6](#6-provenance-contract) and the canonical hash in + [§12](#12-canonical-graph-hash)). +- **No downstream issue may be assigned against an unapproved draft.** #86 is the Phase 0 gate for + the intelligence track. + +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. approved contract vs. unimplemented + +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) | Approved `ri.v1` contract (this RFC) | Status | +| --- | --- | --- | --- | +| Storage | Mutable JSON blob on `repo_metadata` | Immutable sealed snapshots (§11) | **Unimplemented** (#88) | +| Symbol spans | None on `SourceSymbol` ([`models.py:55`](../../apps/backend/app/intelligence/models.py#L55)) | Required line spans (§6) | **Unimplemented** (#89/#90) | +| Extraction | Regex in `engine.py`; `TreeSitterParser` returns `[]` | Syntax-aware extractors with support matrices | **Unimplemented** (#89/#90) | +| Revision identity | `commitSha` in a JSON blob | Indexed immutable columns (§3) | **Unimplemented** (#87) | +| Relationships | 4 of 8 declared types emitted; imports as text | Resolved edges + diagnostics (§5) | **Unimplemented** (#91) | +| Provenance | File paths only | Path + span + extractor/version (§6) | **Unimplemented** | +| Query API | Consumers read the whole blob | Versioned owner-scoped read API (§9.5) | **Unimplemented** (#92) | +| Evidence-backed output | AI emits empty citation lists | Every claim cites a valid span (§7.4) | **Unimplemented** (#95) | + +**This RFC does not claim any of the "Approved contract" column exists today.** It is the target to +be built by #87–#95 after approval. No existing documentation is rewritten by this RFC to imply +otherwise. + +--- + +## 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 and approved | [§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) | +| Approved as an ADR/RFC committed to the repository | [§1](#1-status-and-approval) (Proposed → Accepted on maintainer-approved merge) | + +### 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, evidence, provenance, diagnostic, extractor, resolver, schema/extractor version | [§2](#2-normative-terminology) | +| 3. Revision & snapshot identity (git SHA + ref; upload sha256; moving names; composite identity; reanalysis; reuse; #87 migration) | [§3](#3-revision-and-snapshot-identity) | +| 4. Node identity & stable keys (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/fact identity (predicates; subject/object; edge IDs; snapshot-scoped; multiple occurrences; multiple evidence; ordering/dedup; #91 relationships) | [§5](#5-edgefact-identity) | +| 6. Provenance contract (min record; one-based; inclusive end; span validation; whole-file; columns deferred; multiple evidence; resolved/inferred → observed; 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 (ratify `ri.v1`; compatible additions; breaking; when v2; schema vs extractor 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; pre-seal validation; immutability; rejection; corrections ⇒ new snapshot; failed; #93 cancel/retry; concurrency/idempotency; diagnostics immutable) | [§11](#11-snapshot-lifecycle-and-immutability) | +| 12. Canonical graph hash (format; node/edge/evidence ordering; normalized strings/paths; diagnostics; excluded volatile fields; schema/extractor inputs; 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 (#87–#93,#95 blocked; #94 fixtures early; #94 scoring gated; no assignment on draft) | [§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 & canonical hash | [§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; resolved class; diagnostics) | [§5.1](#51-canonical-predicates), [§5.5](#55-unresolved-relationships), [§7](#7-truth-classes) | +| #92 — versioned owner-scoped query API | [§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 & canonical graph hashing | [§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) — current serialized model; `SourceSymbol` has no span. +- [`apps/backend/app/intelligence/engine.py`](../../apps/backend/app/intelligence/engine.py) — current regex extraction. +- [`apps/backend/app/parsers/tree_sitter_parser.py`](../../apps/backend/app/parsers/tree_sitter_parser.py) — placeholder parser (returns no symbols). +- [`apps/backend/app/services/repository_service.py`](../../apps/backend/app/services/repository_service.py) — `_metadata_with_intelligence`, `_content_hash_for_upload`, owner-scoped `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). From 0c7aeecc84f81ed33661f40835472c08b06e253c Mon Sep 17 00:00:00 2001 From: parthrohit22 Date: Wed, 15 Jul 2026 12:32:12 +0100 Subject: [PATCH 053/347] docs(architecture): resolve RFC review findings (#86) --- docs/README.md | 2 +- .../REPOSITORY_INTELLIGENCE_V1_RFC.md | 475 ++++++++++++++---- 2 files changed, 373 insertions(+), 104 deletions(-) diff --git a/docs/README.md b/docs/README.md index 357feef4..66cc17c7 100644 --- a/docs/README.md +++ b/docs/README.md @@ -14,7 +14,7 @@ Every document listed here is maintained and describes the system as it currentl | [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 | **Proposed** architectural contract (RFC-0001, tracking [#86](https://github.com/Second-Origin/PARTHA/issues/86)) for the future snapshot/evidence schema: stable keys, provenance, truth classes, immutability, diagnostics, versioning, and the canonical graph hash. It describes an **approved-future contract, not current behaviour** — §17 states plainly what is unimplemented. Governs downstream issues #87–#95. | +| [Repository Intelligence v1 RFC](architecture/REPOSITORY_INTELLIGENCE_V1_RFC.md) | Contributors on the intelligence track | **Proposed** architectural contract (RFC-0001, tracking [#86](https://github.com/Second-Origin/PARTHA/issues/86)) for the future snapshot/evidence schema: stable keys, provenance, truth classes, immutability, diagnostics, versioning, and the canonical graph hash. **Status: Proposed** — it describes a **proposed future contract, not current behaviour**, and is not ratified until an independent maintainer approves it; §17 states plainly what is unimplemented. Governs downstream issues #87–#95. | | [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. | diff --git a/docs/architecture/REPOSITORY_INTELLIGENCE_V1_RFC.md b/docs/architecture/REPOSITORY_INTELLIGENCE_V1_RFC.md index 2bc6086d..43492dc1 100644 --- a/docs/architecture/REPOSITORY_INTELLIGENCE_V1_RFC.md +++ b/docs/architecture/REPOSITORY_INTELLIGENCE_V1_RFC.md @@ -5,16 +5,16 @@ | **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) | -| **Schema version ratified** | `ri.v1` | +| **Proposed schema version** | `ri.v1` (proposed for ratification; not yet ratified) | | **Author** | @parthrohit22 | -| **Decision owners (ratifiers)** | Maintainer(s) of `Second-Origin/PARTHA` — reviewer `@parthrohit22` per [CONTRIBUTING §6](../../CONTRIBUTING.md) | +| **Decision owners (ratifiers)** | An **independent project maintainer other than the author**. The author (@parthrohit22) cannot ratify their own RFC. Ratification is not yet recorded. | | **Created** | 2026-07-15 | | **Last updated** | 2026-07-15 | | **Status** | **Proposed** | | **Supersedes** | — | | **Superseded by** | — | -> **This RFC delivers an approved architectural document, not application code.** It does not +> **This RFC proposes an architectural contract for approval, not application code.** It does not > implement snapshots, persistence, extractors, resolvers, queries, jobs, migrations, or consumer > migration. Those are issues [#87–#95](https://github.com/Second-Origin/PARTHA/issues/87) and are > gated on this RFC's approval (see [§16, Dependency gate](#16-dependency-gate)). @@ -25,22 +25,28 @@ ### 1.1 Status -This RFC is **Proposed**. It is not approved, ratified, or accepted until a maintainer of -`Second-Origin/PARTHA` explicitly approves the pull request that introduces it and merges it into -`dev`. +This RFC is **Proposed**. It is not approved, ratified, or accepted until an **independent project +maintainer other than the author** explicitly approves it. No such approval is recorded yet. ### 1.2 Approval / ratification rule -- The RFC remains **Proposed** until a maintainer explicitly approves it in review. -- **Merge of this document into `dev` after explicit maintainer approval constitutes acceptance.** - On that event the `Status` field changes to `Accepted` and the `Last updated` date is set to the - merge date, in the same or an immediately following change. -- The author **must not** self-declare this RFC approved, and **must not** change `Status` to - `Accepted` without a maintainer's recorded approval. Per [CONTRIBUTING §6](../../CONTRIBUTING.md), - the author must not self-merge. +- **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. +- The RFC remains **Proposed** until that independent maintainer explicitly records approval. +- **Approval does not automatically edit this document.** Merging the pull request does not by + itself change the `Status` field. 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`. + Merge of that updated document is what ratifies the contract; the status change is made by hand in + the same 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 ratifies `ri.v2`. + that proposes `ri.v2` for ratification. ### 1.3 What approval unblocks @@ -78,6 +84,7 @@ this RFC governs for `ri.v1` artifacts. | **Fact** | A single stored assertion in a snapshot. Every fact is either a **node** or an **edge**, carries a **truth class** ([§7](#7-truth-classes)), and — when its truth class requires it — carries **provenance** ([§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)). | +| **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)). | @@ -139,29 +146,43 @@ A snapshot's identity is the composite: - `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)). -- `extractor_version_set` — the ordered, deduplicated set of `extractor@version` and +- `extractor_version_set` — the lexicographically sorted, deduplicated set of `extractor@version` and `resolver@version` identifiers that participated (e.g. `["python-ast@1.0.0", "typescript-ast@1.0.0", "import-resolver@1.0.0"]`). -- `config_hash` — a hash of the analysis configuration that affects output (support-matrix - selection, resource limits that change what is extracted). Configuration that cannot change - output MUST NOT be included. +- `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. 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 on sealed snapshots (#88). - -### 3.4 Reanalysis and reuse - -- **Reanalysis always produces a new snapshot.** A completed snapshot is immutable - ([§11](#11-snapshot-lifecycle-and-immutability)); re-running analysis never mutates it. Prior - snapshots remain queryable. -- **Reuse is permitted and RECOMMENDED for identical inputs.** If a **sealed** snapshot already - exists for the exact composite identity in [§3.3](#33-snapshot-identity), a new analysis request - MAY return that existing snapshot instead of building a duplicate. Because the canonical graph - hash ([§12](#12-canonical-graph-hash)) is deterministic for identical inputs, reuse cannot change - observable output. Reuse is an optimization, not a requirement; an implementation MAY always - rebuild. -- If *any* component of the composite differs — a new revision, a bumped extractor version, a - schema bump, or a config change that affects output — the result is a **distinct** snapshot. +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 bumped extractor/resolver 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) @@ -182,15 +203,40 @@ The existing `repo_metadata["commitSha"]` value is migrated forward, not dropped ## 4. Node identity and stable keys -### 4.1 Principle +### 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. Given the same revision and schema version, the same entity MUST always receive -the same stable key. This is what makes facts comparable across snapshots and revisions and is the -identity every downstream issue (#88–#92, #94) is keyed on. - -Stable keys are UTF-8 strings. The general grammar is `:`. +autoincrement id. Stable keys are UTF-8 strings; the general grammar is `:`. + +`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). + +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) @@ -245,12 +291,17 @@ Notes and rules: 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. + 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` @@ -312,8 +363,12 @@ Notes and rules: ### 5.1 Canonical predicates -`ri.v1` defines exactly these predicate names (lowercase `snake_case`). This set is closed for -`ri.v1`; adding a predicate is a **compatible addition** ([§9.1](#91-compatible-additions)). +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 | | --- | --- | --- | --- | @@ -328,10 +383,10 @@ Notes and rules: > Note: today's `KnowledgeGraphRelationship` type union > ([`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` renames to the closed set +> ([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 is a compatible addition when a -> resolver actually produces them. +> 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 @@ -339,9 +394,11 @@ The `subject` and `object` of every edge are **node stable keys** ([§4](#4-node 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" } +{ + "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, **except** an object @@ -435,23 +492,11 @@ The **minimum required** evidence record — the fields every stored piece of ev - **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 reference their supporting observed evidence.** A `resolved` or - `inferred` fact carries, in addition to (or instead of) its own resolver evidence, a - `derived_from` list naming the `observed` facts/evidence it was computed from: - - ```json - "truth_class": "resolved", - "evidence": [ { "path": "src/auth/service.ts", "start_line": 3, "end_line": 3, - "extractor": "import-resolver", "extractor_version": "1.0.0" } ], - "derived_from": [ - { "kind": "edge", "stable_key": "file:src/auth/service.ts|imports|" } - ] - ``` - - A `resolved` fact's own evidence points at the syntax that triggered resolution (e.g. the import - statement line); `derived_from` links the observed inputs so a reader can audit the deterministic - step. An `inferred` fact MUST cite the observed facts that support it via `derived_from`; it MUST - NOT invent a span it did not read. +- **Resolved and inferred facts reference their supporting observed inputs via `derived_from`.** A + `resolved` or `inferred` fact carries a `derived_from` list of **observation references** + ([§6.4](#64-observations-and-the-derived_from-reference-model)) — deterministic pointers to the + stored observed inputs it was computed from. An `inferred` fact MUST cite the ultimate observations + supporting its observed or resolved inputs; it MUST NOT 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 @@ -459,6 +504,117 @@ The **minimum required** evidence record — the fields every stored piece of ev `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`. 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` reference.** A `resolved`/`inferred` fact's `derived_from` is a list of +observation references: + +```json +{ + "derived_from": [ + { + "kind": "observation", + "observation_id": "obs:sha256:d76b7bdbae85fc2016c49cf893a99ac8156cc98c3357582ab092836b94f0b424" + } + ] +} +``` + +Each entry MUST name an `observation_id` that exists in the same snapshot. A `derived_from` entry +MUST NOT reference a node or edge directly, a placeholder, or free text. When a definition is an +input, `derived_from` names its `observed_kind: "definition"` observation. When an inference uses a +resolved fact, it names that fact's ultimate supporting observations. This keeps one exact reference +shape while preserving the full observed basis of every derived claim. + +**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. The observation + remains stored. +- **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-ordering)); `derived_from` lists are canonicalized by + sorting their entries. Because both `observation_id` and `derived_from` are deterministic functions + of the stored revision, identical inputs produce identical bytes. + --- ## 7. Truth classes @@ -468,8 +624,8 @@ The **minimum required** evidence record — the fields every stored piece of ev | Truth class | Definition | Emission rule | | --- | --- | --- | | **observed** | A direct syntax 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 parsing that exact source. No extractor may emit `observed` without a valid span ([§6.2](#62-line-and-span-rules-decided-not-optional)). | -| **resolved** | A relationship produced by a **documented, deterministic resolution algorithm** over stored observed facts. | MAY be emitted **only** by a **resolver**. The algorithm MUST be documented (per #91) and deterministic: same inputs → same output. Carries `derived_from` linking its observed inputs ([§6.3](#63-multiple-evidence-resolvedinferred-evidence-and-the-revision-tie)). | -| **inferred** | A **heuristic** conclusion supported by evidence but **not guaranteed by syntax** (e.g. "this module is the authentication layer"). | MAY be emitted **only** by an **inference/classifier** component. MUST cite supporting observed/resolved facts via `derived_from`. MUST be labeled as inferred everywhere it surfaces. | +| **resolved** | A relationship produced by a **documented, deterministic resolution algorithm** over stored observed facts. | MAY be emitted **only** by a **resolver**. The algorithm MUST be documented (per #91) and deterministic: same inputs → same output. Carries `derived_from` observation references linking its observed inputs ([§6.4](#64-observations-and-the-derived_from-reference-model)). | +| **inferred** | A **heuristic** conclusion supported by evidence but **not guaranteed by syntax** (e.g. "this module is the authentication layer"). | MAY be emitted **only** by an **inference/classifier** component. MUST cite the ultimate supporting observations via `derived_from`, including the observation basis of any resolved input. MUST be labeled as inferred everywhere it surfaces. | | **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) @@ -577,8 +733,9 @@ Codes are stable strings. The `ri.v1` baseline set (extensible as a compatible a ## 9. Schema versioning -This RFC **ratifies `schema_version: "ri.v1"`** as the value carried on every snapshot and every -fact-bearing API response. +This RFC **proposes `schema_version: "ri.v1"` for ratification** as the value carried on every +snapshot and every fact-bearing API response. Once ratified ([§1.2](#12-approval--ratification-rule)), +`ri.v1` is the value all `ri.v1` artifacts carry. ### 9.1 Compatible additions @@ -586,26 +743,29 @@ The following MAY be added within `ri.v1` without a version bump, because they c conforming reader that ignores unknown optional fields: - New **optional** fields on nodes, edges, evidence, or diagnostics. -- New **node kinds**, **predicates**, or **diagnostic codes**. +- New **node kinds**, **predicates** (adding a predicate with new semantics — [§5.1](#51-canonical-predicates)), + **observation kinds**, or **diagnostic codes**. - New extractors/resolvers (they bump their own version, not the schema — [§9.4](#94-schema-version-vs-extractorresolver-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 unknown fields, unknown node kinds, unknown predicates, and unknown diagnostic -codes rather than failing. +Readers MUST **ignore or safely preserve** unknown fields, unknown node kinds, **unknown +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)). +- 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 **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 or renaming a required field; changing a field's type or meaning. +- **Removing, renaming, or changing the meaning of an existing predicate** ([§5.1](#51-canonical-predicates)); + 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 ratifies `ri.v2`; this document is not edited -to describe `ri.v2` behavior. +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 @@ -730,8 +890,10 @@ as follows — this is a decision the RFC must not leave open: - **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 create a NEW snapshot** ([§3.4](#34-reanalysis-and-reuse)). There is - no edit-in-place path. +- **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. @@ -743,17 +905,19 @@ as follows — this is a decision the RFC must not leave open: sealed." - **Cancellation** (#93) aborts a `building` snapshot; the aborted snapshot is discarded or marked `failed`, never `completed`. -- **Retry** (#93) starts a **new** `building` snapshot; it never resumes and seals a previously - abandoned one. +- **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): submitting the same analysis (same composite identity, - [§3.3](#33-snapshot-identity)) twice MUST NOT produce two sealed snapshots. Implementations reuse - the existing sealed snapshot ([§3.4](#34-reanalysis-and-reuse)) or coordinate so exactly one - `building` snapshot seals for a given identity. -- Concurrent builds for the **same** composite identity resolve to a single sealed snapshot; the - uniqueness constraint on sealed snapshots ([§3.3](#33-snapshot-identity)) is the backstop. +- **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. --- @@ -772,17 +936,18 @@ extractor/resolver versions, and configuration MUST produce the same canonical h ### 12.2 What is hashed -A single canonical document with three ordered arrays plus scalar inputs: +A single canonical document with four ordered arrays plus scalar inputs: -``` +```jsonc { "schema_version": "ri.v1", "revision": { "kind": "...", "value": "..." }, "extractor_version_set": ["python-ast@1.0.0", "typescript-ast@1.0.0", "import-resolver@1.0.0"], "config_hash": "sha256:...", - "nodes": [ ...sorted... ], - "edges": [ ...sorted, each with sorted evidence... ], - "diagnostics": [ ...sorted... ] + "nodes": [ ...sorted... ], + "edges": [ ...sorted, each with sorted evidence and sorted derived_from... ], + "observations": [ ...sorted... ], + "diagnostics": [ ...sorted... ] } ``` @@ -792,6 +957,9 @@ A single canonical document with three ordered arrays plus scalar inputs: - **Edges** sorted ascending by the tuple `(subject.stable_key, predicate, object.stable_key)`. - **Evidence** within each fact sorted ascending by `(path, start_line, end_line, extractor, extractor_version)`. +- **`derived_from`** within each fact sorted ascending by `observation_id` + ([§6.4](#64-observations-and-the-derived_from-reference-model)). +- **Observations** sorted ascending by `observation_id`. - **Diagnostics** sorted ascending by `(code, path or "", start_line or 0, subject or "", object or "", message)`. - **`extractor_version_set`** sorted ascending by identifier. @@ -818,7 +986,63 @@ change in what the pipeline *could not* handle is a real change in the snapshot' `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` over `nodes`+`edges` alone, but the **primary** `canonical_graph_hash` -covers all three arrays and is the one stored and compared for determinism. +covers all four arrays (`nodes`, `edges`, `observations`, `diagnostics`) and is the one stored and +compared for determinism. + +### 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: the set/selection of enabled extractors and resolvers and their + support-matrix options; resource limits that change *what is extracted* (max file size, file-count + caps, per-file node caps — cf. the current 512 KB cap in + [`engine.py`](../../apps/backend/app/intelligence/engine.py#L168)); 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: the extractor/resolver **versions** are identity + inputs but are carried separately in `extractor_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. the set of enabled extractors) + 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 two extractors and one resolver with a 512 KB file cap: + +```jsonc +// input (pre-canonicalization) +{ "max_file_bytes": 524288, + "extractors": ["typescript-ast", "python-ast"], // set semantics → sorted + "resolvers": ["import-resolver"] } +``` + +```text +canonical bytes: +{"extractors":["python-ast","typescript-ast"],"max_file_bytes":524288,"resolvers":["import-resolver"]} +config_hash = "sha256:" + hex(sha256(canonical bytes)) + = "sha256:fa3223df915a10dd9519b1b5f426416dd756f9ac94d91336efbb53c9f4e036d9" +``` + +`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. --- @@ -908,12 +1132,26 @@ is not. "extractor": "route-resolver", "extractor_version": "1.0.0" } ], "derived_from": [ - { "kind": "node", "stable_key": "app/api/routes/auth.py::login" } + { "kind": "observation", "observation_id": "obs:sha256:a71c…" } ], "schema_version": "ri.v1" } ``` +The referenced observation is the observed route declaration, e.g.: + +```json +{ + "observation_id": "obs:sha256:a71c…", + "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" } +} +``` + ### 14.4 Inferred architecture classification (must be labeled inferred) ```json @@ -925,13 +1163,17 @@ is not. "truth_class": "inferred", "properties": { "classification": "business-logic-layer", "confidence": "heuristic" }, "derived_from": [ - { "kind": "node", "stable_key": "mod:app/services" }, - { "kind": "node", "stable_key": "app/services/auth_service.py::AuthService" } + { "kind": "observation", "observation_id": "obs:sha256:c0d4…" }, + { "kind": "observation", "observation_id": "obs:sha256:d8a1…" } ], "schema_version": "ri.v1" } ``` +> The inferred classification cites the stored definition observations for the module's supporting +> symbols, never a node reference or a span it did not read. The `mod:app/services` node is the +> subject of the classification, so it does not appear in its own `derived_from`. + ### 14.5 Unresolved / ambiguous diagnostic (not a guessed edge) ```json @@ -945,10 +1187,17 @@ is not. "producer": "import-resolver@1.0.0", "subject": "file:src/app/index.ts", "object": null, - "details": { "candidates": ["src/shared/utils/index.ts", "src/app/utils.ts"] } + "details": { + "observation_id": "obs:sha256:5e88…", + "candidates": ["src/shared/utils/index.ts", "src/app/utils.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. + ### 14.6 Unsupported construct ```json @@ -1003,10 +1252,18 @@ is not. { "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:41ca…" }, + { "kind": "observation", "observation_id": "obs:sha256:90fb…" } + ], "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)). + ### 14.10 Generated narrative — explicitly excluded from deterministic facts ```json @@ -1040,6 +1297,8 @@ is not. | **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** | Deterministic observation reference ([§6.4](#64-observations-and-the-derived_from-reference-model)) | A placeholder/edge reference to a possibly-nonexistent relationship | Unresolved occurrences never become edges ([§5.5](#55-unresolved-relationships)); a stored, deterministic observation is the smallest primitive that lets resolved/inferred facts and diagnostics cite observed inputs and still hash deterministically. | ### 15.1 Operational costs and limitations (stated honestly) @@ -1055,8 +1314,18 @@ is not. - **`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.** Until #87–#95 land, the system behaves exactly as - documented today; this document describes the *approved future contract*, not current capability. + documented today; this document describes the *proposed future contract*, not current capability. --- @@ -1079,12 +1348,12 @@ parallel (fixtures early, scoring after approval); #95 last (on #92 and #94). --- -## 17. Current behavior vs. approved contract vs. unimplemented +## 17. Current behavior vs. proposed contract vs. unimplemented 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) | Approved `ri.v1` contract (this RFC) | Status | +| Concern | Current behavior (today) | Proposed `ri.v1` contract (this RFC) | Status | | --- | --- | --- | --- | | Storage | Mutable JSON blob on `repo_metadata` | Immutable sealed snapshots (§11) | **Unimplemented** (#88) | | Symbol spans | None on `SourceSymbol` ([`models.py:55`](../../apps/backend/app/intelligence/models.py#L55)) | Required line spans (§6) | **Unimplemented** (#89/#90) | @@ -1095,7 +1364,7 @@ rules), the three columns are explicit: | Query API | Consumers read the whole blob | Versioned owner-scoped read API (§9.5) | **Unimplemented** (#92) | | Evidence-backed output | AI emits empty citation lists | Every claim cites a valid span (§7.4) | **Unimplemented** (#95) | -**This RFC does not claim any of the "Approved contract" column exists today.** It is the target to +**This RFC does not claim any of the "Proposed contract" column exists today.** It is the target to be built by #87–#95 after approval. No existing documentation is rewritten by this RFC to imply otherwise. @@ -1107,13 +1376,13 @@ otherwise. | #86 acceptance criterion | Satisfied by | | --- | --- | -| Node/edge identity (stable key format) specified and approved | [§4](#4-node-identity-and-stable-keys), [§5](#5-edgefact-identity) | +| Node/edge identity (stable key format) specified (approval pending independent maintainer) | [§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) | -| Approved as an ADR/RFC committed to the repository | [§1](#1-status-and-approval) (Proposed → Accepted on maintainer-approved merge) | +| Approved as an ADR/RFC committed to the repository | [§1](#1-status-and-approval) — currently **Proposed**; becomes **Accepted** only via a final pre-merge update after a recorded independent-maintainer approval ([§1.2](#12-approval--ratification-rule)) | ### 18.2 Required RFC decisions (issue body §1–§15) and #86-comment dependency requirements @@ -1127,7 +1396,7 @@ otherwise. | 6. Provenance contract (min record; one-based; inclusive end; span validation; whole-file; columns deferred; multiple evidence; resolved/inferred → observed; 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 (ratify `ri.v1`; compatible additions; breaking; when v2; schema vs extractor version; API versioning #92; reader rejection/negotiation; historical retention) | [§9](#9-schema-versioning) | +| 9. Schema versioning (propose `ri.v1` for ratification; compatible additions; breaking; when v2; schema vs extractor 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; pre-seal validation; immutability; rejection; corrections ⇒ new snapshot; failed; #93 cancel/retry; concurrency/idempotency; diagnostics immutable) | [§11](#11-snapshot-lifecycle-and-immutability) | | 12. Canonical graph hash (format; node/edge/evidence ordering; normalized strings/paths; diagnostics; excluded volatile fields; schema/extractor inputs; determinism) | [§12](#12-canonical-graph-hash) | From db2136f686d591290d7d584fd4dcef6ee7cce89b Mon Sep 17 00:00:00 2001 From: parthrohit22 Date: Wed, 15 Jul 2026 13:14:02 +0100 Subject: [PATCH 054/347] docs(architecture): complete RFC schema contract fixes (#86) --- docs/README.md | 2 +- .../REPOSITORY_INTELLIGENCE_V1_RFC.md | 626 ++++++++++++++---- 2 files changed, 483 insertions(+), 145 deletions(-) diff --git a/docs/README.md b/docs/README.md index 66cc17c7..93311b07 100644 --- a/docs/README.md +++ b/docs/README.md @@ -14,7 +14,7 @@ Every document listed here is maintained and describes the system as it currentl | [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 | **Proposed** architectural contract (RFC-0001, tracking [#86](https://github.com/Second-Origin/PARTHA/issues/86)) for the future snapshot/evidence schema: stable keys, provenance, truth classes, immutability, diagnostics, versioning, and the canonical graph hash. **Status: Proposed** — it describes a **proposed future contract, not current behaviour**, and is not ratified until an independent maintainer approves it; §17 states plainly what is unimplemented. Governs downstream issues #87–#95. | +| [Repository Intelligence v1 RFC](architecture/REPOSITORY_INTELLIGENCE_V1_RFC.md) | Contributors on the intelligence track | **Proposed** architectural contract (RFC-0001, tracking [#86](https://github.com/Second-Origin/PARTHA/issues/86)) for the future snapshot/evidence schema: deterministic entity keys, separate inferred assertions, complete derivation chains, planned producer identity, provenance, immutability, diagnostics, versioning, and total canonical graph hashing. **Status: Proposed** — it describes a proposed future contract, not current behaviour, and is not ratified until an independent maintainer approves it; §17 states plainly what is unimplemented. Governs downstream issues #87–#95. | | [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. | diff --git a/docs/architecture/REPOSITORY_INTELLIGENCE_V1_RFC.md b/docs/architecture/REPOSITORY_INTELLIGENCE_V1_RFC.md index 43492dc1..6bbf31a5 100644 --- a/docs/architecture/REPOSITORY_INTELLIGENCE_V1_RFC.md +++ b/docs/architecture/REPOSITORY_INTELLIGENCE_V1_RFC.md @@ -80,18 +80,20 @@ 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 set of extractor/resolver versions. The unit of reproducibility. | -| **Fact** | A single stored assertion in a snapshot. Every fact is either a **node** or an **edge**, carries a **truth class** ([§7](#7-truth-classes)), and — when its truth class requires it — carries **provenance** ([§6](#6-provenance-contract)). | +| **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)). | -| **Extractor/resolver version** | The independently incremented version of a specific extractor or resolver **implementation**. Distinct from schema version ([§9.4](#94-schema-version-vs-extractorresolver-version)). | +| **Producer version** | The independently incremented version of a producer implementation. Distinct from schema version ([§9.4](#94-schema-version-vs-producer-version)). | --- @@ -140,14 +142,18 @@ field. A snapshot's identity is the composite: ```text -(repository_id, revision.value, schema_version, extractor_version_set, config_hash) +(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)). -- `extractor_version_set` — the lexicographically sorted, deduplicated set of `extractor@version` and - `resolver@version` identifiers that participated (e.g. `["python-ast@1.0.0", "typescript-ast@1.0.0", "import-resolver@1.0.0"]`). +- `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. @@ -155,6 +161,14 @@ A snapshot's identity is the composite: 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). @@ -172,7 +186,7 @@ These rules are normative and are stated identically in [§11.3](#113-immutabili 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 bumped extractor/resolver version, a schema bump, or + 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 @@ -209,6 +223,12 @@ Every node has a **deterministic stable key**: a pure function of the repository 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 @@ -231,7 +251,8 @@ autoincrement id. Stable keys are UTF-8 strings; the general grammar is `::[#]` | `src/auth/service.ts::AuthService.login` | @@ -277,6 +298,11 @@ stable keys ([§4](#4-node-identity-and-stable-keys)) and for evidence paths ([ 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 `::`. @@ -380,8 +406,8 @@ changing the meaning of** an existing predicate is a breaking change that requir | `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 `KnowledgeGraphRelationship` type union -> ([`models.py:25`](../../apps/backend/app/intelligence/models.py#L25)) also lists `extends`, +> 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 @@ -401,9 +427,10 @@ each carried with its `kind`: } ``` -An edge MUST NOT reference a node that does not exist in the same snapshot, **except** an object -that is an external `dependency` node or an explicitly *unresolved* target (which is not stored as -an edge at all — see [§5.5](#55-unresolved-relationships)). +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 @@ -428,7 +455,7 @@ This is a decision, not an option: 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 - `(path, start_line, end_line, extractor, extractor_version)` — [§12.3](#123-ordering)). + 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. @@ -440,6 +467,63 @@ that names the subject stable key and the unresolved reference text. This is the *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 @@ -470,18 +554,44 @@ The **minimum required** evidence record — the fields every stored piece of ev - **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`. -- **Validation.** An evidence record is **valid** iff `1 ≤ start_line ≤ end_line ≤ (line count of - the file at the stored revision)`. A record that is reversed (`end_line < start_line`), zero or - negative, or out of range against the stored revision is **invalid**. +- **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:` the file's last line, + 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 @@ -492,11 +602,12 @@ The **minimum required** evidence record — the fields every stored piece of ev - **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 reference their supporting observed inputs via `derived_from`.** A - `resolved` or `inferred` fact carries a `derived_from` list of **observation references** - ([§6.4](#64-observations-and-the-derived_from-reference-model)) — deterministic pointers to the - stored observed inputs it was computed from. An `inferred` fact MUST cite the ultimate observations - supporting its observed or resolved inputs; it MUST NOT invent a span it did not read. +- **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 @@ -580,40 +691,59 @@ immutable revision makes the ID revision-tied; including the extractor identifie 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` reference.** A `resolved`/`inferred` fact's `derived_from` is a list of -observation references: +**The `derived_from` tagged reference.** A derived fact records its **immediate** inputs with one or +more of these exact shapes: ```json -{ - "derived_from": [ - { - "kind": "observation", - "observation_id": "obs:sha256:d76b7bdbae85fc2016c49cf893a99ac8156cc98c3357582ab092836b94f0b424" - } - ] -} +[ + { + "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 entry MUST name an `observation_id` that exists in the same snapshot. A `derived_from` entry -MUST NOT reference a node or edge directly, a placeholder, or free text. When a definition is an -input, `derived_from` names its `observed_kind: "definition"` observation. When an inference uses a -resolved fact, it names that fact's ultimate supporting observations. This keeps one exact reference -shape while preserving the full observed basis of every derived claim. +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. The observation - remains stored. + 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-ordering)); `derived_from` lists are canonicalized by - sorting their entries. Because both `observation_id` and `derived_from` are deterministic functions - of the stored revision, identical inputs produce identical bytes. + ([§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. --- @@ -623,9 +753,9 @@ shape while preserving the full observed basis of every derived claim. | Truth class | Definition | Emission rule | | --- | --- | --- | -| **observed** | A direct syntax 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 parsing that exact source. No extractor may emit `observed` without a valid span ([§6.2](#62-line-and-span-rules-decided-not-optional)). | -| **resolved** | A relationship produced by a **documented, deterministic resolution algorithm** over stored observed facts. | MAY be emitted **only** by a **resolver**. The algorithm MUST be documented (per #91) and deterministic: same inputs → same output. Carries `derived_from` observation references linking its observed inputs ([§6.4](#64-observations-and-the-derived_from-reference-model)). | -| **inferred** | A **heuristic** conclusion supported by evidence but **not guaranteed by syntax** (e.g. "this module is the authentication layer"). | MAY be emitted **only** by an **inference/classifier** component. MUST cite the ultimate supporting observations via `derived_from`, including the observation basis of any resolved input. MUST be labeled as inferred everywhere it surfaces. | +| **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) @@ -636,13 +766,20 @@ shape while preserving the full observed basis of every derived claim. | --- | :---: | :---: | :---: | :---: | | **Extractor** (`typescript-ast`, `python-ast`) | ✔ | | | | | **Resolver** (`import-resolver`, `route-resolver`, `reference-resolver`) | | ✔ | | | -| **Classifier / inference** (architecture/layer classification) | | | ✔ | | +| **Classifier / inference** (architecture/layer classification) | | | ✔ (assertion only) | | | **Narrative generator** (AI answer, doc prose) | | | | ✔ (never in graph) | | **Legacy regex engine** (today's [`engine.py`](../../apps/backend/app/intelligence/engine.py)) | | | | 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. +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 @@ -650,8 +787,9 @@ component may store `generated` narrative as a graph fact. `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 is retained.** Downgrading is also not an in-place operation; every fact - keeps its evidence/`derived_from` for the life of the snapshot. +- **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 @@ -684,7 +822,7 @@ Diagnostics are first-class, structured snapshot output. Every diagnostic record | `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 | The extractor/resolver identifier and version, e.g. `typescript-ast@1.0.0`. REQUIRED. | +| `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. | @@ -702,6 +840,7 @@ Codes are stable strings. The `ri.v1` baseline set (extensible as a compatible a | 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. today's 512 KB cap in [`engine.py`](../../apps/backend/app/intelligence/engine.py#L168) 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)). | @@ -723,7 +862,7 @@ Codes are stable strings. The `ri.v1` baseline set (extensible as a compatible a - 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-LIMIT-SKIP`, +- `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 @@ -742,24 +881,30 @@ snapshot and every fact-bearing API response. Once ratified ([§1.2](#12-approva 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, evidence, or diagnostics. +- 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)), - **observation kinds**, or **diagnostic codes**. -- New extractors/resolvers (they bump their own version, not the schema — [§9.4](#94-schema-version-vs-extractorresolver-version)). + **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 observation kinds, and unknown diagnostic codes rather than failing. +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 predicate** ([§5.1](#51-canonical-predicates)); +- **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. @@ -773,13 +918,13 @@ Precisely when a change falls under [§9.2](#92-breaking-changes-require-riv2). 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 extractor/resolver version +### 9.4 Schema version vs producer version - **Schema version** (`ri.v1`) versions the *contract*. -- **Extractor/resolver version** versions an *implementation*. Bumping `typescript-ast` from - `1.0.0` to `1.1.0` (it now extracts a new construct) does **not** change the schema version; it - changes the snapshot's `extractor_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 +- **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 @@ -860,8 +1005,9 @@ as follows — this is a decision the RFC must not leave open: `ri.v1` defines exactly these snapshot states: -- **`building`** — the snapshot is being populated. Facts and diagnostics are being written. Not - visible to consumers as an authoritative result. +- **`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 @@ -881,12 +1027,24 @@ as follows — this is a decision the RFC must not leave open: 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. The canonical hash is reproducible (computing it twice yields the same value). + 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 nodes, edges, evidence, diagnostics, and hash MUST NOT - change. +- **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. @@ -925,7 +1083,8 @@ as follows — this is a decision the RFC must not leave open: 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, -extractor/resolver versions, and configuration MUST produce the same canonical hash.** +planned producer versions, and configuration MUST produce the same canonical hash, independent of +record insertion order.** ### 12.1 Serialization format @@ -936,48 +1095,98 @@ extractor/resolver versions, and configuration MUST produce the same canonical h ### 12.2 What is hashed -A single canonical document with four ordered arrays plus scalar inputs: +A single canonical document with five ordered arrays plus scalar inputs: ```jsonc { "schema_version": "ri.v1", "revision": { "kind": "...", "value": "..." }, - "extractor_version_set": ["python-ast@1.0.0", "typescript-ast@1.0.0", "import-resolver@1.0.0"], + "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": [ ...sorted... ], - "edges": [ ...sorted, each with sorted evidence and sorted derived_from... ], - "observations": [ ...sorted... ], - "diagnostics": [ ...sorted... ] + "nodes": [""], + "edges": [""], + "assertions": [""], + "observations": [""], + "diagnostics": [""] } ``` -### 12.3 Ordering - -- **Nodes** sorted ascending by `stable_key` (byte order of the NFC-normalized UTF-8 string). -- **Edges** sorted ascending by the tuple `(subject.stable_key, predicate, object.stable_key)`. -- **Evidence** within each fact sorted ascending by `(path, start_line, end_line, extractor, - extractor_version)`. -- **`derived_from`** within each fact sorted ascending by `observation_id` - ([§6.4](#64-observations-and-the-derived_from-reference-model)). -- **Observations** sorted ascending by `observation_id`. -- **Diagnostics** sorted ascending by `(code, path or "", start_line or 0, subject or "", object or - "", message)`. -- **`extractor_version_set`** sorted ascending by identifier. +### 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`. + 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 row id, host/environment identifiers, and the surrogate `edge_id` (which is a pure -function of already-hashed content). `revision.ref` (a moving branch name) is **excluded**; -`revision.value` (the immutable SHA/content hash) is **included**. +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 @@ -985,9 +1194,10 @@ Diagnostics **are included** in the canonical document ([§12.2](#122-what-is-ha 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` over `nodes`+`edges` alone, but the **primary** `canonical_graph_hash` -covers all four arrays (`nodes`, `edges`, `observations`, `diagnostics`) and is the one stored and -compared for determinism. +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` @@ -996,25 +1206,25 @@ is a component of snapshot identity ([§3.3](#33-snapshot-identity)) and an inpu 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: the set/selection of enabled extractors and resolvers and their - support-matrix options; resource limits that change *what is extracted* (max file size, file-count - caps, per-file node caps — cf. the current 512 KB cap in + 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 KB cap in [`engine.py`](../../apps/backend/app/intelligence/engine.py#L168)); 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: the extractor/resolver **versions** are identity - inputs but are carried separately in `extractor_version_set` ([§3.3](#33-snapshot-identity)); they + 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. the set of enabled extractors) - 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. +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 @@ -1024,20 +1234,22 @@ graph hash ([§12.2](#122-what-is-hashed)), so it MUST be computed by exactly th `sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a`. An implementation MUST NOT use this empty hash merely because the caller supplied no overrides. -**Normative example.** Configuration enabling two extractors and one resolver with a 512 KB file cap: +**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"], // set semantics → sorted - "resolvers": ["import-resolver"] } + "extractors": ["typescript-ast", "python-ast", "repository-inventory"], + "resolvers": ["route-resolver", "import-resolver", "reference-resolver"], + "classifiers": ["architecture-classifier"] } ``` ```text canonical bytes: -{"extractors":["python-ast","typescript-ast"],"max_file_bytes":524288,"resolvers":["import-resolver"]} +{"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:fa3223df915a10dd9519b1b5f426416dd756f9ac94d91336efbb53c9f4e036d9" + = "sha256:48e96ba328a03db38556f22d2831d171b82e1ce9287c575328de4bc249da1abe" ``` `config_hash` MUST be referenced by both snapshot identity ([§3.3](#33-snapshot-identity)) and the @@ -1054,7 +1266,7 @@ 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 - [`get_for_owner`](../../apps/backend/app/services/repository_service.py#L250)); a cross-owner + [`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 @@ -1122,17 +1334,22 @@ is not. ```json { "kind": "edge", - "edge_id": "edge:sha256:…", + "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:a71c…" } + { + "kind": "observation", + "observation_id": "obs:sha256:86730689178079a960cf3019128882be518e92e7cdedb67d3dd4351f0201fc7e" + } ], "schema_version": "ri.v1" } @@ -1142,7 +1359,7 @@ The referenced observation is the observed route declaration, e.g.: ```json { - "observation_id": "obs:sha256:a71c…", + "observation_id": "obs:sha256:86730689178079a960cf3019128882be518e92e7cdedb67d3dd4351f0201fc7e", "observed_kind": "route", "subject": { "kind": "symbol", "stable_key": "app/api/routes/auth.py::login" }, "referent_text": "/login", @@ -1152,27 +1369,62 @@ The referenced observation is the observed route declaration, e.g.: } ``` +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", - "properties": { "classification": "business-logic-layer", "confidence": "heuristic" }, + "producer": "architecture-classifier", + "producer_version": "1.0.0", "derived_from": [ - { "kind": "observation", "observation_id": "obs:sha256:c0d4…" }, - { "kind": "observation", "observation_id": "obs:sha256:d8a1…" } + { "kind": "node", "stable_key": "mod:app/services" }, + { + "kind": "edge", + "edge_id": "edge:sha256:e20b7e1135e0535ffb7c19cb2066a0645d9e980d2071816ddfc967431b774807" + } ], "schema_version": "ri.v1" } ``` -> The inferred classification cites the stored definition observations for the module's supporting -> symbols, never a node reference or a span it did not read. The `mod:app/services` node is the -> subject of the classification, so it does not appear in its own `derived_from`. +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) @@ -1188,8 +1440,8 @@ The referenced observation is the observed route declaration, e.g.: "subject": "file:src/app/index.ts", "object": null, "details": { - "observation_id": "obs:sha256:5e88…", - "candidates": ["src/shared/utils/index.ts", "src/app/utils.ts"] + "observation_id": "obs:sha256:d8544c0e7ec142ab6d1cf98919657cf0d289d7f9490d9d03b55d8aab4fafe98c", + "candidates": ["src/app/utils.ts", "src/shared/utils/index.ts"] } } ``` @@ -1198,6 +1450,22 @@ The `observation_id` names the stored observed import occurrence that could not 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 @@ -1221,11 +1489,23 @@ can still cite the unresolved import. { "snapshot_id": "snap_9c2…", "repository_id": "repo_7f3…", - "revision": { "kind": "upload", "value": "sha256:3b1f…c9", "ref": null }, - "schema_version": "ri.v1" + "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 @@ -1233,7 +1513,17 @@ can still cite the unresolved import. "snapshot_id": "snap_1a4…", "repository_id": "repo_7f3…", "revision": { "kind": "git", "value": "9f1d0c7a2b6e4c5d8f3a1b0c7d9e2f4a6b8c0d1e", "ref": "refs/heads/main" }, - "schema_version": "ri.v1" + "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" } ``` @@ -1242,10 +1532,13 @@ can still cite the unresolved import. ```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" }, @@ -1253,8 +1546,14 @@ can still cite the unresolved import. "extractor": "reference-resolver", "extractor_version": "1.0.0" } ], "derived_from": [ - { "kind": "observation", "observation_id": "obs:sha256:41ca…" }, - { "kind": "observation", "observation_id": "obs:sha256:90fb…" } + { + "kind": "observation", + "observation_id": "obs:sha256:ad475fae87c8121b76673172a560885335661f68ea90e4b5fa661be9b884f24a" + }, + { + "kind": "observation", + "observation_id": "obs:sha256:082048c4fd048043fbc22da52166e7fab6f37fa0f8803a1b3444b412b6ba1dd4" + } ], "schema_version": "ri.v1" } @@ -1264,6 +1563,33 @@ can still cite the unresolved import. > 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 @@ -1273,8 +1599,11 @@ can still cite the unresolved import. "stored_in_graph": false, "text": "Authentication is handled by AuthService.login, which issues a token via issueToken.", "claims": [ - { "stable_key": "src/auth/service.ts::AuthService.login", "evidence_ref": "…" }, - { "stable_key": "src/auth/tokens.ts::issueToken", "evidence_ref": "…" } + { "kind": "node", "stable_key": "src/auth/service.ts::AuthService.login" }, + { + "kind": "edge", + "edge_id": "edge:sha256:90594a4734e993838e2db11f9d3bb5ede0cab2f1c70730ca8c4ab407c93bd69e" + } ] } ``` @@ -1291,6 +1620,8 @@ can still cite the unresolved import. | --- | --- | --- | --- | | **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. | @@ -1298,7 +1629,10 @@ can still cite the unresolved import. | **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** | Deterministic observation reference ([§6.4](#64-observations-and-the-derived_from-reference-model)) | A placeholder/edge reference to a possibly-nonexistent relationship | Unresolved occurrences never become edges ([§5.5](#55-unresolved-relationships)); a stored, deterministic observation is the smallest primitive that lets resolved/inferred facts and diagnostics cite observed inputs and still hash deterministically. | +| **`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) @@ -1308,7 +1642,7 @@ can still cite the unresolved import. - **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 extractors.** Extractors/resolvers must be deterministic and must not +- **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 @@ -1355,11 +1689,14 @@ rules), the three columns are explicit: | Concern | Current behavior (today) | Proposed `ri.v1` contract (this RFC) | Status | | --- | --- | --- | --- | -| Storage | Mutable JSON blob on `repo_metadata` | Immutable sealed snapshots (§11) | **Unimplemented** (#88) | +| Storage | Mutable JSON blob on `repo_metadata` | Immutable sealed snapshots with nodes, edges, assertions, observations, evidence, and diagnostics (§11) | **Unimplemented** (#88) | +| Pipeline identity | No pre-enqueue producer plan | Precomputed `producer_version_set` covers every enabled extractor/resolver/classifier (§3.3) | **Unimplemented** (#88/#93) | +| Repository graph key | No canonical snapshot node key | Deterministic snapshot-scoped `repo:root`; database `repository_id` excluded from graph keys (§4.3) | **Unimplemented** (#88) | | Symbol spans | None on `SourceSymbol` ([`models.py:55`](../../apps/backend/app/intelligence/models.py#L55)) | Required line spans (§6) | **Unimplemented** (#89/#90) | | Extraction | Regex in `engine.py`; `TreeSitterParser` returns `[]` | Syntax-aware extractors with support matrices | **Unimplemented** (#89/#90) | | Revision identity | `commitSha` in a JSON blob | Indexed immutable columns (§3) | **Unimplemented** (#87) | | Relationships | 4 of 8 declared types emitted; imports as text | Resolved edges + diagnostics (§5) | **Unimplemented** (#91) | +| Inferred entity properties | Heuristic module roles embedded in the mutable model | Separate inferred property assertions; observed nodes remain unique (§5.6) | **Unimplemented** (#88/#92) | | Provenance | File paths only | Path + span + extractor/version (§6) | **Unimplemented** | | Query API | Consumers read the whole blob | Versioned owner-scoped read API (§9.5) | **Unimplemented** (#92) | | Evidence-backed output | AI emits empty citation lists | Every claim cites a valid span (§7.4) | **Unimplemented** (#95) | @@ -1376,7 +1713,7 @@ otherwise. | #86 acceptance criterion | Satisfied by | | --- | --- | -| Node/edge identity (stable key format) specified (approval pending independent maintainer) | [§4](#4-node-identity-and-stable-keys), [§5](#5-edgefact-identity) | +| Node/edge identity (stable-key format) specified; assertion identity also specified (approval pending independent maintainer) | [§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) | @@ -1389,29 +1726,29 @@ otherwise. | 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, evidence, provenance, diagnostic, extractor, resolver, schema/extractor version | [§2](#2-normative-terminology) | -| 3. Revision & snapshot identity (git SHA + ref; upload sha256; moving names; composite identity; reanalysis; reuse; #87 migration) | [§3](#3-revision-and-snapshot-identity) | -| 4. Node identity & stable keys (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/fact identity (predicates; subject/object; edge IDs; snapshot-scoped; multiple occurrences; multiple evidence; ordering/dedup; #91 relationships) | [§5](#5-edgefact-identity) | -| 6. Provenance contract (min record; one-based; inclusive end; span validation; whole-file; columns deferred; multiple evidence; resolved/inferred → observed; revision tie; no provenance ⇒ not observed) | [§6](#6-provenance-contract) | +| 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 (propose `ri.v1` for ratification; compatible additions; breaking; when v2; schema vs extractor version; API versioning #92; reader rejection/negotiation; historical retention) | [§9](#9-schema-versioning) | +| 9. Schema versioning (propose `ri.v1` for ratification; 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; pre-seal validation; immutability; rejection; corrections ⇒ new snapshot; failed; #93 cancel/retry; concurrency/idempotency; diagnostics immutable) | [§11](#11-snapshot-lifecycle-and-immutability) | -| 12. Canonical graph hash (format; node/edge/evidence ordering; normalized strings/paths; diagnostics; excluded volatile fields; schema/extractor inputs; determinism) | [§12](#12-canonical-graph-hash) | +| 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 (#87–#93,#95 blocked; #94 fixtures early; #94 scoring gated; no assignment on draft) | [§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 & canonical hash | [§11](#11-snapshot-lifecycle-and-immutability), [§12](#12-canonical-graph-hash) | +| #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; resolved class; diagnostics) | [§5.1](#51-canonical-predicates), [§5.5](#55-unresolved-relationships), [§7](#7-truth-classes) | -| #92 — versioned owner-scoped query API | [§9.5](#95-api-response-versioning-92-and-negotiation), [§13](#13-security-and-ownership) | +| #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 & canonical graph hashing | [§6.3](#63-multiple-evidence-resolvedinferred-evidence-and-the-revision-tie), [§12](#12-canonical-graph-hash), [§16](#16-dependency-gate) | +| #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) | --- @@ -1421,7 +1758,8 @@ otherwise. - [`apps/backend/app/intelligence/models.py`](../../apps/backend/app/intelligence/models.py) — current serialized model; `SourceSymbol` has no span. - [`apps/backend/app/intelligence/engine.py`](../../apps/backend/app/intelligence/engine.py) — current regex extraction. - [`apps/backend/app/parsers/tree_sitter_parser.py`](../../apps/backend/app/parsers/tree_sitter_parser.py) — placeholder parser (returns no symbols). -- [`apps/backend/app/services/repository_service.py`](../../apps/backend/app/services/repository_service.py) — `_metadata_with_intelligence`, `_content_hash_for_upload`, owner-scoped `get_for_owner`. +- [`apps/backend/app/services/repository_service.py`](../../apps/backend/app/services/repository_service.py) — `_metadata_with_intelligence`, `_content_hash_for_upload`, 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. From e753a1d83c364fa8e47a3084fc05b5d438d27e6a Mon Sep 17 00:00:00 2001 From: parthrohit22 Date: Wed, 15 Jul 2026 14:02:50 +0100 Subject: [PATCH 055/347] fix(dependencies): report assessments as not computed (#82) --- README.md | 2 +- apps/backend/app/graph/dependency_graph.py | 13 +-- apps/backend/app/reports/builders.py | 8 ++ apps/backend/app/schemas/dependencies.py | 10 ++- apps/backend/tests/test_export_api.py | 20 +++++ apps/backend/tests/test_ingestion_pipeline.py | 73 +++++++++++++++++ apps/backend/tests/test_report_builders.py | 23 ++++-- .../tests/test_repository_intelligence.py | 2 + .../src/app/pages/DependenciesPage.test.tsx | 82 +++++++++++++++++++ .../src/app/pages/DependenciesPage.tsx | 22 +++-- .../frontend/src/shared/services/api/types.ts | 10 ++- docs/architecture/REPOSITORY_INTELLIGENCE.md | 2 +- docs/architecture/SYSTEM_OVERVIEW.md | 2 +- 13 files changed, 242 insertions(+), 27 deletions(-) create mode 100644 apps/frontend/src/app/pages/DependenciesPage.test.tsx diff --git a/README.md b/README.md index ea17f47f..a7565961 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ Statuses below were checked against the implementation, not against prior docume | Citations and grounded AI answers | **Not implemented** | No source content or line numbers are sent to providers, and no citations are returned. | | Asynchronous / incremental processing | **Not implemented** | Ingestion and analysis run synchronously in the request; there is no background job system and no incremental re-analysis. | | Change-impact analysis | **Not implemented** | — | -| Vulnerability and outdated-dependency scanning | **Not implemented** | The API exposes these fields, but they are constants. No scanning is performed. | +| Vulnerability and outdated-dependency scanning | **Not implemented** | The API exposes explicit `not_computed` assessment statuses. It emits no clean result or count because no scanning is performed. | | Persistent semantic knowledge graph | **Not implemented** | The graph is serialized as JSON onto the repository row; there is no queryable graph store. | A capability is listed as implemented only where the behaviour exists in code — not because a model, an API field, a class name, or an issue describes it. diff --git a/apps/backend/app/graph/dependency_graph.py b/apps/backend/app/graph/dependency_graph.py index 79e2f66f..9bcf17c2 100644 --- a/apps/backend/app/graph/dependency_graph.py +++ b/apps/backend/app/graph/dependency_graph.py @@ -1,6 +1,11 @@ from app.intelligence.engine import RepositoryIntelligenceEngine from app.models.repository import RepositoryRecord -from app.schemas.dependencies import DependencyEdge, DependencyGraphResponse, DependencyNode +from app.schemas.dependencies import ( + DependencyAssessment, + DependencyEdge, + DependencyGraphResponse, + DependencyNode, +) class DependencyGraphBuilder: @@ -15,8 +20,6 @@ def build(self, record: RepositoryRecord) -> DependencyGraphResponse: name=dependency.name, version=dependency.version, type=dependency.type, - has_vulnerabilities=False, - is_outdated=False, size=None, ) for dependency in repository_intelligence.dependencies @@ -32,6 +35,6 @@ def build(self, record: RepositoryRecord) -> DependencyGraphResponse: nodes=nodes, edges=edges, total_dependencies=len(nodes), - vulnerabilities=0, - outdated=0, + vulnerability_assessment=DependencyAssessment(status="not_computed"), + outdated_assessment=DependencyAssessment(status="not_computed"), ) diff --git a/apps/backend/app/reports/builders.py b/apps/backend/app/reports/builders.py index b541a03a..68cca46e 100644 --- a/apps/backend/app/reports/builders.py +++ b/apps/backend/app/reports/builders.py @@ -175,6 +175,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(), + ], ], ), ), diff --git a/apps/backend/app/schemas/dependencies.py b/apps/backend/app/schemas/dependencies.py index 19857542..c0108203 100644 --- a/apps/backend/app/schemas/dependencies.py +++ b/apps/backend/app/schemas/dependencies.py @@ -8,8 +8,6 @@ class DependencyNode(CamelModel): name: str version: str type: Literal["production", "development", "peer", "optional"] - has_vulnerabilities: bool - is_outdated: bool size: int | None = None @@ -19,10 +17,14 @@ class DependencyEdge(CamelModel): type: Literal["depends-on", "peer", "optional"] +class DependencyAssessment(CamelModel): + status: Literal["not_computed"] + + class DependencyGraphResponse(CamelModel): repository_id: str nodes: list[DependencyNode] edges: list[DependencyEdge] total_dependencies: int - vulnerabilities: int - outdated: int + vulnerability_assessment: DependencyAssessment + outdated_assessment: DependencyAssessment diff --git a/apps/backend/tests/test_export_api.py b/apps/backend/tests/test_export_api.py index f0111caf..00b3f509 100644 --- a/apps/backend/tests/test_export_api.py +++ b/apps/backend/tests/test_export_api.py @@ -108,6 +108,26 @@ def test_export_json_supported_for_every_target(auth_client): json.loads(body["content"]) # must be valid JSON +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) diff --git a/apps/backend/tests/test_ingestion_pipeline.py b/apps/backend/tests/test_ingestion_pipeline.py index b902d900..33109081 100644 --- a/apps/backend/tests/test_ingestion_pipeline.py +++ b/apps/backend/tests/test_ingestion_pipeline.py @@ -78,6 +78,79 @@ def test_zip_upload_persists_repository_and_analysis_completes(auth_client): assert repositories[0]["status"] == "completed" +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 + + 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 + + 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 _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( auth_client, diff --git a/apps/backend/tests/test_report_builders.py b/apps/backend/tests/test_report_builders.py index d2d843ab..81195b68 100644 --- a/apps/backend/tests/test_report_builders.py +++ b/apps/backend/tests/test_report_builders.py @@ -8,7 +8,7 @@ ArchNode, RequestFlowStep, ) -from app.schemas.dependencies import DependencyGraphResponse, DependencyNode +from app.schemas.dependencies import DependencyAssessment, DependencyGraphResponse, DependencyNode def _architecture() -> ArchitectureResponse: @@ -53,13 +53,13 @@ def _dependencies() -> DependencyGraphResponse: return DependencyGraphResponse( repository_id="repo-1", 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="dependency:npm:react", name="react", version="^18.0.0", type="production"), + DependencyNode(id="dependency:npm:vite", name="vite", version="^5.0.0", type="development"), ], edges=[], total_dependencies=2, - vulnerabilities=0, - outdated=0, + vulnerability_assessment=DependencyAssessment(status="not_computed"), + outdated_assessment=DependencyAssessment(status="not_computed"), ) @@ -83,13 +83,24 @@ 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", + 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_repository_intelligence.py b/apps/backend/tests/test_repository_intelligence.py index e98e6556..6906e343 100644 --- a/apps/backend/tests/test_repository_intelligence.py +++ b/apps/backend/tests/test_repository_intelligence.py @@ -106,4 +106,6 @@ def test_feature_consumers_read_repository_intelligence(tmp_path: Path): 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 dependencies.vulnerability_assessment.status == "not_computed" + assert dependencies.outdated_assessment.status == "not_computed" assert review.summary.total_findings >= 1 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..028d2888 --- /dev/null +++ b/apps/frontend/src/app/pages/DependenciesPage.test.tsx @@ -0,0 +1,82 @@ +import { render, screen } 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 { DependenciesPage } from './DependenciesPage'; + +vi.mock('@/features/dependencies/hooks/useDependencies', () => ({ + useDependencies: vi.fn(), +})); + +describe('DependenciesPage', () => { + beforeEach(() => { + vi.mocked(useDependencies).mockReturnValue({ + activeRepository: { + id: 'repo-1', + name: 'sample', + source: 'upload', + size: 100, + fileCount: 2, + status: 'completed', + dataSource: 'real', + 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', + loading: false, + error: null, + empty: false, + success: true, + source: 'real', + emptyReason: null, + graph: { + repositoryId: 'repo-1', + nodes: [ + { + id: 'dependency:npm:lodash', + name: 'lodash', + version: '4.17.15', + type: 'production', + size: null, + }, + ], + edges: [], + totalDependencies: 1, + vulnerabilityAssessment: { status: 'not_computed' }, + outdatedAssessment: { status: 'not_computed' }, + }, + retry: vi.fn(), + refresh: vi.fn(), + packageManager: 'npm', + }); + }); + + it('shows uncomputed assessments without clean badges or numeric fallbacks', () => { + render( + + + , + ); + + 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(); + }); +}); diff --git a/apps/frontend/src/app/pages/DependenciesPage.tsx b/apps/frontend/src/app/pages/DependenciesPage.tsx index 4aad471e..9bacd648 100644 --- a/apps/frontend/src/app/pages/DependenciesPage.tsx +++ b/apps/frontend/src/app/pages/DependenciesPage.tsx @@ -6,6 +6,7 @@ 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 type { DependencyAssessment } from '@/shared/services/api/types'; export function DependenciesPage() { const navigate = useNavigate(); @@ -81,9 +82,18 @@ export function DependenciesPage() {
- - + +
+

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

@@ -118,8 +128,6 @@ export function DependenciesPage() {
{node.type} - {node.hasVulnerabilities && vulnerable} - {node.isOutdated && outdated}
))} @@ -133,7 +141,11 @@ export function DependenciesPage() { ); } -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}

diff --git a/apps/frontend/src/shared/services/api/types.ts b/apps/frontend/src/shared/services/api/types.ts index fb930c50..dd9fb30c 100644 --- a/apps/frontend/src/shared/services/api/types.ts +++ b/apps/frontend/src/shared/services/api/types.ts @@ -94,8 +94,6 @@ export interface DependencyNode { name: string; version: string; type: 'production' | 'development' | 'peer' | 'optional'; - hasVulnerabilities: boolean; - isOutdated: boolean; size: number | null; } @@ -105,13 +103,17 @@ export interface DependencyEdge { type: 'depends-on' | 'peer' | 'optional'; } +export interface DependencyAssessment { + status: 'not_computed'; +} + export interface DependencyGraphResponse { repositoryId: string; nodes: DependencyNode[]; edges: DependencyEdge[]; totalDependencies: number; - vulnerabilities: number; - outdated: number; + vulnerabilityAssessment: DependencyAssessment; + outdatedAssessment: DependencyAssessment; } // Review diff --git a/docs/architecture/REPOSITORY_INTELLIGENCE.md b/docs/architecture/REPOSITORY_INTELLIGENCE.md index c167a748..85e97c88 100644 --- a/docs/architecture/REPOSITORY_INTELLIGENCE.md +++ b/docs/architecture/REPOSITORY_INTELLIGENCE.md @@ -174,7 +174,7 @@ Do not describe PARTHA as having evidence-backed or grounded output until line s - **Graph persistence:** a JSON blob on a metadata column. No graph tables, no queryability, no incremental update. - **Relationships:** four of the eight declared types are never emitted. An import edge resolves to a declared dependency when the name matches and otherwise creates an `external:` node — there is no real module resolution. - **Revision identity:** recorded per repository, not per fact. No history, no diffing, no re-analysis on change. -- **Dependencies:** three manifest formats, no lockfiles, no transitive resolution, and no vulnerability or outdated data (those API fields are hardcoded to `false`/`0`). +- **Dependencies:** three manifest formats, no lockfiles, 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. - **Languages:** meaningful extraction covers Python and TypeScript/JavaScript. 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, synchronously, inside the HTTP request. diff --git a/docs/architecture/SYSTEM_OVERVIEW.md b/docs/architecture/SYSTEM_OVERVIEW.md index 88307d51..563cb9b7 100644 --- a/docs/architecture/SYSTEM_OVERVIEW.md +++ b/docs/architecture/SYSTEM_OVERVIEW.md @@ -233,7 +233,7 @@ These are properties of the system as built, not a wish list. 3. **The knowledge graph is not persisted as a graph.** It is a JSON blob on `repo_metadata`. It cannot be queried, indexed, or joined. Four of the eight declared relationship types are never emitted. 4. **Processing is synchronous and whole-repository.** No background jobs, no incremental re-analysis, no cancellation. 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, no lockfiles, no transitive resolution; the vulnerability and outdated fields in the API are constants, not scan results. +6. **Dependency coverage is narrow.** Three manifest formats, no lockfiles, 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 is thin.** Coverage is limited and there is no end-to-end suite. 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. From 6932ea32294d779147882e5f20cdad4b6879df19 Mon Sep 17 00:00:00 2001 From: parthrohit22 Date: Thu, 16 Jul 2026 02:05:02 +0100 Subject: [PATCH 056/347] docs(architecture): reopen RFC-0001 ratification --- docs/README.md | 2 +- .../REPOSITORY_INTELLIGENCE_V1_RFC.md | 16 +++++++++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/docs/README.md b/docs/README.md index 93311b07..45f9e3cf 100644 --- a/docs/README.md +++ b/docs/README.md @@ -14,7 +14,7 @@ Every document listed here is maintained and describes the system as it currentl | [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 | **Proposed** architectural contract (RFC-0001, tracking [#86](https://github.com/Second-Origin/PARTHA/issues/86)) for the future snapshot/evidence schema: deterministic entity keys, separate inferred assertions, complete derivation chains, planned producer identity, provenance, immutability, diagnostics, versioning, and total canonical graph hashing. **Status: Proposed** — it describes a proposed future contract, not current behaviour, and is not ratified until an independent maintainer approves it; §17 states plainly what is unimplemented. Governs downstream issues #87–#95. | +| [Repository Intelligence v1 RFC](architecture/REPOSITORY_INTELLIGENCE_V1_RFC.md) | Contributors on the intelligence track | **Proposed** architectural contract (RFC-0001, tracking [#86](https://github.com/Second-Origin/PARTHA/issues/86)) for the future snapshot/evidence schema: deterministic entity keys, separate inferred assertions, complete derivation chains, planned producer identity, provenance, immutability, diagnostics, versioning, and total canonical graph hashing. **Status: Proposed** — it describes a proposed future contract, not current behaviour, and is not ratified until an independent maintainer approves it. Issue #86 was reopened on 2026-07-16 to complete that missing ratification step explicitly; §17 states plainly what is unimplemented. Governs downstream issues #87–#95. | | [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. | diff --git a/docs/architecture/REPOSITORY_INTELLIGENCE_V1_RFC.md b/docs/architecture/REPOSITORY_INTELLIGENCE_V1_RFC.md index 6bbf31a5..555493a1 100644 --- a/docs/architecture/REPOSITORY_INTELLIGENCE_V1_RFC.md +++ b/docs/architecture/REPOSITORY_INTELLIGENCE_V1_RFC.md @@ -9,7 +9,7 @@ | **Author** | @parthrohit22 | | **Decision owners (ratifiers)** | An **independent project maintainer other than the author**. The author (@parthrohit22) cannot ratify their own RFC. Ratification is not yet recorded. | | **Created** | 2026-07-15 | -| **Last updated** | 2026-07-15 | +| **Last updated** | 2026-07-16 | | **Status** | **Proposed** | | **Supersedes** | — | | **Superseded by** | — | @@ -54,6 +54,20 @@ Approval releases the dependency gate in [§16](#16-dependency-gate). Until then issue in the intelligence track (except #94 fixture construction) may begin, and none may be assigned against an unapproved draft. +### 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. + +This follow-up intentionally leaves the RFC **Proposed**. An independent project maintainer must +first record approval. Only after that approval exists may a final pre-merge update set the status +to **Accepted**, record the ratifier and ratification date, and close #86. Until that update is +merged, the dependency gate in §16 remains in force for contributors who do not have an explicit +repository-administrator exception. + --- ## 2. Normative terminology From 1fe5a026af3236eb2bae036c3f2b811c45189703 Mon Sep 17 00:00:00 2001 From: parthrohit22 Date: Thu, 16 Jul 2026 11:55:07 +0100 Subject: [PATCH 057/347] docs(architecture): record RFC-0001 acceptance --- docs/README.md | 2 +- .../REPOSITORY_INTELLIGENCE_V1_RFC.md | 104 ++++++++++-------- 2 files changed, 61 insertions(+), 45 deletions(-) diff --git a/docs/README.md b/docs/README.md index 45f9e3cf..d551021c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -14,7 +14,7 @@ Every document listed here is maintained and describes the system as it currentl | [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 | **Proposed** architectural contract (RFC-0001, tracking [#86](https://github.com/Second-Origin/PARTHA/issues/86)) for the future snapshot/evidence schema: deterministic entity keys, separate inferred assertions, complete derivation chains, planned producer identity, provenance, immutability, diagnostics, versioning, and total canonical graph hashing. **Status: Proposed** — it describes a proposed future contract, not current behaviour, and is not ratified until an independent maintainer approves it. Issue #86 was reopened on 2026-07-16 to complete that missing ratification step explicitly; §17 states plainly what is unimplemented. Governs downstream issues #87–#95. | +| [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, planned producer identity, 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. Acceptance records the governing contract; it does not make unimplemented downstream functionality current product behaviour. §17 tracks implementation status. Governs downstream issues #87–#95. | | [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. | diff --git a/docs/architecture/REPOSITORY_INTELLIGENCE_V1_RFC.md b/docs/architecture/REPOSITORY_INTELLIGENCE_V1_RFC.md index 555493a1..87510764 100644 --- a/docs/architecture/REPOSITORY_INTELLIGENCE_V1_RFC.md +++ b/docs/architecture/REPOSITORY_INTELLIGENCE_V1_RFC.md @@ -5,19 +5,22 @@ | **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) | -| **Proposed schema version** | `ri.v1` (proposed for ratification; not yet ratified) | +| **Accepted schema version** | `ri.v1` | | **Author** | @parthrohit22 | -| **Decision owners (ratifiers)** | An **independent project maintainer other than the author**. The author (@parthrohit22) cannot ratify their own RFC. Ratification is not yet recorded. | +| **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-16 | -| **Status** | **Proposed** | +| **Status** | **Accepted** | | **Supersedes** | — | | **Superseded by** | — | -> **This RFC proposes an architectural contract for approval, not application code.** It does not -> implement snapshots, persistence, extractors, resolvers, queries, jobs, migrations, or consumer -> migration. Those are issues [#87–#95](https://github.com/Second-Origin/PARTHA/issues/87) and are -> gated on this RFC's approval (see [§16, Dependency gate](#16-dependency-gate)). +> **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 is tracked in issues +> [#87–#95](https://github.com/Second-Origin/PARTHA/issues/87); see +> [§16, Dependency gate](#16-dependency-gate) and [§17, implementation status](#17-current-behavior-vs-accepted-contract-vs-unimplemented). --- @@ -25,8 +28,12 @@ ### 1.1 Status -This RFC is **Proposed**. It is not approved, ratified, or accepted until an **independent project -maintainer other than the author** explicitly approves it. No such approval is recorded yet. +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 @@ -34,14 +41,16 @@ maintainer other than the author** explicitly approves it. No such approval is r (@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. -- The RFC remains **Proposed** until that independent maintainer explicitly records approval. -- **Approval does not automatically edit this document.** Merging the pull request does not by - itself change the `Status` field. After approval is recorded, a **final pre-merge update MUST**: +- **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`. - Merge of that updated document is what ratifies the contract; the status change is made by hand in - the same pre-merge update, not inferred from the merge event. + 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 @@ -50,9 +59,10 @@ maintainer other than the author** explicitly approves it. No such approval is r ### 1.3 What approval unblocks -Approval releases the dependency gate in [§16](#16-dependency-gate). Until then, no downstream -issue in the intelligence track (except #94 fixture construction) may begin, and none may be -assigned against an unapproved draft. +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 @@ -62,11 +72,13 @@ issue [#86](https://github.com/Second-Origin/PARTHA/issues/86) was reopened on 2 the missing ratification step can be completed explicitly rather than inferred from the earlier merge. -This follow-up intentionally leaves the RFC **Proposed**. An independent project maintainer must -first record approval. Only after that approval exists may a final pre-merge update set the status -to **Accepted**, record the ratifier and ratification date, and close #86. Until that update is -merged, the dependency gate in §16 remains in force for contributors who do not have an explicit -repository-administrator exception. +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. --- @@ -886,9 +898,8 @@ Codes are stable strings. The `ri.v1` baseline set (extensible as a compatible a ## 9. Schema versioning -This RFC **proposes `schema_version: "ri.v1"` for ratification** as the value carried on every -snapshot and every fact-bearing API response. Once ratified ([§1.2](#12-approval--ratification-rule)), -`ri.v1` is the value all `ri.v1` artifacts carry. +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 @@ -1672,36 +1683,40 @@ Both IDs are computed against the immutable Git revision in [§14.8](#148-git-re 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.** Until #87–#95 land, the system behaves exactly as - documented today; this document describes the *proposed future contract*, not current capability. +- **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): - -- **#87, #88, #89, #90, #91, #92, #93, and #95 MUST NOT begin until this RFC is approved.** -- **#94 fixture construction MAY begin before approval** — writing expected facts down first is a - genuine test of whether the support matrix is coherent. -- **#94 scoring and provenance validation still depend on the approved contract** (they need the +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)). -- **No downstream issue may be assigned against an unapproved draft.** #86 is the Phase 0 gate for - the intelligence track. +- **Contract acceptance and implementation status are distinct.** Approval permits downstream work; + it does not make that work current product behavior. At the time of this update, PR #102 remains + open, so its #87/#88 implementation is not described here as merged or current behavior. 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. proposed contract vs. unimplemented +## 17. Current behavior vs. accepted contract vs. unimplemented 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) | Proposed `ri.v1` contract (this RFC) | Status | +| Concern | Current behavior (today) | Accepted `ri.v1` contract (this RFC) | Status | | --- | --- | --- | --- | | Storage | Mutable JSON blob on `repo_metadata` | Immutable sealed snapshots with nodes, edges, assertions, observations, evidence, and diagnostics (§11) | **Unimplemented** (#88) | | Pipeline identity | No pre-enqueue producer plan | Precomputed `producer_version_set` covers every enabled extractor/resolver/classifier (§3.3) | **Unimplemented** (#88/#93) | @@ -1715,9 +1730,10 @@ rules), the three columns are explicit: | Query API | Consumers read the whole blob | Versioned owner-scoped read API (§9.5) | **Unimplemented** (#92) | | Evidence-backed output | AI emits empty citation lists | Every claim cites a valid span (§7.4) | **Unimplemented** (#95) | -**This RFC does not claim any of the "Proposed contract" column exists today.** It is the target to -be built by #87–#95 after approval. No existing documentation is rewritten by this RFC to imply -otherwise. +**This RFC does not claim every capability in the "Accepted contract" column exists today.** Each +capability becomes current behavior only when its implementation is merged. As of this update, +PR #102 remains open, so the #87/#88 rows remain unimplemented in `dev`; #89–#95 also remain +downstream work. No existing documentation is rewritten by this RFC to imply otherwise. --- @@ -1727,13 +1743,13 @@ otherwise. | #86 acceptance criterion | Satisfied by | | --- | --- | -| Node/edge identity (stable-key format) specified; assertion identity also specified (approval pending independent maintainer) | [§4](#4-node-identity-and-stable-keys), [§5](#5-edgefact-identity) | +| 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) | -| Approved as an ADR/RFC committed to the repository | [§1](#1-status-and-approval) — currently **Proposed**; becomes **Accepted** only via a final pre-merge update after a recorded independent-maintainer approval ([§1.2](#12-approval--ratification-rule)) | +| 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 @@ -1747,14 +1763,14 @@ otherwise. | 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 (propose `ri.v1` for ratification; compatible additions; breaking; when v2; schema vs producer version; API versioning #92; reader rejection/negotiation; historical retention) | [§9](#9-schema-versioning) | +| 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 (#87–#93,#95 blocked; #94 fixtures early; #94 scoring gated; no assignment on draft) | [§16](#16-dependency-gate) | +| 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) | From 59f236b576f3c3bc519422bd1561531169cf8604 Mon Sep 17 00:00:00 2001 From: parthrohit22 Date: Thu, 16 Jul 2026 02:57:24 +0100 Subject: [PATCH 058/347] chore(backend): persist revision identity and intelligence snapshots --- apps/backend/README.md | 15 + .../versions/0005_revision_snapshots.py | 498 +++++++ apps/backend/app/core/database.py | 22 +- apps/backend/app/github/client.py | 65 +- apps/backend/app/intelligence/__init__.py | 3 +- apps/backend/app/intelligence/canonical.py | 688 +++++++++ .../app/intelligence/snapshot_store.py | 1258 +++++++++++++++++ apps/backend/app/models/__init__.py | 25 +- apps/backend/app/models/repository.py | 82 +- apps/backend/app/models/snapshot.py | 474 +++++++ .../app/repositories/repository_repository.py | 21 +- apps/backend/app/schemas/repository.py | 18 + .../app/services/repository_service.py | 86 +- apps/backend/tests/test_canonical_hash.py | 174 +++ apps/backend/tests/test_ingestion_pipeline.py | 54 +- apps/backend/tests/test_migrations.py | 127 ++ .../tests/test_snapshot_persistence.py | 676 +++++++++ .../frontend/src/shared/services/api/types.ts | 14 + apps/frontend/src/shared/services/backend.ts | 2 + apps/frontend/src/shared/types/index.ts | 8 + docs/README.md | 2 +- docs/architecture/REPOSITORY_INTELLIGENCE.md | 29 +- .../REPOSITORY_INTELLIGENCE_V1_RFC.md | 37 +- docs/architecture/SYSTEM_OVERVIEW.md | 14 +- 24 files changed, 4321 insertions(+), 71 deletions(-) create mode 100644 apps/backend/alembic/versions/0005_revision_snapshots.py create mode 100644 apps/backend/app/intelligence/canonical.py create mode 100644 apps/backend/app/intelligence/snapshot_store.py create mode 100644 apps/backend/app/models/snapshot.py create mode 100644 apps/backend/tests/test_canonical_hash.py create mode 100644 apps/backend/tests/test_snapshot_persistence.py diff --git a/apps/backend/README.md b/apps/backend/README.md index a2f28187..37ebfe9f 100644 --- a/apps/backend/README.md +++ b/apps/backend/README.md @@ -79,3 +79,18 @@ curl -X POST http://localhost:8000/repositories/github \ ``` Only public GitHub HTTPS URLs are accepted. Ingestion and analysis run **synchronously** inside the request — a large repository will block until the clone, parse, and analysis finish. + +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 query endpoints are not part of #87/#88 and remain #92. 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..5647eb83 --- /dev/null +++ b/apps/backend/alembic/versions/0005_revision_snapshots.py @@ -0,0 +1,498 @@ +"""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("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", 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/app/core/database.py b/apps/backend/app/core/database.py index af00f372..b465c3b1 100644 --- a/apps/backend/app/core/database.py +++ b/apps/backend/app/core/database.py @@ -1,13 +1,33 @@ +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() + +@event.listens_for(Engine, "connect") +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. Registered on the + ``Engine`` class so it also applies to engines rebuilt by the test fixtures. + """ + + if isinstance(dbapi_connection, sqlite3.Connection): + cursor = dbapi_connection.cursor() + cursor.execute("PRAGMA foreign_keys=ON") + cursor.close() + + connect_args = {} if settings.database_url.startswith("sqlite"): connect_args["check_same_thread"] = False diff --git a/apps/backend/app/github/client.py b/apps/backend/app/github/client.py index a8ea6957..8d3d243d 100644 --- a/apps/backend/app/github/client.py +++ b/apps/backend/app/github/client.py @@ -54,11 +54,63 @@ def read_head_commit(self, repo_dir: Path) -> str | None: timeout=self.timeout_seconds, ) except (subprocess.SubprocessError, OSError) as exc: - logger.warning("Unable to read HEAD commit for clone at %s: %s", repo_dir, 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() @@ -84,9 +136,14 @@ 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) - # Keep raw git stderr (may contain local paths/URLs) server-side only. - logger.warning("git clone failed for %s: %s", url, stderr) + 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.", ) from exc diff --git a/apps/backend/app/intelligence/__init__.py b/apps/backend/app/intelligence/__init__.py index d51d2584..6322a878 100644 --- a/apps/backend/app/intelligence/__init__.py +++ b/apps/backend/app/intelligence/__init__.py @@ -1,4 +1,5 @@ from app.intelligence.engine import RepositoryIntelligenceEngine from app.intelligence.models import RepositoryIntelligence +from app.intelligence.snapshot_store import SnapshotStore -__all__ = ["RepositoryIntelligenceEngine", "RepositoryIntelligence"] +__all__ = ["RepositoryIntelligenceEngine", "RepositoryIntelligence", "SnapshotStore"] diff --git a/apps/backend/app/intelligence/canonical.py b/apps/backend/app/intelligence/canonical.py new file mode 100644 index 00000000..327f6e08 --- /dev/null +++ b/apps/backend/app/intelligence/canonical.py @@ -0,0 +1,688 @@ +"""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 + 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/snapshot_store.py b/apps/backend/app/intelligence/snapshot_store.py new file mode 100644 index 00000000..6a427483 --- /dev/null +++ b/apps/backend/app/intelligence/snapshot_store.py @@ -0,0 +1,1258 @@ +"""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 producers (#89-#91) behind the +``RepositoryIntelligenceEngine`` boundary. 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 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._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") + + 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. + """ + + 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) -> RiSnapshot: + """Validate, hash, and complete a snapshot in one transaction (RFC §11.2).""" + + self._require_building(snapshot) + facts = self._load_facts(snapshot) + try: + self._validate(snapshot, facts) + graph_hash = self._compute_hash(snapshot, facts) + # Reproducibility check (RFC §11.2 rule 10): recomputation is stable. + if graph_hash != self._compute_hash(snapshot, facts): + raise SnapshotSealError("canonical graph hash is not reproducible") + except SnapshotSealError: + snapshot.state = "failed" + snapshot.failure_code = snapshot.failure_code or "RI-INT-VALIDATION" + self._commit_transition(snapshot, from_state="building", to_state="failed") + raise + + 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 _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) + + @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: + 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, + 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) -> "_Facts": + snapshot_id = snapshot.snapshot_id + nodes = list(self.db.scalars(select(RiNode).where(RiNode.snapshot_id == snapshot_id))) + edges = list(self.db.scalars(select(RiEdge).where(RiEdge.snapshot_id == snapshot_id))) + assertions = list(self.db.scalars(select(RiAssertion).where(RiAssertion.snapshot_id == snapshot_id))) + observations = list(self.db.scalars(select(RiObservation).where(RiObservation.snapshot_id == snapshot_id))) + evidence = list(self.db.scalars(select(RiEvidence).where(RiEvidence.snapshot_id == snapshot_id))) + derivations = list(self.db.scalars(select(RiDerivation).where(RiDerivation.snapshot_id == snapshot_id))) + diagnostics = list(self.db.scalars(select(RiDiagnostic).where(RiDiagnostic.snapshot_id == snapshot_id))) + return _Facts(nodes, edges, assertions, observations, evidence, derivations, diagnostics) + + def _validate(self, snapshot: RiSnapshot, facts: "_Facts") -> 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: + 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: + 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) + # Provenance extractor must be a declared producer (RFC §11.2 rule 6). + self._require_producer(record.extractor, record.extractor_version, producer_set) + + # Rule 1: every observed node has >=1 valid evidence record. + for node in facts.nodes: + 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: + 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: + 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: + 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: + 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: + 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 _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") -> str: + evidence_by_node = defaultdict(list) + evidence_by_edge = defaultdict(list) + evidence_by_observation: dict[int, RiEvidence] = {} + for record in facts.evidence: + 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: + 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 = [ + { + "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, []), + } + for node in facts.nodes + ] + edges = [ + { + "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, []), + } + for edge in facts.edges + ] + assertions = [ + { + "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, []), + } + for assertion in facts.assertions + ] + observations = [ + { + "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], + } + for observation in facts.observations + ] + diagnostics = [ + { + "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, + } + for diagnostic in facts.diagnostics + ] + 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 _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/models/__init__.py b/apps/backend/app/models/__init__.py index 06da1000..5e328d0f 100644 --- a/apps/backend/app/models/__init__.py +++ b/apps/backend/app/models/__init__.py @@ -1,6 +1,29 @@ from app.models.ai_provider_config import AiProviderConfigRecord from app.models.refresh_token import RefreshToken from app.models.repository import RepositoryRecord +from app.models.snapshot import ( + RiAssertion, + RiDerivation, + RiDiagnostic, + RiEdge, + RiEvidence, + RiNode, + RiObservation, + RiSnapshot, +) from app.models.user import User -__all__ = ["AiProviderConfigRecord", "RefreshToken", "RepositoryRecord", "User"] +__all__ = [ + "AiProviderConfigRecord", + "RefreshToken", + "RepositoryRecord", + "RiAssertion", + "RiDerivation", + "RiDiagnostic", + "RiEdge", + "RiEvidence", + "RiNode", + "RiObservation", + "RiSnapshot", + "User", +] diff --git a/apps/backend/app/models/repository.py b/apps/backend/app/models/repository.py index 8c96005d..d417911b 100644 --- a/apps/backend/app/models/repository.py +++ b/apps/backend/app/models/repository.py @@ -1,11 +1,30 @@ from datetime import UTC, datetime -from sqlalchemy import BigInteger, DateTime, ForeignKey, Integer, JSON, String, Text -from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy import ( + BigInteger, + CheckConstraint, + DateTime, + ForeignKey, + 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" @@ -16,6 +35,15 @@ class RepositoryRecord(Base): 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) local_path: Mapped[str] = mapped_column(Text) size: Mapped[int] = mapped_column(BigInteger, default=0) file_count: Mapped[int] = mapped_column(Integer, default=0) @@ -34,3 +62,53 @@ 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"), + ) + + @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/snapshot.py b/apps/backend/app/models/snapshot.py new file mode 100644 index 00000000..c75d6c32 --- /dev/null +++ b/apps/backend/app/models/snapshot.py @@ -0,0 +1,474 @@ +"""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"), + ) + + +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) + 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", + ), + CheckConstraint("start_line >= 1 AND end_line >= start_line", 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/repositories/repository_repository.py b/apps/backend/app/repositories/repository_repository.py index ef3752e6..17b09ef2 100644 --- a/apps/backend/app/repositories/repository_repository.py +++ b/apps/backend/app/repositories/repository_repository.py @@ -28,17 +28,30 @@ def get_for_owner(self, repository_id: str, owner_id: str) -> RepositoryRecord | return None return record - def find_by_name_for_owner(self, name: str, owner_id: str) -> RepositoryRecord | None: + 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.name == name, + RepositoryRecord.revision_value == revision_value, RepositoryRecord.owner_id == owner_id, ) return self.db.scalars(statement).first() - def find_by_source_for_owner(self, source_url: str, branch: str | None, owner_id: str) -> RepositoryRecord | None: + def find_by_source_revision_for_owner( + self, + source_url: str, + revision_value: str, + owner_id: str, + ) -> RepositoryRecord | None: + """Find the same GitHub source at the same immutable commit. + + A commit SHA alone is not a repository identity: forks can legitimately + share commits. GitHub duplicate detection is therefore scoped by source + URL as well as immutable revision, while a new commit at the same URL is + accepted as a new repository revision. + """ 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() diff --git a/apps/backend/app/schemas/repository.py b/apps/backend/app/schemas/repository.py index e832a634..79bf5fa8 100644 --- a/apps/backend/app/schemas/repository.py +++ b/apps/backend/app/schemas/repository.py @@ -46,6 +46,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 @@ -62,6 +76,10 @@ class RepositoryResponse(CamelModel): 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) diff --git a/apps/backend/app/services/repository_service.py b/apps/backend/app/services/repository_service.py index 72225515..3318a015 100644 --- a/apps/backend/app/services/repository_service.py +++ b/apps/backend/app/services/repository_service.py @@ -1,5 +1,6 @@ import base64 import hashlib +import re from datetime import UTC, datetime from pathlib import Path from typing import NoReturn @@ -19,11 +20,16 @@ RepositoryFileResponse, RepositoryListResponse, 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", @@ -71,18 +77,23 @@ def import_github_repository(self, request: GitHubImportRequest) -> RepositoryRe 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_for_owner(url, branch, self.owner_id) - 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. destination = self.storage.reset_repository_path(repository_id) try: self.github.clone_public_repository(url, destination, branch) root = self._resolve_repository_root(destination) commit_sha = self.github.read_head_commit(destination) + revision_kind, revision_value, revision_ref = self._git_revision(destination, commit_sha, branch) + existing = self.repository.find_by_source_revision_for_owner(url, revision_value, self.owner_id) + if existing: + raise ConflictServiceError( + "Repository has already been imported.", + {"repositoryId": existing.id, "name": existing.name}, + ) tree, meta, total_size = self.parser.parse(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) @@ -99,6 +110,9 @@ def import_github_repository(self, request: GitHubImportRequest) -> RepositoryRe 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, @@ -108,7 +122,7 @@ def import_github_repository(self, request: GitHubImportRequest) -> RepositoryRe analysis_progress=70, uploaded_at=now, analysed_at=None, - repo_metadata=self._metadata_with_intelligence(meta, repository_intelligence, commit_sha), + repo_metadata=self._metadata_with_intelligence(meta, repository_intelligence), 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)) @@ -116,16 +130,20 @@ def import_github_repository(self, request: GitHubImportRequest) -> RepositoryRe 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_for_owner(repository_name, self.owner_id) - 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) self._validate_parsed_repository(meta.total_files) @@ -145,6 +163,9 @@ async def import_uploaded_repository(self, file: UploadFile) -> RepositoryRespon 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, @@ -154,7 +175,7 @@ async def import_uploaded_repository(self, file: UploadFile) -> RepositoryRespon analysis_progress=70, uploaded_at=now, analysed_at=None, - repo_metadata=self._metadata_with_intelligence(meta, repository_intelligence, content_hash), + repo_metadata=self._metadata_with_intelligence(meta, repository_intelligence), 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)) @@ -226,6 +247,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, @@ -242,7 +270,10 @@ def to_response(self, record: RepositoryRecord) -> RepositoryResponse: uploaded_at=record.uploaded_at, analysed_at=record.analysed_at, error_message=record.error_message, - commit_sha=(record.repo_metadata or {}).get("commitSha"), + 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, ) @@ -272,15 +303,30 @@ def _repository_name_from_archive(self, filename: str) -> str: return filename[: -len(suffix)] return Path(filename).stem - def _metadata_with_intelligence(self, meta, intelligence, commit_sha: str | None = None) -> dict: + 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) - # Commit-addressability seed: the git HEAD SHA for GitHub imports, or a - # stable content hash (sha256:...) for uploads that have no git history. - # Stored in metadata for now; promotion to a first-class column is M2 work. - metadata["commitSha"] = commit_sha 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: diff --git a/apps/backend/tests/test_canonical_hash.py b/apps/backend/tests/test_canonical_hash.py new file mode 100644 index 00000000..0c5671c5 --- /dev/null +++ b/apps/backend/tests/test_canonical_hash.py @@ -0,0 +1,174 @@ +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_ingestion_pipeline.py b/apps/backend/tests/test_ingestion_pipeline.py index 33109081..baa2a63d 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 @@ -55,9 +56,14 @@ def test_zip_upload_persists_repository_and_analysis_completes(auth_client): assert repository["analysisStage"] == "building-file-tree" assert repository["analysisProgress"] == 70 assert repository["meta"]["framework"] == "React" - # Uploads have no git history, so a stable content hash stands in as the - # commit-addressability identifier (T9 / F2). + 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"] start_response = auth_client.post(f"/analysis/{repository['id']}/start") assert start_response.status_code == 200 @@ -202,6 +208,16 @@ def test_duplicate_upload_name_returns_conflict(auth_client): assert body["details"]["name"] == "repo" +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) @@ -209,10 +225,17 @@ def fake_clone(_: GitHubClient, __: str, destination: Path, ___: str | None = No (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) + 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"}, @@ -220,14 +243,19 @@ def fake_clone(_: GitHubClient, __: str, destination: Path, ___: str | None = No 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 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 assert malformed_branch.status_code == 422 assert malformed_branch.json()["message"] == "Branch name contains unsupported characters." def test_github_clone_timeout_is_reported(monkeypatch: pytest.MonkeyPatch, tmp_path: Path): - import subprocess - def fake_run(*args, **kwargs): raise subprocess.TimeoutExpired(cmd=args[0], timeout=kwargs["timeout"]) @@ -240,6 +268,24 @@ def fake_run(*args, **kwargs): auth_client.clone_public_repository("https://github.com/example/demo", tmp_path / "demo") +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 diff --git a/apps/backend/tests/test_migrations.py b/apps/backend/tests/test_migrations.py index e687be5e..96dcdef9 100644 --- a/apps/backend/tests/test_migrations.py +++ b/apps/backend/tests/test_migrations.py @@ -1,7 +1,9 @@ +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 BACKEND_ROOT = Path(__file__).resolve().parents[1] @@ -29,3 +31,128 @@ def test_migrations_upgrade_and_downgrade_run_clean(tmp_path, monkeypatch): command.upgrade(cfg, "head") finally: 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") + 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 "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_snapshot_persistence.py b/apps/backend/tests/test_snapshot_persistence.py new file mode 100644 index 00000000..4f65ce18 --- /dev/null +++ b/apps/backend/tests/test_snapshot_persistence.py @@ -0,0 +1,676 @@ +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 + +# Import registers SQLite foreign-key enforcement on every Engine connection. +import app.core.database # noqa: F401 +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): + 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): + root_evidence = [_evidence("README.md", 1, 2), _evidence("README.md", 4, 4)] + 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)], + ) + 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), + ) + 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")], + 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_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")]) diff --git a/apps/frontend/src/shared/services/api/types.ts b/apps/frontend/src/shared/services/api/types.ts index dd9fb30c..296ad8d8 100644 --- a/apps/frontend/src/shared/services/api/types.ts +++ b/apps/frontend/src/shared/services/api/types.ts @@ -22,6 +22,17 @@ export interface PaginatedResponse { } // Repository + +// First-class revision identity (#87). `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 null for uploads. +export interface RepositoryRevision { + kind: 'git' | 'upload'; + value: string; + ref: string | null; +} + export interface RepositoryResponse { id: string; name: string; @@ -38,6 +49,9 @@ export interface RepositoryResponse { uploadedAt: string; analysedAt: string | null; errorMessage: string | null; + revision: RepositoryRevision | null; + // Backward-compatible alias of `revision.value`. + commitSha: string | null; meta: RepositoryMeta | null; fileTree: FileTreeNode[]; } diff --git a/apps/frontend/src/shared/services/backend.ts b/apps/frontend/src/shared/services/backend.ts index 6e969fbb..79952cfb 100644 --- a/apps/frontend/src/shared/services/backend.ts +++ b/apps/frontend/src/shared/services/backend.ts @@ -96,6 +96,8 @@ function mapRepositoryResponse(response: RepositoryResponse): Repository { uploadedAt: response.uploadedAt, analysedAt: response.analysedAt || undefined, errorMessage: response.errorMessage || undefined, + revision: response.revision, + commitSha: response.commitSha, meta: response.meta, fileTree: response.fileTree, }; diff --git a/apps/frontend/src/shared/types/index.ts b/apps/frontend/src/shared/types/index.ts index 60add8c6..4bf671ca 100644 --- a/apps/frontend/src/shared/types/index.ts +++ b/apps/frontend/src/shared/types/index.ts @@ -53,6 +53,12 @@ export interface RepositoryMeta { licenseName: string | null; } +export interface RepositoryRevision { + kind: 'git' | 'upload'; + value: string; + ref: string | null; +} + export interface Repository { id: string; name: string; @@ -68,6 +74,8 @@ export interface Repository { uploadedAt: string; analysedAt?: string; errorMessage?: string; + revision?: RepositoryRevision | null; + commitSha?: string | null; meta: RepositoryMeta | null; fileTree: FileTreeNode[]; } diff --git a/docs/README.md b/docs/README.md index d551021c..5977e515 100644 --- a/docs/README.md +++ b/docs/README.md @@ -14,7 +14,7 @@ Every document listed here is maintained and describes the system as it currentl | [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, planned producer identity, 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. Acceptance records the governing contract; it does not make unimplemented downstream functionality current product behaviour. §17 tracks implementation status. Governs downstream issues #87–#95. | +| [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, planned producer identity, 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. Acceptance records the governing contract; it does not make unimplemented downstream functionality current product behaviour. The #87 revision identity and #88 immutable snapshot-persistence boundary are implemented against this accepted contract; syntax-aware producers, queries, durable jobs, benchmarks, and consumer migration remain #89–#95. §17 tracks implementation status. Governs downstream issues #87–#95. | | [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. | diff --git a/docs/architecture/REPOSITORY_INTELLIGENCE.md b/docs/architecture/REPOSITORY_INTELLIGENCE.md index 85e97c88..25049098 100644 --- a/docs/architecture/REPOSITORY_INTELLIGENCE.md +++ b/docs/architecture/REPOSITORY_INTELLIGENCE.md @@ -20,7 +20,9 @@ If your feature needs a repository fact that does not exist yet, the answer is a ## What Repository Intelligence currently means -Concretely, it is one Pydantic model — `RepositoryIntelligence` ([`app/intelligence/models.py`](../../apps/backend/app/intelligence/models.py)) — built by one engine — `RepositoryIntelligenceEngine` ([`app/intelligence/engine.py`](../../apps/backend/app/intelligence/engine.py)) — and serialized onto the repository row. +The production extraction path is still one Pydantic model — `RepositoryIntelligence` ([`app/intelligence/models.py`](../../apps/backend/app/intelligence/models.py)) — built by one engine — `RepositoryIntelligenceEngine` ([`app/intelligence/engine.py`](../../apps/backend/app/intelligence/engine.py)) — and serialized onto the repository row. That blob is retained as explicitly legacy/unverified compatibility data. + +The `ri.v1` persistence boundary now also exists: first-class repository revision columns, normalized snapshot/fact/provenance tables, deterministic canonical hashing, and a lifecycle store that validates and seals immutable snapshots. It does not run the legacy regex output through those tables. Syntax-aware producers, resolution, query APIs, durable jobs, determinism scoring, and consumer migration remain later work (#89–#95). ```mermaid flowchart LR @@ -28,10 +30,14 @@ flowchart LR Parser["RepositoryParser
file tree + metadata"] Engine["RepositoryIntelligenceEngine.build()"] Model["RepositoryIntelligence"] - Store[("repositories.repo_metadata
intelligence key · JSON column")] + Store[("repositories.repo_metadata
legacy intelligence JSON")] + Revision[("repositories.revision_*
immutable source identity")] + Snapshot[("ri_* tables
available persistence boundary")] Consumers["Consumers"] Root --> Parser --> Engine --> Model --> Store + Root --> Revision + Revision -. future conforming producers .-> Snapshot Store -->|"from_record()"| Consumers ``` @@ -96,13 +102,16 @@ At import, the engine builds `RepositoryIntelligence` and `RepositoryService` se ```text repositories.repo_metadata["intelligence"] -- entire model, JSON -repositories.repo_metadata["commitSha"] -- git HEAD SHA, or "sha256:..." of the uploaded archive +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 ``` -Consumers call `RepositoryIntelligenceEngine.from_record(record)`, which returns the persisted model if present and **rebuilds it from disk as a fallback** if it is missing or fails validation. +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. + +Consumers still call `RepositoryIntelligenceEngine.from_record(record)`, which returns the legacy model if present and **rebuilds it from disk as a fallback** if it is missing or fails validation. That compatibility path is not an `ri.v1` snapshot producer: its regex facts have no valid spans or versioned provenance and are never promoted to `observed`, `resolved`, or `inferred` rows. -**There are no graph tables.** The knowledge graph is a JSON blob inside a metadata column. It cannot be queried, indexed, joined, or partially updated — it is read and written whole. +The normalized `ri_*` tables are ready for conforming producers. `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 atomically. Completed snapshots reject mutation. There is intentionally no snapshot query API or consumer cutover in this change. --- @@ -153,13 +162,13 @@ Two terms with distinct meanings. PARTHA uses them precisely, and supports neith **Evidence: partial.** Graph relationships and engineering-review findings carry the **file paths** they were derived from. That is real evidence, and it is enough to point a reader at the right file. -**Provenance: incomplete.** Specifically: +**Production provenance: incomplete.** The new persistence schema can store complete `ri.v1` provenance, but the current regex engine cannot produce it. Specifically: - **No line spans.** `SourceSymbol` has `id`, `name`, `kind`, `file_path`, and `exported`. It has **no start or end line**. Nothing in the model records where in a file a fact was found. - **No extraction method on the fact.** A consumer cannot tell whether a given fact was matched deterministically or inferred heuristically. That distinction lives in this document, not in the data. -- **Revision identity is coarse.** `commitSha` (the git HEAD SHA, or a `sha256:` content hash for uploads) is stored on the **repository row**, not on the `RepositoryIntelligence` model or on any individual fact. Facts are not addressed to a revision, and re-importing does not version them. +- **Revision identity is now exact at the repository boundary.** GitHub imports store a 40-character commit plus resolved ref; uploads store a `sha256:` archive identity. Legacy JSON facts still are not individually revision-addressed, while conforming snapshot rows are. -The honest summary: **PARTHA can tell you which file a fact came from. It cannot tell you which line, from which revision, or how the fact was derived.** +The honest summary: **the persistence layer can retain exact revisions, spans, producer versions, and derivations, but today's production regex output still tells consumers only which file a legacy fact came from.** No line-cited product claim exists until #89–#95 populate and consume conforming snapshots. 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). @@ -171,9 +180,9 @@ Do not describe PARTHA as having evidence-backed or grounded output until line s - **Symbols:** regex-derived, Python and TS/JS only, no line spans, no signatures, no nesting, no cross-file resolution. Matches inside comments and strings are not excluded. - **Line spans:** not extracted anywhere in the system. -- **Graph persistence:** a JSON blob on a metadata column. No graph tables, no queryability, no incremental update. +- **Graph production and consumption:** normalized immutable graph tables exist, but no syntax-aware producer or query/consumer path populates and serves them yet. Product surfaces still read the legacy JSON blob. - **Relationships:** four of the eight declared types are never emitted. An import edge resolves to a declared dependency when the name matches and otherwise creates an `external:` node — there is no real module resolution. -- **Revision identity:** recorded per repository, not per fact. No history, no diffing, no re-analysis on change. +- **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, no lockfiles, 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. - **Languages:** meaningful extraction covers Python and TypeScript/JavaScript. 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. diff --git a/docs/architecture/REPOSITORY_INTELLIGENCE_V1_RFC.md b/docs/architecture/REPOSITORY_INTELLIGENCE_V1_RFC.md index 87510764..61733977 100644 --- a/docs/architecture/REPOSITORY_INTELLIGENCE_V1_RFC.md +++ b/docs/architecture/REPOSITORY_INTELLIGENCE_V1_RFC.md @@ -20,7 +20,7 @@ > does not by itself implement snapshots, persistence, extractors, resolvers, queries, jobs, > migrations, or consumer migration. Downstream implementation is tracked in issues > [#87–#95](https://github.com/Second-Origin/PARTHA/issues/87); see -> [§16, Dependency gate](#16-dependency-gate) and [§17, implementation status](#17-current-behavior-vs-accepted-contract-vs-unimplemented). +> [§16, Dependency gate](#16-dependency-gate) and [§17, implementation status](#17-current-behavior-vs-accepted-contract-vs-implementation-status). --- @@ -127,12 +127,11 @@ this RFC governs for `ri.v1` artifacts. ### 3.1 Problem this settles -Today revision identity is coarse and mutable: `RepositoryService._metadata_with_intelligence` -([`repository_service.py:275`](../../apps/backend/app/services/repository_service.py#L275)) stashes -a `commitSha` inside the mutable `repo_metadata` JSON blob, and its own comment says *"Stored in -metadata for now; promotion to a first-class column is M2 work."* A value inside a mutable blob is +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. This section defines the identity that #87 and #88 make first-class. +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 @@ -1711,29 +1710,33 @@ parallel (fixtures early, scoring after approval); #95 last (on #92 and #94). --- -## 17. Current behavior vs. accepted contract vs. unimplemented +## 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 | Mutable JSON blob on `repo_metadata` | Immutable sealed snapshots with nodes, edges, assertions, observations, evidence, and diagnostics (§11) | **Unimplemented** (#88) | -| Pipeline identity | No pre-enqueue producer plan | Precomputed `producer_version_set` covers every enabled extractor/resolver/classifier (§3.3) | **Unimplemented** (#88/#93) | -| Repository graph key | No canonical snapshot node key | Deterministic snapshot-scoped `repo:root`; database `repository_id` excluded from graph keys (§4.3) | **Unimplemented** (#88) | +| Storage | Legacy regex consumers still read the mutable JSON blob; normalized snapshot tables and the sealing store now exist | Immutable sealed snapshots with nodes, edges, assertions, observations, evidence, and diagnostics (§11) | **Persistence implemented** (#88); production producers/queries remain #89–#92 | +| Pipeline identity | `SnapshotStore` fixes and normalizes the planned set before a build; no production job planner invokes it yet | Precomputed `producer_version_set` covers every enabled extractor/resolver/classifier (§3.3) | **Persistence implemented** (#88); enqueue/job coordination remains #93 | +| Repository graph key | The store validates exactly one deterministic `repo:root` before sealing; no production extractor emits it yet | Deterministic snapshot-scoped `repo:root`; database `repository_id` excluded from graph keys (§4.3) | **Persistence implemented** (#88); production emission remains #89/#90 | | Symbol spans | None on `SourceSymbol` ([`models.py:55`](../../apps/backend/app/intelligence/models.py#L55)) | Required line spans (§6) | **Unimplemented** (#89/#90) | | Extraction | Regex in `engine.py`; `TreeSitterParser` returns `[]` | Syntax-aware extractors with support matrices | **Unimplemented** (#89/#90) | -| Revision identity | `commitSha` in a JSON blob | Indexed immutable columns (§3) | **Unimplemented** (#87) | +| Revision identity | Indexed `revision_kind`/`revision_value`/`revision_ref`; `commitSha` is API compatibility only | Indexed immutable columns (§3) | **Implemented** (#87) | | Relationships | 4 of 8 declared types emitted; imports as text | Resolved edges + diagnostics (§5) | **Unimplemented** (#91) | -| Inferred entity properties | Heuristic module roles embedded in the mutable model | Separate inferred property assertions; observed nodes remain unique (§5.6) | **Unimplemented** (#88/#92) | -| Provenance | File paths only | Path + span + extractor/version (§6) | **Unimplemented** | +| Inferred entity properties | Legacy heuristic module roles remain in the compatibility blob; the snapshot store supports separate validated assertions | Separate inferred property assertions; observed nodes remain unique (§5.6) | **Persistence implemented** (#88); production inference/querying remains #91/#92 | +| Provenance | Legacy production output has file paths only; the normalized store validates path + span + producer/version | Path + span + extractor/version (§6) | **Persistence implemented** (#88); syntax-aware production remains #89/#90 | | Query API | Consumers read the whole blob | Versioned owner-scoped read API (§9.5) | **Unimplemented** (#92) | | 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 exists today.** Each -capability becomes current behavior only when its implementation is merged. As of this update, -PR #102 remains open, so the #87/#88 rows remain unimplemented in `dev`; #89–#95 also remain -downstream work. No existing documentation is rewritten by this RFC to imply otherwise. +**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. #89–#95 producers, +queries, jobs, benchmarks, and consumer migration remain downstream work and are not current +behavior. No existing documentation is rewritten by this RFC to imply otherwise. --- diff --git a/docs/architecture/SYSTEM_OVERVIEW.md b/docs/architecture/SYSTEM_OVERVIEW.md index 563cb9b7..0d5a05cc 100644 --- a/docs/architecture/SYSTEM_OVERVIEW.md +++ b/docs/architecture/SYSTEM_OVERVIEW.md @@ -78,7 +78,7 @@ flowchart LR ## Ingestion flow -Both entry points converge on the same path: land the source on disk, parse it, build Repository Intelligence, persist everything on one row. +Both entry points converge on the same production path: land the source on disk, compute immutable revision identity, parse it, build the legacy Repository Intelligence model, and persist the repository revision. The normalized `ri.v1` snapshot boundary exists alongside this path but is not populated by the legacy regex engine. ```mermaid sequenceDiagram @@ -99,7 +99,7 @@ sequenceDiagram Parser-->>Repo: FileTreeNode[] + RepositoryMeta + size Repo->>RI: build(...) RI-->>Repo: RepositoryIntelligence - Repo->>DB: insert row (metadata + file_tree + serialized intelligence + commitSha) + Repo->>DB: insert row (revision kind/value/ref + metadata + file_tree + legacy intelligence) DB-->>UI: RepositoryResponse ``` @@ -113,8 +113,10 @@ This runs **synchronously inside the HTTP request**. A large repository blocks a | Store | Holds | Notes | | --- | --- | --- | -| Relational DB | `users`, `refresh_tokens`, `repositories`, `ai_provider_configs` | SQLite by default for local development; PostgreSQL under Docker Compose. Four Alembic migrations. | -| `repositories.repo_metadata` (JSON column) | Parser metadata, `commitSha`, and the **entire serialized Repository Intelligence** under the `intelligence` key. | There are **no graph tables**. The knowledge graph is a JSON blob on this column. | +| Relational DB | `users`, `refresh_tokens`, `repositories`, `ai_provider_configs`, and normalized `ri_*` snapshot tables | SQLite by default for local development; PostgreSQL under Docker Compose. The current migration head adds revision identity plus immutable snapshot persistence. | +| `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) | Parser metadata and the **legacy/unverified** serialized Repository Intelligence under the `intelligence` key. | New imports no longer stash `commitSha` here. Existing legacy facts are retained for compatibility and are not copied into `ri.v1` observed facts. | +| `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. | The persistence boundary and sealing rules are implemented. Syntax-aware producers, query APIs, durable jobs, benchmarks, and consumer migration remain #89–#95, so current product consumers do not read these tables yet. | | `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 and never returned to the client. | | Filesystem (`STORAGE_PATH`) | Extracted archives and cloned repositories; uploaded archives (deleted after extraction). | Repository source is read from here on demand for file preview. | @@ -229,8 +231,8 @@ flowchart TB These are properties of the system as built, not a wish list. 1. **Extraction is heuristic, not language-aware.** File roles, modules, and layers are inferred from path segments and filenames. Symbols come from regular expressions. `TreeSitterParser` returns nothing, even though `tree-sitter` is a declared dependency. -2. **No line-level provenance.** Facts carry a file path and nothing finer. Revision identity (`commitSha`) lives on the repository row, not on the facts. -3. **The knowledge graph is not persisted as a graph.** It is a JSON blob on `repo_metadata`. It cannot be queried, indexed, or joined. Four of the eight declared relationship types are never emitted. +2. **No line-level provenance in production output.** The snapshot schema can store validated spans and derivations, but the current regex engine emits neither and is deliberately not promoted into `ri.v1`. +3. **The graph store has no production producers or consumers yet.** Immutable normalized tables exist, but product surfaces still read the legacy JSON blob. Four of the eight legacy relationship types are never emitted; syntax-aware extraction/resolution and snapshot queries remain later issues. 4. **Processing is synchronous and whole-repository.** No background jobs, no incremental re-analysis, no cancellation. 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, no lockfiles, 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. From 6292e5988ca0632ab76ddf1a59ec273f4918bd01 Mon Sep 17 00:00:00 2001 From: parthrohit22 Date: Thu, 16 Jul 2026 13:12:29 +0100 Subject: [PATCH 059/347] fix(intelligence): enforce snapshot evidence invariants and validate revision reuse Persist the producer-supplied logical_line_count as internal evidence validation metadata and bound every span by it. The value is kept out of the canonical evidence record and the graph hash, so equivalent graphs still hash identically regardless of reported file lengths. - add logical_line_count to the RiEvidence model and the still-unmerged 0005 migration, with a CHECK constraint requiring start_line >= 1, end_line >= start_line, logical_line_count >= 1, and end_line <= logical_line_count; - reject conflicting logical-line counts when duplicate evidence collapses; - revalidate every stored evidence record during _validate() before sealing (canonical repository-relative path equal to its normalized form, valid granularity, declared non-empty producer, span bounded by the logical-line count), converting canonicalization/path failures into SnapshotSealError so an invalid build transitions to failed; - validate the supplied revision and repository match in get_or_reuse() before the reuse lookup, so a malformed request cannot be answered with an unrelated snapshot that merely shares a revision value. Adds regression coverage for post-insert span and path mutations, the new database constraint, duplicate logical-line conflicts, revision reuse validation, and canonical-hash stability across logical-line counts. --- .../versions/0005_revision_snapshots.py | 7 +- .../app/intelligence/snapshot_store.py | 116 ++++++++-- apps/backend/app/models/snapshot.py | 14 +- .../tests/test_snapshot_persistence.py | 200 +++++++++++++++++- 4 files changed, 318 insertions(+), 19 deletions(-) diff --git a/apps/backend/alembic/versions/0005_revision_snapshots.py b/apps/backend/alembic/versions/0005_revision_snapshots.py index 5647eb83..e1110a5a 100644 --- a/apps/backend/alembic/versions/0005_revision_snapshots.py +++ b/apps/backend/alembic/versions/0005_revision_snapshots.py @@ -339,6 +339,7 @@ def _create_snapshot_tables() -> None: 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), @@ -348,7 +349,11 @@ def _create_snapshot_tables() -> None: "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", name="ck_ri_evidence_span"), + 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( diff --git a/apps/backend/app/intelligence/snapshot_store.py b/apps/backend/app/intelligence/snapshot_store.py index 6a427483..2dc9625a 100644 --- a/apps/backend/app/intelligence/snapshot_store.py +++ b/apps/backend/app/intelligence/snapshot_store.py @@ -321,12 +321,7 @@ def begin( written, exactly as required for pre-enqueue idempotency (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") + 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): @@ -374,6 +369,12 @@ def get_or_reuse( ``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, @@ -707,7 +708,11 @@ def seal(self, snapshot: RiSnapshot) -> RiSnapshot: """Validate, hash, and complete a snapshot in one transaction (RFC §11.2).""" self._require_building(snapshot) - facts = self._load_facts(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) try: self._validate(snapshot, facts) graph_hash = self._compute_hash(snapshot, facts) @@ -715,10 +720,14 @@ def seal(self, snapshot: RiSnapshot) -> RiSnapshot: if graph_hash != self._compute_hash(snapshot, facts): raise SnapshotSealError("canonical graph hash is not reproducible") except SnapshotSealError: - snapshot.state = "failed" - snapshot.failure_code = snapshot.failure_code or "RI-INT-VALIDATION" - self._commit_transition(snapshot, from_state="building", to_state="failed") + 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) @@ -744,6 +753,27 @@ def seal(self, snapshot: RiSnapshot) -> RiSnapshot: # -- 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})") @@ -759,6 +789,22 @@ def _commit_transition(self, snapshot: RiSnapshot, *, from_state: str, to_state: 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": @@ -836,6 +882,14 @@ def _add_evidence( ) ).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, @@ -845,6 +899,7 @@ def _add_evidence( 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, @@ -947,8 +1002,11 @@ def _validate(self, snapshot: RiSnapshot, facts: "_Facts") -> None: evidence_by_edge[record.edge_ref].append(record) elif record.observation_ref is not None: evidence_by_observation[record.observation_ref].append(record) - # Provenance extractor must be a declared producer (RFC §11.2 rule 6). - self._require_producer(record.extractor, record.extractor_version, producer_set) + # 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: @@ -1051,6 +1109,40 @@ def _validate(self, snapshot: RiSnapshot, facts: "_Facts") -> None: 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: diff --git a/apps/backend/app/models/snapshot.py b/apps/backend/app/models/snapshot.py index c75d6c32..93f6469b 100644 --- a/apps/backend/app/models/snapshot.py +++ b/apps/backend/app/models/snapshot.py @@ -292,6 +292,11 @@ class RiEvidence(Base): 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) @@ -304,7 +309,14 @@ class RiEvidence(Base): "CASE WHEN observation_ref IS NOT NULL THEN 1 ELSE 0 END) = 1", name="ck_ri_evidence_single_parent", ), - CheckConstraint("start_line >= 1 AND end_line >= start_line", name="ck_ri_evidence_span"), + # 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. diff --git a/apps/backend/tests/test_snapshot_persistence.py b/apps/backend/tests/test_snapshot_persistence.py index 4f65ce18..cb3e2e3a 100644 --- a/apps/backend/tests/test_snapshot_persistence.py +++ b/apps/backend/tests/test_snapshot_persistence.py @@ -104,8 +104,17 @@ def _evidence( ) -def _populate(store: SnapshotStore, snapshot: RiSnapshot, *, reverse_evidence: bool = False): - root_evidence = [_evidence("README.md", 1, 2), _evidence("README.md", 4, 4)] +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( @@ -121,7 +130,7 @@ def _populate(store: SnapshotStore, snapshot: RiSnapshot, *, reverse_evidence: b stable_key="file:src/main.py", name="main.py", language="python", - evidence=[_evidence("src/main.py", 1, 20)], + evidence=[_evidence("src/main.py", 1, 20, logical_lines=logical_lines)], ) observation = store.add_observation( snapshot, @@ -129,7 +138,7 @@ def _populate(store: SnapshotStore, snapshot: RiSnapshot, *, reverse_evidence: b subject_kind="repository", subject_key="repo:root", referent_text="src/main.py", - evidence=_evidence("src/main.py", 1, 20), + evidence=_evidence("src/main.py", 1, 20, logical_lines=logical_lines), ) edge = store.add_edge( snapshot, @@ -140,7 +149,7 @@ def _populate(store: SnapshotStore, snapshot: RiSnapshot, *, reverse_evidence: b object_key="file:src/main.py", producer="resolver", producer_version="1.0.0", - evidence=[_evidence("src/main.py", 1, 20, producer="resolver")], + evidence=[_evidence("src/main.py", 1, 20, producer="resolver", logical_lines=logical_lines)], derived_from=[observation_ref(observation.observation_id)], ) store.add_assertion( @@ -674,3 +683,184 @@ def test_store_rejects_writes_after_failed_snapshot(db): 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 From 1fb516e08f132126d0796e99e8e86aa4735ba424 Mon Sep 17 00:00:00 2001 From: parthrohit22 Date: Thu, 16 Jul 2026 13:20:21 +0100 Subject: [PATCH 060/347] refactor(backend): make SQLite foreign-key enforcement an explicit registration The snapshot persistence tests relied on importing app.core.database purely for its Engine 'connect' listener side effect (PRAGMA foreign_keys=ON), which reads as an unused import. Expose the registration as an idempotent register_sqlite_foreign_key_enforcement() function that production still calls at import time and the test fixture now calls explicitly, so the dependency on foreign-key enforcement is genuine and self-documenting rather than a side-effect import. --- apps/backend/app/core/database.py | 20 ++++++++++++++++--- .../tests/test_snapshot_persistence.py | 6 ++++-- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/apps/backend/app/core/database.py b/apps/backend/app/core/database.py index b465c3b1..7fd27c37 100644 --- a/apps/backend/app/core/database.py +++ b/apps/backend/app/core/database.py @@ -11,15 +11,13 @@ settings = get_settings() -@event.listens_for(Engine, "connect") 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. Registered on the - ``Engine`` class so it also applies to engines rebuilt by the test fixtures. + is a no-op on PostgreSQL, which always enforces them. """ if isinstance(dbapi_connection, sqlite3.Connection): @@ -28,6 +26,22 @@ def _enable_sqlite_foreign_keys(dbapi_connection, _connection_record) -> None: 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() + + connect_args = {} if settings.database_url.startswith("sqlite"): connect_args["check_same_thread"] = False diff --git a/apps/backend/tests/test_snapshot_persistence.py b/apps/backend/tests/test_snapshot_persistence.py index cb3e2e3a..cf742db7 100644 --- a/apps/backend/tests/test_snapshot_persistence.py +++ b/apps/backend/tests/test_snapshot_persistence.py @@ -8,8 +8,7 @@ from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session, sessionmaker -# Import registers SQLite foreign-key enforcement on every Engine connection. -import app.core.database # noqa: F401 +from app.core.database import register_sqlite_foreign_key_enforcement from app.intelligence import canonical from app.intelligence.snapshot_store import ( Evidence, @@ -41,6 +40,9 @@ @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) From 30d99b913fa816804ba3ad998bc66662c3693696 Mon Sep 17 00:00:00 2001 From: Shaurya K Sharma Date: Thu, 16 Jul 2026 13:53:28 +0100 Subject: [PATCH 061/347] docs(extraction): design for evidence-backed TS/Python extractors (#89, #90) --- .../2026-07-16-evidence-extractors-design.md | 305 ++++++++++++++++++ 1 file changed, 305 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-16-evidence-extractors-design.md diff --git a/docs/superpowers/specs/2026-07-16-evidence-extractors-design.md b/docs/superpowers/specs/2026-07-16-evidence-extractors-design.md new file mode 100644 index 00000000..7a46f984 --- /dev/null +++ b/docs/superpowers/specs/2026-07-16-evidence-extractors-design.md @@ -0,0 +1,305 @@ +# Design — Evidence-backed TypeScript & Python extractors (#89, #90) + +| | | +| --- | --- | +| **Issues** | [#89](https://github.com/Second-Origin/PARTHA/issues/89) (TypeScript), [#90](https://github.com/Second-Origin/PARTHA/issues/90) (Python) | +| **Governing contract** | RFC-0001 (Repository Intelligence v1), **Accepted** 2026-07-16 | +| **Depends on** | #88 immutable snapshot persistence (`SnapshotStore`, `Evidence`, `ri_*` tables) — merged to `dev` in PR #102 | +| **Status** | Design — pending maintainer approval | + +## 1. Goal and scope + +Replace regex symbol extraction (for TypeScript and Python only) with two real, +evidence-backed extractors that emit RFC-0001 `observed` facts — nodes and +observations, each carrying a valid line span — against a **named support +matrix**. Constructs outside the matrix produce diagnostics, never silent drops +and never invented facts. + +### In scope + +- A shared `Extractor` protocol and shared result/identity/diagnostic types in a + new `apps/backend/app/extraction/` package. +- `TypeScriptExtractor` (`.ts`/`.tsx`) built on tree-sitter. +- `PythonExtractor` (`.py`) built on the standard-library `ast` module. +- A published support matrix per language (the deliverable, not just prose). +- Golden fixtures per supported construct and adversarial fixtures per declared + blind spot. +- Integration tests that feed extractor output into `SnapshotStore` and seal a + snapshot, proving the facts satisfy the persistence contract end to end. +- Removal of the `TreeSitterParser` placeholder. +- New pinned dependency: `tree-sitter-typescript` (grammar), plus regenerated + `requirements.txt`. + +### Explicitly out of scope + +- **Resolution into edges.** Extractors emit `observed` nodes and `observation` + records only. Turning an import/call/route observation into a `resolved` edge + is #91's job (RFC §7.2 emission matrix: an extractor MUST NOT emit `resolved`). +- **Live wiring into import/analysis.** The extractors are not called from + `POST /repositories/*` or `/analysis/{id}/start`. Production wiring waits for + #93 (durable job lifecycle), which replaces today's synchronous analysis; wiring + now would be rewritten then. +- **JavaScript (`.js`/`.jsx`).** Out of the `ri.v1` support matrix for these + issues; the legacy regex path in `engine.py` continues to serve the non-`ri.v1` + blob for those files unchanged. +- **Other languages.** Per the RFC's breadth-before-depth rule: no third language + until TS and Python both meet the support contract. +- **The legacy `engine.py` blob.** Its regex output stays as `legacy_unverified` + compatibility data (RFC §10.3). These extractors do not feed it and do not + replace its non-TS/Python behavior. + +## 2. The extractor/resolver boundary (the load-bearing decision) + +RFC §2.2 and §7.2 draw a hard line: an **extractor** reads a source span and emits +`observed` facts; a **resolver** (#91) reads stored observations and emits +`resolved` edges. #89/#90 are extractors, so they do **not** emit edges. + +Concretely, for each cross-reference the extractor records an **observation** +(RFC §6.4) — an evidence-bearing record of the raw syntax — and leaves resolution +to #91: + +- An `import './tokens'` becomes an `observation` with `observed_kind: "import"`, + `referent_text: "./tokens"`, evidence at the import line. It does **not** become + an edge to `file:src/auth/tokens.ts`. +- A FastAPI `@router.post("/login")` under `router = APIRouter(prefix="/auth")` + becomes an `observation` with `observed_kind: "route"`, + `referent_text: "/login"` — the **literal decorator string only**. Joining the + `/auth` prefix to produce the effective path `/auth/login` requires binding + `router` across two statements: that is cross-statement inference, a resolver's + job, not an extractor's. The extractor never emits `/auth/login`. + +This keeps #89/#90 independently correct and testable, and means #91 has real +observations to resolve rather than having to re-parse source. + +## 3. Parser strategy + +**Python → standard-library `ast`. TypeScript → tree-sitter.** Not tree-sitter +for both. + +- **Python `ast`** is native (no grammar dependency), always spec-correct for the + interpreter (repo floor is Python 3.12), and gives exact spans via `lineno` / + `end_lineno` (stable since 3.8). Qualified names fall out of an `ast.NodeVisitor` + scope stack; decorators are `node.decorator_list` directly — which #90 leans on + for FastAPI routes. Its fail-hard behavior on a syntax error is *correct* for an + evidence contract: a `SyntaxError` becomes an `RI-SRC-MALFORMED` diagnostic + (RFC §8.2), not partial extraction. Tree-sitter's error-tolerant recovery would + be a liability here. +- **TypeScript tree-sitter**: there is no native TypeScript parser in Python. + tree-sitter runs in-process (no Node runtime, no subprocess), which the security + model prefers. Construct discovery uses tree-sitter **queries** (`.scm` + patterns) — one named query per supported construct, so the query set *is* the + machine-checkable support matrix. Qualified names come from walking each match's + `.parent` chain to reconstruct enclosing scope, which queries alone cannot give. + +**Shared interface is preserved** (#90 acceptance criterion): both extractors +implement one `Extractor` protocol and return the same `ExtractionResult` +dataclasses; the qualified-name builder, source-order discriminator assignment +(RFC §4.3), path normalization, and diagnostic emission live once in `base.py`. +Only the parser *backend* differs — appropriately, because the languages differ +and Python has a native parser TypeScript lacks. "Shared interface, not a parallel +implementation" is satisfied at the interface, not by forcing one parser library. + +## 4. Package layout + +``` +apps/backend/app/extraction/ + __init__.py # exports Extractor, ExtractionResult, extractor_for() + base.py # Extractor protocol; result/observation/diagnostic types; + # qualified-name + discriminator + path-normalization helpers; + # logical-line-count + span validation; diagnostic codes + typescript.py # TypeScriptExtractor (tree-sitter) + queries/ # *.scm tree-sitter queries, one per supported construct + python.py # PythonExtractor (ast.NodeVisitor) + support_matrix.py # the published matrix, asserted by both docs and tests +apps/backend/app/parsers/tree_sitter_parser.py # DELETED +apps/backend/tests/extraction/ + fixtures/typescript/ # one source file per supported construct + per blind spot + fixtures/python/ + test_typescript_extractor.py + test_python_extractor.py + test_extractor_snapshot_integration.py # ExtractionResult -> SnapshotStore -> seal + test_support_matrix.py # matrix <-> implementation parity +``` + +`engine.py` stops calling `TreeSitterParser`; its regex symbol extraction remains +for non-TS/Python files feeding the legacy blob, untouched. + +## 5. Data model (`base.py`) + +Plain frozen dataclasses, decoupled from the ORM (so extractors stay unit-testable +and #93 can later drive them into `SnapshotStore` from a job): + +```python +@dataclass(frozen=True) +class ExtractedEvidence: + path: str # repo-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 # RI-EXT-UNSUPPORTED, RI-SRC-MALFORMED, ... + category: str + severity: str # fatal | error | warning | info + message: str # deterministic: no timestamps/absolute paths + 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, ...] + +class Extractor(Protocol): + name: str # "typescript-ast" | "python-ast" + version: str # "1.0.0" + def supports(self, path: str) -> bool: ... + def extract(self, path: str, source: bytes) -> ExtractionResult: ... +``` + +These map one-to-one onto `SnapshotStore.add_node` / `add_observation` / +`add_diagnostic` and the `Evidence` value object from #88. The extractor takes +raw `bytes` (not decoded text) so it owns the UTF-8 decode and can emit +`RI-SRC-BINARY` / `RI-SRC-MALFORMED` itself (RFC §6.2). `producer` on the +resulting `Evidence` is `f"{extractor.name}@{extractor.version}"`. + +## 6. Support matrix (draft — refined during implementation, published as the deliverable) + +`support_matrix.py` is the single source; docs and `test_support_matrix.py` both +assert against it so they cannot drift. + +### TypeScript (`.ts`, `.tsx`) + +| Supported → node/observation | Not supported → diagnostic | +| --- | --- | +| file node (whole-file evidence) | dynamic `import()` → `RI-EXT-UNSUPPORTED` | +| `import`/`export … from` → `import` observation | decorators → `RI-EXT-UNSUPPORTED` | +| function decls (incl. nested, arrow assigned to const) → symbol node + `definition` obs | `namespace` / ambient `module` → `RI-EXT-UNSUPPORTED` | +| class decls + methods (incl. nested) → symbol nodes | `export =` / `require(...)` (CommonJS) → `RI-EXT-UNSUPPORTED` | +| `interface` / `type` / `enum` / exported `const` → symbol nodes | | +| react-router routes (``, `createBrowserRouter` entries) → `route` obs | | + +> **Exports** (an explicit #89 deliverable) are represented as an `exported: true` +> **node property** on the symbol they qualify — not a separate node kind. A +> re-export (`export … from`) is additionally an `import` observation, since it is +> a cross-file reference a resolver will later follow. +> +> Route detection matches this repo's actual routing (`createBrowserRouter` in +> `apps/frontend/src/app/routes/router.tsx`), not generic Express `.get('/x')` +> calls the old regex looked for and never found here. +> +> **Decorators are unsupported for TypeScript but supported for Python** — this is +> not an oversight: it traces directly to each issue's own scope. #89 lists +> files/modules/symbols/imports/exports/routes (no decorators); #90 lists +> modules/classes/functions/imports/routes/**decorators**. TS decorators are a +> more complex stage-3 feature with metadata semantics; Python decorators are +> first-class via `ast.decorator_list`. + +### Python (`.py`) + +| Supported → node/observation | Not supported → diagnostic | +| --- | --- | +| module node (whole-file evidence) | `import *` (star-import) → `RI-EXT-UNSUPPORTED` | +| `import` / `from … import` → `import` observation | dynamic import (`importlib`, `__import__`) → `RI-EXT-UNSUPPORTED` | +| function defs (incl. nested, async) → symbol node + `definition` obs | monkey-patching / reflection (`setattr`/`getattr`) → `RI-EXT-UNSUPPORTED` | +| class defs + methods (incl. nested) → symbol nodes | metaclasses → `RI-EXT-UNSUPPORTED` | +| decorators → node property on the decorated symbol | syntax error → `RI-SRC-MALFORMED` (whole file, no facts) | +| FastAPI route decorators → `route` obs (literal path only) | | + +## 7. Stable keys, qualified names, spans (shared, `base.py`) + +- **Stable keys** per RFC §4.3: `file:`, `mod:`, + `::[#]`, `dep::`. Paths normalized per + §4.2 (POSIX, lexical `.`/`..` resolution, reject escapes → `RI-SEC-PATH-ESCAPE`, + NFC). Symbol keys carry no `sym:` prefix (detected by `::`). +- **Qualified names**: dotted enclosing-scope path, language-native. + `AuthService.login`, `outer._inner`. Python from the `NodeVisitor` scope stack; + TypeScript from the parent-walk. +- **Discriminators** (RFC §4.3, revision-local, source-order): duplicate + `::` gets `#2`, `#3`… by ascending start position (first + has none) + an informational `RI-KEY-DUP-SYMBOL`. Anonymous symbols that must be + represented get `(anonymous:#)`. +- **Spans** (RFC §6.2): one-based, inclusive; `logical_line_count = 1 + + count(U+000A)` over the strict UTF-8 decode; empty file = 1 logical line; + whole-file facts use `granularity: "file"`, `1..logical_line_count`. A span + outside `1 ≤ start ≤ end ≤ logical_line_count` is dropped with `RI-SPAN-INVALID` + rather than stored. + +## 8. Diagnostics behavior + +Diagnostics are **opt-in per declared blind spot**, driven by explicit queries / +AST checks — there is no generic "unmatched node" fallback (which would flag +comments and punctuation as gaps). Severities follow RFC §8.3–8.4: the extractors +emit only non-fatal diagnostics (`RI-EXT-UNSUPPORTED` = info, `RI-SRC-BINARY` = +info, `RI-SRC-MALFORMED` = error, `RI-SPAN-INVALID` = error, `RI-KEY-DUP-SYMBOL` = +info). A snapshot with only these still seals `completed` with visible gaps — +extractors never produce `fatal`. + +## 9. Testing + +- **Golden fixtures** — one minimal source file per supported construct, asserting + exact nodes/observations with exact stable keys and spans (mirrors the existing + `test_canonical_hash.py` vector style). Includes the RFC §6.2 empty-file and + `\r\n` line-count vectors. +- **Adversarial fixtures** — one per declared blind spot, asserting the specific + diagnostic code fires and no fact is fabricated; plus a syntax-error file + (`RI-SRC-MALFORMED`) and a NUL-byte file (`RI-SRC-BINARY`). +- **Support-matrix parity** — `test_support_matrix.py` asserts every matrix entry + has a fixture and vice-versa, so the published matrix cannot lie. +- **SnapshotStore integration** — feed a fixture's `ExtractionResult` through + `SnapshotStore.add_node/add_observation/add_diagnostic` and `seal()`, proving the + facts satisfy #88's persistence contract and that a real snapshot completes + end to end (this is where "every emitted fact carries valid provenance" is + proven, per each issue's acceptance criteria). +- CI green (backend suite + lint). + +## 10. Dependencies + +- Add `tree-sitter-typescript` (grammar) to `apps/backend/pyproject.toml`; the + base `tree-sitter==0.26.0` is already pinned. Regenerate `requirements.txt`. +- No `tree-sitter-python` — Python uses stdlib `ast`. +- No new frontend or runtime-service dependency. + +## 11. Sequencing of the two issues + +Land the shared `base.py` + one extractor first (Python, since `ast` is the lower- +risk backend and validates the interface), then the TypeScript extractor against +the same interface. Both can be one PR or two stacked PRs; the shared interface is +settled before the second extractor starts, satisfying #90's "shared interface" +criterion by construction. + +## 12. Acceptance-criteria trace + +| Criterion (#89/#90) | Satisfied by | +| --- | --- | +| Named support matrix published | §6, `support_matrix.py`, `test_support_matrix.py` | +| Files/modules/symbols/imports/exports/routes (TS) and modules/classes/functions/imports/routes/decorators (Py) with valid spans | §5–§7, golden fixtures | +| Every fact carries evidence conforming to the contract | §5, §7, SnapshotStore integration test | +| Golden fixtures per supported construct | §9 | +| Unsupported constructs & failures → diagnostics, not drops/guesses | §8, adversarial fixtures | +| `TreeSitterParser` made real or removed | §4 — removed | +| Shared extractor interface, not parallel impl (#90) | §3, §5 — shared `base.py` | +| CI green | §9 | From f3145b431ddd4841cec65ff5942c63e0f2f7ac8f Mon Sep 17 00:00:00 2001 From: Shaurya K Sharma Date: Thu, 16 Jul 2026 14:40:33 +0100 Subject: [PATCH 062/347] docs(extraction): implementation plan for TS/Python extractors (#89, #90) --- .../plans/2026-07-16-evidence-extractors.md | 2260 +++++++++++++++++ 1 file changed, 2260 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-16-evidence-extractors.md diff --git a/docs/superpowers/plans/2026-07-16-evidence-extractors.md b/docs/superpowers/plans/2026-07-16-evidence-extractors.md new file mode 100644 index 00000000..0d2d6dc8 --- /dev/null +++ b/docs/superpowers/plans/2026-07-16-evidence-extractors.md @@ -0,0 +1,2260 @@ +# Evidence-backed TS/Python Extractors — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace regex symbol extraction (TypeScript and Python only) with two real extractors that emit RFC-0001 `observed` nodes and `observation` records, each carrying a valid line span, against a published support matrix — with diagnostics for unsupported constructs instead of silent drops or guesses. + +**Architecture:** A shared `apps/backend/app/extraction/` package. `base.py` holds the `Extractor` protocol, result dataclasses, source decoding, span/path validation, and diagnostic codes; `naming.py` holds the qualified-name + discriminator helpers. `python.py` uses the stdlib `ast` module; `typescript.py` uses tree-sitter, walking named nodes by type (rather than `.scm` query files — a plain type-walk is simpler and more robust across grammar versions, and the "named support matrix" criterion is met explicitly by `support_matrix.py` + a parity test). Extractors emit `observed` facts only (no `resolved` edges — that is #91). Output dataclasses map one-to-one onto the merged `SnapshotStore` from #88. + +**Tech Stack:** Python 3.12+ stdlib `ast`; `tree-sitter` (already pinned) + `tree-sitter-typescript` grammar; pytest; SQLAlchemy/SQLite for the `SnapshotStore` integration tests. + +## Global Constraints + +- Python floor is **3.12** (`requires-python = ">=3.12,<3.14"`). `ast.end_lineno` and `X | None` are available; do not add compatibility shims for older Pythons. +- **Extractors emit `observed` nodes and `observation` records only.** Never emit a `resolved`/`inferred` fact or an edge — RFC-0001 §7.2 forbids it for extractors; resolution is #91. +- **Scope is extraction correctness only.** Do not wire extractors into `POST /repositories/*`, `/analysis/{id}/start`, or `engine.py`'s build path. Production wiring is #93. +- **TypeScript = `.ts`/`.tsx` only. Python = `.py` only.** No `.js`/`.jsx`, no third language. +- **Lines are one-based and inclusive.** `logical_line_count = 1 + text.count("\n")` over a strict UTF-8 decode (RFC §6.2). Empty file = 1 logical line. +- **Paths are repository-relative POSIX, normalized via `canonical.normalize_repo_path`** (RFC §4.2). A path that escapes root → `RI-SEC-PATH-ESCAPE`, drop the fact. +- **Diagnostics are opt-in per declared blind spot** — no generic "unmatched node" fallback. Extractors emit only non-fatal severities. +- **Producer identifier** is `f"{extractor.name}@{extractor.version}"`, e.g. `python-ast@1.0.0`, `typescript-ast@1.0.0`. +- **Commit identity:** author `shauryaksharma24@gmail.com`; **no** AI/Claude attribution trailer in any commit. +- **Run tests from `apps/backend`** with the backend venv active: `apps/backend/.venv/Scripts/python.exe -m pytest ...` (Windows). All `pytest`/`python` commands below assume CWD = `apps/backend` and that interpreter. + +--- + +## Phase A — Foundation + Python extractor (settles the shared interface) + +### Task A1: Add the tree-sitter-typescript grammar dependency + +**Files:** +- Modify: `apps/backend/pyproject.toml` (dependencies list, near line 21) +- Modify: `apps/backend/requirements.txt` (add pinned grammar) + +**Interfaces:** +- Produces: an importable `tree_sitter` and `tree_sitter_typescript` in the backend venv. + +- [ ] **Step 1: Add the grammar to `pyproject.toml`** + +In `apps/backend/pyproject.toml`, in the `dependencies` array, immediately after the existing `"tree-sitter>=0.22.0",` line, add: + +```toml + "tree-sitter-typescript>=0.23.0", +``` + +- [ ] **Step 2: Install into the backend venv** + +Run (CWD `apps/backend`): +```bash +.venv/Scripts/python.exe -m pip install "tree-sitter-typescript>=0.23.0" +``` +Expected: installs `tree-sitter-typescript` and a compatible `tree-sitter` wheel. + +- [ ] **Step 3: Verify both import and a parser can be built** + +Run: +```bash +.venv/Scripts/python.exe -c "import tree_sitter_typescript as t; from tree_sitter import Language, Parser; Parser(Language(t.language_tsx())); print('ok')" +``` +Expected: prints `ok`. (If `Language(...)`/`Parser(...)` raise a signature error, the installed `tree-sitter` core is <0.22; upgrade it: `.venv/Scripts/python.exe -m pip install "tree-sitter>=0.22,<0.26"`.) + +- [ ] **Step 4: Pin in `requirements.txt`** + +Determine the installed versions: +```bash +.venv/Scripts/python.exe -m pip show tree-sitter tree-sitter-typescript | grep -E "^(Name|Version)" +``` +Add a line to `apps/backend/requirements.txt` next to the existing `tree-sitter==...` pin: +``` +tree-sitter-typescript== +``` +If `pip show` reports a different `tree-sitter` version than the existing pin, update that pin to match too. + +- [ ] **Step 5: Commit** + +```bash +git add apps/backend/pyproject.toml apps/backend/requirements.txt +git commit -m "build(extraction): add tree-sitter-typescript grammar dependency" +``` + +--- + +### Task A2: `base.py` data model and `Extractor` protocol + +**Files:** +- Create: `apps/backend/app/extraction/__init__.py` +- Create: `apps/backend/app/extraction/base.py` +- Test: `apps/backend/tests/extraction/__init__.py` (empty), `apps/backend/tests/extraction/test_base_model.py` + +**Interfaces:** +- Produces: `ExtractedEvidence`, `ExtractedNode`, `ExtractedObservation`, `ExtractedDiagnostic`, `ExtractionResult` (frozen dataclasses); `Extractor` (Protocol) with `name: str`, `version: str`, `supports(path) -> bool`, `extract(path, source: bytes) -> ExtractionResult`. + +- [ ] **Step 1: Write the failing test** + +Create `apps/backend/tests/extraction/__init__.py` (empty file), then `apps/backend/tests/extraction/test_base_model.py`: + +```python +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] +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `.venv/Scripts/python.exe -m pytest tests/extraction/test_base_model.py -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'app.extraction'`. + +- [ ] **Step 3: Write the module** + +Create `apps/backend/app/extraction/__init__.py`: + +```python +from app.extraction.base import ( + ExtractedDiagnostic, + ExtractedEvidence, + ExtractedNode, + ExtractedObservation, + ExtractionResult, + Extractor, +) + +__all__ = [ + "ExtractedDiagnostic", + "ExtractedEvidence", + "ExtractedNode", + "ExtractedObservation", + "ExtractionResult", + "Extractor", +] +``` + +Create `apps/backend/app/extraction/base.py`: + +```python +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from dataclasses import dataclass, field +from typing import Protocol, runtime_checkable + + +@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: ... +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `.venv/Scripts/python.exe -m pytest tests/extraction/test_base_model.py -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add apps/backend/app/extraction/__init__.py apps/backend/app/extraction/base.py apps/backend/tests/extraction/__init__.py apps/backend/tests/extraction/test_base_model.py +git commit -m "feat(extraction): add shared result model and Extractor protocol" +``` + +--- + +### Task A3: Source decoding, logical line count, binary/malformed diagnostics + +**Files:** +- Modify: `apps/backend/app/extraction/base.py` +- Test: `apps/backend/tests/extraction/test_base_source.py` + +**Interfaces:** +- Consumes: `ExtractedDiagnostic` (Task A2). +- Produces: `DIAGNOSTIC_CATEGORIES` constants and code constants (`RI_SRC_BINARY`, `RI_SRC_MALFORMED`, `RI_EXT_UNSUPPORTED`, `RI_SPAN_INVALID`, `RI_SEC_PATH_ESCAPE`, `RI_KEY_DUP_SYMBOL`); `logical_line_count(text: str) -> int`; `decode_source(path: str, source: bytes, *, producer: str) -> tuple[str | None, ExtractedDiagnostic | None]`. + +- [ ] **Step 1: Write the failing test** + +Create `apps/backend/tests/extraction/test_base_source.py`: + +```python +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 +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `.venv/Scripts/python.exe -m pytest tests/extraction/test_base_source.py -v` +Expected: FAIL — `ImportError: cannot import name 'decode_source'`. + +- [ ] **Step 3: Add the constants and functions to `base.py`** + +Append to `apps/backend/app/extraction/base.py`: + +```python +# --- Diagnostic codes (RFC §8.2) ------------------------------------------- + +RI_SRC_BINARY = "RI-SRC-BINARY" +RI_SRC_MALFORMED = "RI-SRC-MALFORMED" +RI_EXT_UNSUPPORTED = "RI-EXT-UNSUPPORTED" +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_SPAN_INVALID: "invalid span", + RI_SEC_PATH_ESCAPE: "path escape", + RI_KEY_DUP_SYMBOL: "duplicate symbol", +} + + +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. + """ + + 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=path, + ) + 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=path, + ) +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `.venv/Scripts/python.exe -m pytest tests/extraction/test_base_source.py -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add apps/backend/app/extraction/base.py apps/backend/tests/extraction/test_base_source.py +git commit -m "feat(extraction): add source decode, line count, and source diagnostics" +``` + +--- + +### Task A4: Evidence construction with span + path validation + +**Files:** +- Modify: `apps/backend/app/extraction/base.py` +- Test: `apps/backend/tests/extraction/test_base_evidence.py` + +**Interfaces:** +- Consumes: `ExtractedEvidence`, `ExtractedDiagnostic`, code constants (Tasks A2–A3), `app.intelligence.canonical.normalize_repo_path` / `PathEscapeError`. +- Produces: `build_evidence(path, start_line, end_line, logical_line_count, *, producer, granularity="span") -> tuple[ExtractedEvidence | None, ExtractedDiagnostic | None]`. + +- [ ] **Step 1: Write the failing test** + +Create `apps/backend/tests/extraction/test_base_evidence.py`: + +```python +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.0.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" +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `.venv/Scripts/python.exe -m pytest tests/extraction/test_base_evidence.py -v` +Expected: FAIL — `ImportError: cannot import name 'build_evidence'`. + +- [ ] **Step 3: Add `build_evidence` to `base.py`** + +Add near the top of `apps/backend/app/extraction/base.py`, after the existing imports: + +```python +from app.intelligence import canonical +``` + +Append this function: + +```python +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 ``name@version`` identifier, carried into the diagnostic + so callers do not have to duplicate it. + """ + + 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, + ) +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `.venv/Scripts/python.exe -m pytest tests/extraction/test_base_evidence.py -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add apps/backend/app/extraction/base.py apps/backend/tests/extraction/test_base_evidence.py +git commit -m "feat(extraction): add span- and path-validated evidence builder" +``` + +--- + +### Task A5: Qualified-name scope stack + duplicate discriminator + +**Files:** +- Create: `apps/backend/app/extraction/naming.py` +- Test: `apps/backend/tests/extraction/test_naming.py` + +**Interfaces:** +- Consumes: `canonical.normalize_repo_path`. +- Produces: `symbol_stable_key(path: str, scope: Sequence[str], name: str) -> str` (joins scope + name with `.`, prefixes normalized file path + `::`); `DiscriminatorAssigner` with `.key(base_symbol_key: str) -> tuple[str, bool]` returning `(final_key, was_duplicate)` where the first occurrence returns the base key and later ones append `#2`, `#3`, … in call order. + +- [ ] **Step 1: Write the failing test** + +Create `apps/backend/tests/extraction/test_naming.py`: + +```python +from app.extraction.naming import DiscriminatorAssigner, 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_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) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `.venv/Scripts/python.exe -m pytest tests/extraction/test_naming.py -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'app.extraction.naming'`. + +- [ ] **Step 3: Write `naming.py`** + +Create `apps/backend/app/extraction/naming.py`: + +```python +from __future__ import annotations + +from collections import defaultdict +from collections.abc import Sequence + +from app.intelligence import canonical + + +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}" + + +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 +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `.venv/Scripts/python.exe -m pytest tests/extraction/test_naming.py -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add apps/backend/app/extraction/naming.py apps/backend/tests/extraction/test_naming.py +git commit -m "feat(extraction): add qualified-name and duplicate-discriminator helpers" +``` + +--- + +### Task A6: Python extractor — module node + import observations + +**Files:** +- Create: `apps/backend/app/extraction/python.py` +- Test: `apps/backend/tests/extraction/test_python_extractor.py` + +**Interfaces:** +- Consumes: everything in `base.py` and `naming.py`. +- Produces: `PythonExtractor` with `name="python-ast"`, `version="1.0.0"`, `supports(path)`, `extract(path, source)`; emits a `module` node (whole-file evidence) and one `import` observation per imported name. + +- [ ] **Step 1: Write the failing test** + +Create `apps/backend/tests/extraction/test_python_extractor.py`: + +```python +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 +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `.venv/Scripts/python.exe -m pytest tests/extraction/test_python_extractor.py -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'app.extraction.python'`. + +- [ ] **Step 3: Write the module skeleton + imports** + +Create `apps/backend/app/extraction/python.py`: + +```python +from __future__ import annotations + +import ast +import posixpath + +from app.extraction.base import ( + ExtractedDiagnostic, + ExtractedNode, + ExtractedObservation, + ExtractionResult, + RI_SRC_MALFORMED, + build_evidence, + decode_source, + logical_line_count, +) +from app.intelligence import canonical + + +class PythonExtractor: + name = "python-ast" + version = "1.0.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,)) + + 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), + ), + ) + ) + + nodes: list[ExtractedNode] = [] + observations: list[ExtractedObservation] = [] + diagnostics: list[ExtractedDiagnostic] = [] + + module_key = self._module_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=posixpath.basename(canonical.normalize_repo_path(path)), + language="python", + 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 + ) + + return ExtractionResult( + nodes=tuple(nodes), + observations=tuple(observations), + diagnostics=tuple(diagnostics), + ) + + def _module_key(self, path: str) -> str: + directory = posixpath.dirname(canonical.normalize_repo_path(path)) + return canonical.normalize_stable_key("module", f"mod:{directory}") + + def _collect_imports( + self, tree, path, line_count, module_key, observations, diagnostics + ) -> None: + ordinal = 0 + for node in ast.walk(tree): + names: list[str] = [] + if isinstance(node, ast.Import): + names = [alias.name for alias in node.names] + elif isinstance(node, ast.ImportFrom): + base = node.module or "" + names = [ + f"{base}.{alias.name}" if base else alias.name + for alias in node.names + if alias.name != "*" + ] + else: + continue + for name in names: + ordinal += 1 + 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=ordinal, + evidence=ev, + ) + ) +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `.venv/Scripts/python.exe -m pytest tests/extraction/test_python_extractor.py -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add apps/backend/app/extraction/python.py apps/backend/tests/extraction/test_python_extractor.py +git commit -m "feat(extraction): Python extractor emits module node and import observations" +``` + +--- + +### Task A7: Python extractor — functions, classes, methods, definition observations + +**Files:** +- Modify: `apps/backend/app/extraction/python.py` +- Test: `apps/backend/tests/extraction/test_python_symbols.py` + +**Interfaces:** +- Consumes: `symbol_stable_key`, `DiscriminatorAssigner` (Task A5). +- Produces: for each `def`/`async def`/`class`, a `symbol` node with a qualified stable key + a `definition` observation; nested scopes reflected in the qualified name; `RI-KEY-DUP-SYMBOL` for duplicates. + +- [ ] **Step 1: Write the failing test** + +Create `apps/backend/tests/extraction/test_python_symbols.py`: + +```python +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\n" + "def 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) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `.venv/Scripts/python.exe -m pytest tests/extraction/test_python_symbols.py -v` +Expected: FAIL — nested/qualified symbol keys are not produced yet. + +- [ ] **Step 3: Add a scope-walking visitor to `python.py`** + +Add these imports at the top of `apps/backend/app/extraction/python.py`: + +```python +from app.extraction.base import RI_KEY_DUP_SYMBOL +from app.extraction.naming import DiscriminatorAssigner, symbol_stable_key +``` + +In `extract`, after the `_collect_imports(...)` call and before the `return`, add: + +```python + self._collect_symbols( + tree, path, line_count, nodes, observations, diagnostics + ) +``` + +Add these methods to `PythonExtractor`: + +```python + _DEF_TYPES = (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef) + + def _collect_symbols( + self, tree, path, line_count, nodes, observations, diagnostics + ) -> None: + assigner = DiscriminatorAssigner() + ordinal = 0 + + def visit(scope: list[str], body) -> None: + nonlocal 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: + nodes.append( + ExtractedNode( + node_kind="symbol", + stable_key=canonical.normalize_stable_key("symbol", final_key), + name=child.name, + language="python", + evidence=(ev,), + ) + ) + ordinal += 1 + observations.append( + ExtractedObservation( + observed_kind="definition", + subject_kind="symbol", + subject_key=canonical.normalize_stable_key("symbol", final_key), + referent_text=None, + ordinal=ordinal, + evidence=ev, + ) + ) + if duplicate: + diagnostics.append( + ExtractedDiagnostic( + code=RI_KEY_DUP_SYMBOL, + category="duplicate symbol", + severity="info", + message=f"duplicate symbol name resolved with a discriminator: {final_key}", + path=canonical.normalize_repo_path(path), + subject=canonical.normalize_stable_key("symbol", final_key), + ) + ) + visit([*scope, child.name], child.body) + + visit([], tree.body) +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `.venv/Scripts/python.exe -m pytest tests/extraction/test_python_symbols.py -v` +Expected: PASS. Also run the Task A6 test to confirm no regression: `.venv/Scripts/python.exe -m pytest tests/extraction/test_python_extractor.py -v` → PASS. + +- [ ] **Step 5: Commit** + +```bash +git add apps/backend/app/extraction/python.py apps/backend/tests/extraction/test_python_symbols.py +git commit -m "feat(extraction): Python extractor emits qualified symbol nodes and definitions" +``` + +--- + +### Task A8: Python extractor — decorators as property + FastAPI route observations + +**Files:** +- Modify: `apps/backend/app/extraction/python.py` +- Test: `apps/backend/tests/extraction/test_python_routes.py` + +**Interfaces:** +- Produces: decorated symbols carry `properties={"decorators": [], "exported": ...}`; a FastAPI-style route decorator (`@router.post("/login")`) yields a `route` observation whose `referent_text` is the **literal path string only** (no prefix joining — that is #91). + +- [ ] **Step 1: Write the failing test** + +Create `apps/backend/tests/extraction/test_python_routes.py`: + +```python +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\n" + "def 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_fastapi_route_decorator_yields_literal_path_observation(): + result = _extract( + "router = APIRouter(prefix='/auth')\n" + "@router.post('/login')\n" + "def 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::login" +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `.venv/Scripts/python.exe -m pytest tests/extraction/test_python_routes.py -v` +Expected: FAIL — no decorator property, no route observation. + +- [ ] **Step 3: Extend the visitor with decorator + route handling** + +Add a helper and extend `_collect_symbols` in `apps/backend/app/extraction/python.py`. First add this module-level constant near the top: + +```python +_ROUTE_METHODS = {"get", "post", "put", "patch", "delete", "options", "head"} +``` + +Then, inside `_collect_symbols`'s `visit`, after `final_key`/`ev` are computed and the symbol node is appended, replace the plain `ExtractedNode(...)` construction with one that attaches decorator properties, and emit route observations. Concretely, change the node append block to: + +```python + decorators = [self._decorator_name(d) for d in getattr(child, "decorator_list", [])] + decorators = [d for d in decorators if d] + 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, + ) + ) + ordinal += 1 + observations.append( + ExtractedObservation( + observed_kind="definition", + subject_kind="symbol", + subject_key=canonical.normalize_stable_key("symbol", final_key), + referent_text=None, + ordinal=ordinal, + evidence=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 + ordinal += 1 + observations.append( + ExtractedObservation( + observed_kind="route", + subject_kind="symbol", + subject_key=canonical.normalize_stable_key("symbol", final_key), + referent_text=route_path, + ordinal=ordinal, + evidence=route_ev, + ) + ) +``` + +Add these helper methods to `PythonExtractor`: + +```python + 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 +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `.venv/Scripts/python.exe -m pytest tests/extraction/test_python_routes.py tests/extraction/test_python_symbols.py -v` +Expected: PASS (both files). + +- [ ] **Step 5: Commit** + +```bash +git add apps/backend/app/extraction/python.py apps/backend/tests/extraction/test_python_routes.py +git commit -m "feat(extraction): Python extractor records decorators and route observations" +``` + +--- + +### Task A9: Python extractor — blind-spot diagnostics + +**Files:** +- Modify: `apps/backend/app/extraction/python.py` +- Test: `apps/backend/tests/extraction/test_python_diagnostics.py` + +**Interfaces:** +- Produces: `RI-EXT-UNSUPPORTED` (info) for star-imports, dynamic imports (`importlib.import_module`, `__import__`), and reflection (`getattr`/`setattr`/`delattr`); each names the construct; no fabricated fact. + +- [ ] **Step 1: Write the failing test** + +Create `apps/backend/tests/extraction/test_python_diagnostics.py`: + +```python +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_reflection_is_flagged(): + codes, _ = _codes("x = getattr(object(), 'name', None)\n") + assert "RI-EXT-UNSUPPORTED" 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 == () +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `.venv/Scripts/python.exe -m pytest tests/extraction/test_python_diagnostics.py -v` +Expected: FAIL — blind-spot diagnostics not emitted (syntax-error case already passes from Task A6). + +- [ ] **Step 3: Add blind-spot detection** + +In `apps/backend/app/extraction/python.py`, add to `extract` after `_collect_symbols(...)`: + +```python + self._collect_blind_spots(tree, path, line_count, diagnostics) +``` + +Add the module-level constant and method: + +```python +_DYNAMIC_IMPORT_CALLS = {"import_module", "__import__"} +_REFLECTION_CALLS = {"getattr", "setattr", "delattr"} +``` + +```python + def _collect_blind_spots(self, tree, path, line_count, diagnostics) -> None: + normalized = canonical.normalize_repo_path(path) + + def flag(node, message: str) -> None: + 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), + ) + ) + + for node in ast.walk(tree): + if isinstance(node, ast.ImportFrom) and any(a.name == "*" for a in node.names): + flag(node, f"star-import from {node.module or '.'} is unsupported") + elif isinstance(node, ast.Call): + func = node.func + if isinstance(func, ast.Name) and func.id in _REFLECTION_CALLS: + flag(node, f"reflection via {func.id}() is unsupported") + elif isinstance(func, ast.Name) and func.id == "__import__": + flag(node, "dynamic import via __import__() is unsupported") + elif isinstance(func, ast.Attribute) and func.attr in _DYNAMIC_IMPORT_CALLS: + flag(node, f"dynamic import via {func.attr}() is unsupported") +``` + +Note: `RI_EXT_UNSUPPORTED` is already imported in Task A6's import block via `base`; if not, add it to the `from app.extraction.base import (...)` list. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `.venv/Scripts/python.exe -m pytest tests/extraction/test_python_diagnostics.py -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add apps/backend/app/extraction/python.py apps/backend/tests/extraction/test_python_diagnostics.py +git commit -m "feat(extraction): Python extractor emits blind-spot diagnostics" +``` + +--- + +### Task A10: Support matrix (Python) + parity test + +**Files:** +- Create: `apps/backend/app/extraction/support_matrix.py` +- Test: `apps/backend/tests/extraction/test_support_matrix.py` + +**Interfaces:** +- Produces: `SUPPORT_MATRIX: dict[str, LanguageSupport]` where `LanguageSupport` has `supported: tuple[str, ...]` and `unsupported: tuple[str, ...]`; `PYTHON` key populated. + +- [ ] **Step 1: Write the failing test** + +Create `apps/backend/tests/extraction/test_support_matrix.py`: + +```python +from app.extraction.support_matrix import SUPPORT_MATRIX + + +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) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `.venv/Scripts/python.exe -m pytest tests/extraction/test_support_matrix.py -v` +Expected: FAIL — module does not exist. + +- [ ] **Step 3: Write `support_matrix.py`** + +Create `apps/backend/app/extraction/support_matrix.py`: + +```python +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class LanguageSupport: + supported: tuple[str, ...] + unsupported: tuple[str, ...] + + +SUPPORT_MATRIX: dict[str, LanguageSupport] = { + "python": LanguageSupport( + supported=("module", "import", "function", "class", "method", "decorator", "route"), + unsupported=("star-import", "dynamic-import", "reflection", "metaclass"), + ), +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `.venv/Scripts/python.exe -m pytest tests/extraction/test_support_matrix.py -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add apps/backend/app/extraction/support_matrix.py apps/backend/tests/extraction/test_support_matrix.py +git commit -m "feat(extraction): publish Python support matrix with parity test" +``` + +--- + +### Task A11: SnapshotStore integration — Python facts seal + +**Files:** +- Test: `apps/backend/tests/extraction/test_python_snapshot_integration.py` + +**Interfaces:** +- Consumes: `PythonExtractor`, and the merged `SnapshotStore`/`Evidence`/`Revision` (#88). Reuses the fixture pattern from `tests/test_snapshot_persistence.py`. +- Produces: proof that an `ExtractionResult` writes into a `building` snapshot and seals to `completed`. + +- [ ] **Step 1: Write the failing test** + +Create `apps/backend/tests/extraction/test_python_snapshot_integration.py`: + +```python +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.0.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.0.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.0.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.0.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:") +``` + +- [ ] **Step 2: Run test to verify it fails, then passes** + +Run: `.venv/Scripts/python.exe -m pytest tests/extraction/test_python_snapshot_integration.py -v` +Expected: If the extractor and store are correct, this PASSES immediately (no new production code — it wires existing pieces). If it FAILS with a `SnapshotSealError`, read the message: the most likely causes are an observation subject whose node was not added (add the missing node) or a `module` node used where the RFC expects `repo:root` — this test intentionally adds `repo:root` separately, so a failure here signals a real contract gap to fix in `python.py`. + +- [ ] **Step 3: (If needed) fix the extractor to satisfy the seal contract** + +Only if Step 2 failed: adjust `python.py` so every observation's `subject_key` refers to a node the result also emits (e.g. ensure the `module` node's `subject` linkage is consistent), then re-run. Do not weaken the store. + +- [ ] **Step 4: Confirm green** + +Run: `.venv/Scripts/python.exe -m pytest tests/extraction/ -v` +Expected: PASS (all extraction tests). + +- [ ] **Step 5: Commit** + +```bash +git add apps/backend/tests/extraction/test_python_snapshot_integration.py apps/backend/app/extraction/python.py +git commit -m "test(extraction): prove Python extraction seals into a snapshot" +``` + +--- + +## Phase B — TypeScript extractor (interface now settled) + +### Task B1: TypeScript extractor scaffold — grammar load + file node + +**Files:** +- Create: `apps/backend/app/extraction/typescript.py` +- Test: `apps/backend/tests/extraction/test_typescript_extractor.py` + +**Interfaces:** +- Produces: `TypeScriptExtractor` with `name="typescript-ast"`, `version="1.0.0"`, `supports(path)` (`.ts`/`.tsx`), `extract(path, source)`; emits a `file` node with whole-file evidence; selects the `tsx` grammar for `.tsx` and `typescript` for `.ts`. + +- [ ] **Step 1: Write the failing test** + +Create `apps/backend/tests/extraction/test_typescript_extractor.py`: + +```python +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 == () +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `.venv/Scripts/python.exe -m pytest tests/extraction/test_typescript_extractor.py -v` +Expected: FAIL — module does not exist. + +- [ ] **Step 3: Write the scaffold** + +Create `apps/backend/app/extraction/typescript.py`: + +```python +from __future__ import annotations + +import posixpath + +import tree_sitter_typescript as tsts +from tree_sitter import Language, Parser + +from app.extraction.base import ( + ExtractedNode, + ExtractionResult, + build_evidence, + decode_source, + logical_line_count, +) +from app.intelligence import canonical + +_TS_LANGUAGE = Language(tsts.language_typescript()) +_TSX_LANGUAGE = Language(tsts.language_tsx()) + + +class TypeScriptExtractor: + name = "typescript-ast" + version = "1.0.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,)) + + line_count = logical_line_count(text) + tree = self._parser(path).parse(source) + + nodes: list[ExtractedNode] = [] + diagnostics = [] + + file_key = canonical.normalize_stable_key( + "file", f"file:{canonical.normalize_repo_path(path)}" + ) + 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(canonical.normalize_repo_path(path)), + language="typescript", + evidence=(file_ev,), + ) + ) + elif file_diag is not None: + diagnostics.append(file_diag) + + # tree is retained for construct queries added in later tasks. + _ = tree + return ExtractionResult(nodes=tuple(nodes), diagnostics=tuple(diagnostics)) +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `.venv/Scripts/python.exe -m pytest tests/extraction/test_typescript_extractor.py -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add apps/backend/app/extraction/typescript.py apps/backend/tests/extraction/test_typescript_extractor.py +git commit -m "feat(extraction): TypeScript extractor scaffold with file node" +``` + +--- + +### Task B2: TypeScript — symbols with qualified names + discriminators + +**Files:** +- Modify: `apps/backend/app/extraction/typescript.py` +- Test: `apps/backend/tests/extraction/test_typescript_symbols.py` + +**Interfaces:** +- Consumes: `symbol_stable_key`, `DiscriminatorAssigner`, `ExtractedObservation`. +- Produces: `symbol` nodes + `definition` observations for `function_declaration`, `class_declaration` (and its `method_definition`s), `interface_declaration`, `type_alias_declaration`, `enum_declaration`, and top-level `lexical_declaration` const bindings; qualified names via ancestor walk; `#` discriminators + `RI-KEY-DUP-SYMBOL`. + +- [ ] **Step 1: Write the failing test** + +Create `apps/backend/tests/extraction/test_typescript_symbols.py`: + +```python +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_method_and_function_qualified_names(): + keys, _ = _keys( + "export class AuthService {\n" + " login() {}\n" + "}\n" + "export 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::issueToken" in keys + + +def test_interface_type_enum_are_symbols(): + keys, _ = _keys( + "export interface Session {}\n" + "export type Id = string;\n" + "export 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) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `.venv/Scripts/python.exe -m pytest tests/extraction/test_typescript_symbols.py -v` +Expected: FAIL — no symbol nodes yet. + +- [ ] **Step 3: Add a cursor walk that collects named declarations** + +In `apps/backend/app/extraction/typescript.py`, add imports: + +```python +from app.extraction.base import ExtractedDiagnostic, ExtractedObservation, RI_KEY_DUP_SYMBOL +from app.extraction.naming import DiscriminatorAssigner, symbol_stable_key +``` + +Add a constant mapping declaration node types to the child field that holds the name: + +```python +_NAMED_DECLARATIONS = { + "function_declaration": "name", + "class_declaration": "name", + "interface_declaration": "name", + "type_alias_declaration": "name", + "enum_declaration": "name", +} +``` + +In `extract`, replace `_ = tree` with: + +```python + observations: list[ExtractedObservation] = [] + self._collect_symbols( + tree.root_node, path, line_count, file_key, nodes, observations, diagnostics + ) + return ExtractionResult( + nodes=tuple(nodes), + observations=tuple(observations), + diagnostics=tuple(diagnostics), + ) +``` + +(remove the previous `return ExtractionResult(nodes=..., diagnostics=...)` line). + +Add these methods to `TypeScriptExtractor`: + +```python + def _node_text(self, node, source: bytes) -> str: + return source[node.start_byte:node.end_byte].decode("utf-8", errors="replace") + + def _collect_symbols( + self, root, path, line_count, file_key, nodes, observations, diagnostics + ) -> None: + assigner = DiscriminatorAssigner() + ordinal = 0 + source = root.text # bytes of the whole tree + + def name_of(node) -> str | None: + field = _NAMED_DECLARATIONS.get(node.type) + if field is None: + return None + name_node = node.child_by_field_name(field) + return None if name_node is None else self._node_text(name_node, source) + + def visit(node, scope: list[str]) -> None: + nonlocal ordinal + child_scope = scope + name = name_of(node) + if name is not None: + 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, node.start_point[0] + 1, node.end_point[0] + 1, + line_count, producer=self.producer, + ) + if ev is None: + if diag is not None: + diagnostics.append(diag) + else: + nodes.append( + ExtractedNode( + node_kind="symbol", stable_key=key, name=name, + language="typescript", evidence=(ev,), + ) + ) + ordinal += 1 + observations.append( + ExtractedObservation( + observed_kind="definition", subject_kind="symbol", + subject_key=key, referent_text=None, ordinal=ordinal, evidence=ev, + ) + ) + if duplicate: + diagnostics.append( + ExtractedDiagnostic( + code=RI_KEY_DUP_SYMBOL, category="duplicate symbol", + severity="info", + message=f"duplicate symbol name resolved with a discriminator: {final_key}", + path=canonical.normalize_repo_path(path), subject=key, + ) + ) + child_scope = [*scope, name] + # Methods live under class_body; recurse to find them and nested types. + for child in node.named_children: + if child.type == "method_definition": + method_name_node = child.child_by_field_name("name") + if method_name_node is not None and name is not None: + self._emit_method( + child, path, line_count, [*scope, name], + self._node_text(method_name_node, source), + assigner, nodes, observations, + ) + else: + visit(child, child_scope) + + visit(root, []) + + def _emit_method( + self, node, path, line_count, scope, name, assigner, nodes, observations + ) -> None: + base_key = symbol_stable_key(path, scope, name) + final_key, _ = assigner.key(base_key) + key = canonical.normalize_stable_key("symbol", final_key) + ev, _ = build_evidence( + path, node.start_point[0] + 1, node.end_point[0] + 1, + line_count, producer=self.producer, + ) + if ev is None: + return + nodes.append( + ExtractedNode( + node_kind="symbol", stable_key=key, name=name, + language="typescript", evidence=(ev,), + ) + ) + observations.append( + ExtractedObservation( + observed_kind="definition", subject_kind="symbol", + subject_key=key, referent_text=None, ordinal=1, evidence=ev, + ) + ) +``` + +Also handle top-level `export const` bindings: in `visit`, before recursing, if `node.type == "lexical_declaration"`, find each `variable_declarator`'s `name` child and emit a symbol the same way (add this branch mirroring the `name is not None` block, using the declarator's identifier text). Keep it minimal — one symbol per top-level const. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `.venv/Scripts/python.exe -m pytest tests/extraction/test_typescript_symbols.py -v` +Expected: PASS. If `const` assertions in a later task need it, the `lexical_declaration` branch is already in place. + +- [ ] **Step 5: Commit** + +```bash +git add apps/backend/app/extraction/typescript.py apps/backend/tests/extraction/test_typescript_symbols.py +git commit -m "feat(extraction): TypeScript extractor emits qualified symbols and definitions" +``` + +--- + +### Task B3: TypeScript — import/export observations + exported property + +**Files:** +- Modify: `apps/backend/app/extraction/typescript.py` +- Test: `apps/backend/tests/extraction/test_typescript_imports.py` + +**Interfaces:** +- Produces: an `import` observation (`referent_text` = the module specifier) for each `import`/`export … from`; symbols that are exported carry `properties={"exported": True}`. + +- [ ] **Step 1: Write the failing test** + +Create `apps/backend/tests/extraction/test_typescript_imports.py`: + +```python +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';\n" + "export { 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_exported_symbol_carries_exported_property(): + result = _extract("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 +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `.venv/Scripts/python.exe -m pytest tests/extraction/test_typescript_imports.py -v` +Expected: FAIL — no import observations, no exported property. + +- [ ] **Step 3: Add import collection and export detection** + +In `_collect_symbols` (or a new `_collect_imports` called from `extract`), walk the tree for `import_statement` and `export_statement` nodes that have a `source` field (a `string` node). Emit an `import` observation with `referent_text` set to the string literal's inner text (strip the surrounding quotes), `subject_kind="file"`, `subject_key=file_key`, one-based span from `start_point`/`end_point`, incrementing a file-level ordinal. + +For the exported property: when visiting a named declaration, set `exported=True` if the declaration's parent node type is `export_statement`. Attach `properties={"exported": True}` to that symbol's `ExtractedNode` (merge with any existing properties). + +Concretely, add to `extract` before the `return`: + +```python + self._collect_imports(tree.root_node, path, line_count, file_key, observations) +``` + +Add: + +```python + def _collect_imports(self, root, path, line_count, file_key, observations) -> None: + source = root.text + ordinal = len(observations) + + def walk(node): + nonlocal ordinal + 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: + ordinal += 1 + observations.append( + ExtractedObservation( + observed_kind="import", subject_kind="file", + subject_key=file_key, referent_text=literal, + ordinal=ordinal, evidence=ev, + ) + ) + for child in node.named_children: + walk(child) + + walk(root) +``` + +For the `exported` property, in `visit`, compute `exported = node.parent is not None and node.parent.type == "export_statement"` and pass `properties={"exported": True} if exported else None` into the symbol's `ExtractedNode(...)`. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `.venv/Scripts/python.exe -m pytest tests/extraction/test_typescript_imports.py tests/extraction/test_typescript_symbols.py -v` +Expected: PASS (both). + +- [ ] **Step 5: Commit** + +```bash +git add apps/backend/app/extraction/typescript.py apps/backend/tests/extraction/test_typescript_imports.py +git commit -m "feat(extraction): TypeScript extractor emits imports and exported property" +``` + +--- + +### Task B4: TypeScript — react-router route observations + +**Files:** +- Modify: `apps/backend/app/extraction/typescript.py` +- Test: `apps/backend/tests/extraction/test_typescript_routes.py` + +**Interfaces:** +- Produces: a `route` observation for each `createBrowserRouter` entry object with a `path` property and each JSX ``; `referent_text` = the literal path string only. + +- [ ] **Step 1: Write the failing test** + +Create `apps/backend/tests/extraction/test_typescript_routes.py`: + +```python +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"] +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `.venv/Scripts/python.exe -m pytest tests/extraction/test_typescript_routes.py -v` +Expected: FAIL — no route observations. + +- [ ] **Step 3: Add route detection** + +Add a `_collect_routes(root, path, line_count, file_key, observations)` called from `extract`. Walk the tree: +- For a `pair` node whose `key` child text is `path` and whose `value` child is a string, and which sits inside a `createBrowserRouter` call argument, emit a `route` observation with the string literal (quotes stripped). +- For a JSX attribute (`jsx_attribute`) whose name is `path` on a `` element, emit the same. + +Concretely: + +```python + def _collect_routes(self, root, path, line_count, file_key, observations) -> None: + source = root.text + ordinal = len(observations) + + def emit(node, literal): + nonlocal ordinal + ev, _ = build_evidence( + path, node.start_point[0] + 1, node.end_point[0] + 1, + line_count, producer=self.producer, + ) + if ev is not None: + ordinal += 1 + observations.append( + ExtractedObservation( + observed_kind="route", subject_kind="file", + subject_key=file_key, referent_text=literal, + ordinal=ordinal, evidence=ev, + ) + ) + + def walk(node): + if node.type == "pair": + key = node.child_by_field_name("key") + value = node.child_by_field_name("value") + if (key is not None and value is not None + and self._node_text(key, source).strip("'\"") == "path" + and value.type in ("string",)): + emit(node, self._node_text(value, source).strip("'\"`")) + elif node.type == "jsx_attribute": + children = node.named_children + if children and self._node_text(children[0], source) == "path" and len(children) > 1: + literal = self._node_text(children[1], source).strip("'\"{}`") + emit(node, literal) + for child in node.named_children: + walk(child) + + walk(root) +``` + +Call it from `extract` before the `return`: + +```python + self._collect_routes(tree.root_node, path, line_count, file_key, observations) +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `.venv/Scripts/python.exe -m pytest tests/extraction/test_typescript_routes.py -v` +Expected: PASS. (If the JSX case fails because `.ts` was used instead of `.tsx`, confirm the test uses a `.tsx` path so the tsx grammar parses JSX — it does.) + +- [ ] **Step 5: Commit** + +```bash +git add apps/backend/app/extraction/typescript.py apps/backend/tests/extraction/test_typescript_routes.py +git commit -m "feat(extraction): TypeScript extractor emits react-router route observations" +``` + +--- + +### Task B5: TypeScript — blind-spot diagnostics + +**Files:** +- Modify: `apps/backend/app/extraction/typescript.py` +- Test: `apps/backend/tests/extraction/test_typescript_diagnostics.py` + +**Interfaces:** +- Produces: `RI-EXT-UNSUPPORTED` (info) for dynamic `import(...)` calls, `namespace`/ambient `module` declarations, and CommonJS `require(...)` calls; `RI-SRC-MALFORMED` (error) when the parse tree has errors (`root_node.has_error`). + +- [ ] **Step 1: Write the failing test** + +Create `apps/backend/tests/extraction/test_typescript_diagnostics.py`: + +```python +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(): + assert "RI-SRC-MALFORMED" in _codes("class {{{ broken\n") +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `.venv/Scripts/python.exe -m pytest tests/extraction/test_typescript_diagnostics.py -v` +Expected: FAIL — diagnostics not emitted. + +- [ ] **Step 3: Add blind-spot + parse-error detection** + +In `extract`, right after building `tree`, add a parse-error check: + +```python + if tree.root_node.has_error: + diagnostics.append( + ExtractedDiagnostic( + code="RI-SRC-MALFORMED", category="malformed source", + severity="error", message="file has TypeScript syntax errors", + path=canonical.normalize_repo_path(path), + ) + ) +``` + +Add `_collect_blind_spots(tree.root_node, path, line_count, diagnostics)` before the `return`, and: + +```python + def _collect_blind_spots(self, root, path, line_count, diagnostics) -> None: + source = root.text + normalized = canonical.normalize_repo_path(path) + + 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), + ) + ) + + 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 == "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) +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `.venv/Scripts/python.exe -m pytest tests/extraction/test_typescript_diagnostics.py -v` +Expected: PASS. (If `namespace` matches a different node type in the installed grammar, run a one-off probe: `.venv/Scripts/python.exe -c "import tree_sitter_typescript as t; from tree_sitter import Language, Parser; p=Parser(Language(t.language_typescript())); print(p.parse(b'namespace N {}').root_node.named_children[0].type)"` and use the printed type in the `walk` check.) + +- [ ] **Step 5: Commit** + +```bash +git add apps/backend/app/extraction/typescript.py apps/backend/tests/extraction/test_typescript_diagnostics.py +git commit -m "feat(extraction): TypeScript extractor emits blind-spot diagnostics" +``` + +--- + +### Task B6: TypeScript support matrix entry + parity test + +**Files:** +- Modify: `apps/backend/app/extraction/support_matrix.py` +- Modify: `apps/backend/tests/extraction/test_support_matrix.py` + +**Interfaces:** +- Produces: `SUPPORT_MATRIX["typescript"]`. + +- [ ] **Step 1: Write the failing test** + +Append to `apps/backend/tests/extraction/test_support_matrix.py`: + +```python +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) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `.venv/Scripts/python.exe -m pytest tests/extraction/test_support_matrix.py::test_typescript_matrix_lists_supported_and_unsupported -v` +Expected: FAIL — `KeyError: 'typescript'`. + +- [ ] **Step 3: Add the TypeScript entry** + +In `apps/backend/app/extraction/support_matrix.py`, add to the `SUPPORT_MATRIX` dict: + +```python + "typescript": LanguageSupport( + supported=( + "file", "import", "export", "function", "class", "method", + "interface", "type", "enum", "const", "route", + ), + unsupported=("dynamic-import", "decorator", "namespace", "commonjs-require", "ambient-module"), + ), +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `.venv/Scripts/python.exe -m pytest tests/extraction/test_support_matrix.py -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add apps/backend/app/extraction/support_matrix.py apps/backend/tests/extraction/test_support_matrix.py +git commit -m "feat(extraction): publish TypeScript support matrix" +``` + +--- + +### Task B7: SnapshotStore integration — TypeScript facts seal + +**Files:** +- Test: `apps/backend/tests/extraction/test_typescript_snapshot_integration.py` + +**Interfaces:** +- Consumes: `TypeScriptExtractor`, `SnapshotStore` (#88), the fixture pattern from Task A11. +- Produces: proof a TS `ExtractionResult` seals to `completed`. + +- [ ] **Step 1: Write the failing test** + +Create `apps/backend/tests/extraction/test_typescript_snapshot_integration.py` — identical structure to Task A11's test but importing `TypeScriptExtractor`, using producer `typescript-ast@1.0.0`, path `src/auth/service.ts`, source `b"export function issueToken() {\n return 1;\n}\n"`, and a `repo:root` evidence with `logical_line_count=3`. Copy Task A11's `session`/`_repository`/`_to_evidence` helpers verbatim (change `extractor="typescript-ast"` in `_to_evidence`) and assert `sealed.state == "completed"`. + +- [ ] **Step 2: Run and verify it passes** + +Run: `.venv/Scripts/python.exe -m pytest tests/extraction/test_typescript_snapshot_integration.py -v` +Expected: PASS (wires existing pieces; if it fails, the failure names the contract gap to fix in `typescript.py`, same as Task A11 Step 3). + +- [ ] **Step 3: Confirm the whole extraction suite is green** + +Run: `.venv/Scripts/python.exe -m pytest tests/extraction/ -v` +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add apps/backend/tests/extraction/test_typescript_snapshot_integration.py +git commit -m "test(extraction): prove TypeScript extraction seals into a snapshot" +``` + +--- + +## Phase C — Remove the placeholder + +### Task C1: Delete `TreeSitterParser`, decouple `engine.py` + +**Files:** +- Delete: `apps/backend/app/parsers/tree_sitter_parser.py` +- Modify: `apps/backend/app/intelligence/engine.py` +- Test: `apps/backend/tests/test_repository_intelligence.py` (confirm existing behavior) + +**Interfaces:** +- Consumes: nothing new. +- Produces: `engine.py` no longer imports `TreeSitterParser`; its regex extraction for non-TS/Python files is unchanged. + +- [ ] **Step 1: Confirm the placeholder's only consumer** + +Run: `.venv/Scripts/python.exe -m pytest tests/test_repository_intelligence.py -v` +Expected: PASS (baseline). Then search usages: +```bash +grep -rn "tree_sitter_parser\|TreeSitterParser\|syntax_parser\|parse_symbols" apps/backend/app +``` +Expected: matches only in `engine.py` and the file being deleted. + +- [ ] **Step 2: Remove the parser usage from `engine.py`** + +In `apps/backend/app/intelligence/engine.py`: +- Delete the import line `from app.parsers.tree_sitter_parser import TreeSitterParser`. +- In `RepositoryIntelligenceEngine.__init__`, remove the `syntax_parser` parameter and the `self.syntax_parser = ...` line. +- In `_file_intelligence`, delete the two lines that call `self.syntax_parser.parse_symbols(...)` and the `if syntax.language and not language:` block that overrode `language` from it. `language` already comes from `node.language`; leave the rest of the method intact. + +- [ ] **Step 3: Delete the placeholder file** + +```bash +git rm apps/backend/app/parsers/tree_sitter_parser.py +``` + +- [ ] **Step 4: Run tests to verify no regression** + +Run: `.venv/Scripts/python.exe -m pytest tests/test_repository_intelligence.py tests/test_ingestion_pipeline.py -v` +Expected: PASS. The legacy regex intelligence blob is unchanged for non-TS/Python files; the engine simply no longer references the dead placeholder. + +- [ ] **Step 5: Commit** + +```bash +git add apps/backend/app/intelligence/engine.py apps/backend/app/parsers/tree_sitter_parser.py +git commit -m "refactor(extraction): remove TreeSitterParser placeholder from engine" +``` + +--- + +## Final verification (before opening the PR) + +- [ ] Run the full backend suite from `apps/backend`: `.venv/Scripts/python.exe -m pytest` + Expected: all pass (existing suite + new `tests/extraction/`). +- [ ] Confirm no live wiring crept in: `grep -rn "PythonExtractor\|TypeScriptExtractor" apps/backend/app` should show references only inside `app/extraction/`, not in `services/`, `api/`, or `engine.py`. +- [ ] Push the branch and open a PR targeting `dev` that closes #89 and #90. + +## Open the PR + +```bash +git push -u origin feat/89-90-evidence-extractors +gh pr create --base dev --title "feat(extraction): evidence-backed TypeScript and Python extractors (#89, #90)" --body "Implements #89 and #90 per docs/superpowers/specs/2026-07-16-evidence-extractors-design.md. Closes #89. Closes #90." +``` From 82955da45727d9147023439b83b04accd42205c5 Mon Sep 17 00:00:00 2001 From: Shaurya K Sharma Date: Thu, 16 Jul 2026 15:26:12 +0100 Subject: [PATCH 063/347] =?UTF-8?q?docs(extraction):=20harden=20Phase=20B?= =?UTF-8?q?=20plan=20=E2=80=94=20method=20traversal,=20const,=20ordinals,?= =?UTF-8?q?=20inline=20B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plans/2026-07-16-evidence-extractors.md | 326 ++++++++++++------ 1 file changed, 220 insertions(+), 106 deletions(-) diff --git a/docs/superpowers/plans/2026-07-16-evidence-extractors.md b/docs/superpowers/plans/2026-07-16-evidence-extractors.md index 0d2d6dc8..058a8fb3 100644 --- a/docs/superpowers/plans/2026-07-16-evidence-extractors.md +++ b/docs/superpowers/plans/2026-07-16-evidence-extractors.md @@ -1589,15 +1589,19 @@ def _keys(source: str): return {n.stable_key for n in result.nodes if n.node_kind == "symbol"}, result -def test_class_method_and_function_qualified_names(): +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" "}\n" "export 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 @@ -1621,6 +1625,27 @@ def test_duplicate_overloads_get_discriminator(): 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([]);\n" + "const 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 ``` - [ ] **Step 2: Run test to verify it fails** @@ -1642,13 +1667,19 @@ Add a constant mapping declaration node types to the child field that holds the ```python _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 } ``` +Including `function_signature` is what makes overload signatures (`export function fmt(...): string;` with no body) each become an occurrence, so duplicates get `#2`/`#3` discriminators. Including `method_definition` is what fixes the class-method traversal: a method is reached with its enclosing class already on the scope stack, so the single emission path qualifies it as `Class.method` — no separate method branch is needed. + In `extract`, replace `_ = tree` with: ```python @@ -1671,107 +1702,107 @@ Add these methods to `TypeScriptExtractor`: 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 _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() - ordinal = 0 source = root.text # bytes of the whole tree - - def name_of(node) -> str | None: - field = _NAMED_DECLARATIONS.get(node.type) - if field is None: + counter = {"n": 0} # mutable box so the one running ordinal is shared + + 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 - name_node = node.child_by_field_name(field) - return None if name_node is None else self._node_text(name_node, source) - - def visit(node, scope: list[str]) -> None: - nonlocal ordinal - child_scope = scope - name = name_of(node) - if name is not None: - 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, node.start_point[0] + 1, node.end_point[0] + 1, - line_count, producer=self.producer, + counter["n"] += 1 + nodes.append( + ExtractedNode( + node_kind="symbol", stable_key=key, name=name, + language="typescript", evidence=(ev,), + properties={"exported": True} if exported else None, ) - if ev is None: - if diag is not None: - diagnostics.append(diag) - else: - nodes.append( - ExtractedNode( - node_kind="symbol", stable_key=key, name=name, - language="typescript", evidence=(ev,), - ) - ) - ordinal += 1 - observations.append( - ExtractedObservation( - observed_kind="definition", subject_kind="symbol", - subject_key=key, referent_text=None, ordinal=ordinal, evidence=ev, - ) - ) - if duplicate: - diagnostics.append( - ExtractedDiagnostic( - code=RI_KEY_DUP_SYMBOL, category="duplicate symbol", - severity="info", - message=f"duplicate symbol name resolved with a discriminator: {final_key}", - path=canonical.normalize_repo_path(path), subject=key, - ) + ) + observations.append( + ExtractedObservation( + observed_kind="definition", subject_kind="symbol", + subject_key=key, referent_text=None, ordinal=counter["n"], evidence=ev, + ) + ) + if duplicate: + diagnostics.append( + ExtractedDiagnostic( + code=RI_KEY_DUP_SYMBOL, category="duplicate symbol", + severity="info", + message=f"duplicate symbol name resolved with a discriminator: {final_key}", + path=canonical.normalize_repo_path(path), subject=key, ) - child_scope = [*scope, name] - # Methods live under class_body; recurse to find them and nested types. + ) + 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: - if child.type == "method_definition": - method_name_node = child.child_by_field_name("name") - if method_name_node is not None and name is not None: - self._emit_method( - child, path, line_count, [*scope, name], - self._node_text(method_name_node, source), - assigner, nodes, observations, - ) - else: - visit(child, child_scope) + visit(child, child_scope) visit(root, []) - - def _emit_method( - self, node, path, line_count, scope, name, assigner, nodes, observations - ) -> None: - base_key = symbol_stable_key(path, scope, name) - final_key, _ = assigner.key(base_key) - key = canonical.normalize_stable_key("symbol", final_key) - ev, _ = build_evidence( - path, node.start_point[0] + 1, node.end_point[0] + 1, - line_count, producer=self.producer, - ) - if ev is None: - return - nodes.append( - ExtractedNode( - node_kind="symbol", stable_key=key, name=name, - language="typescript", evidence=(ev,), - ) - ) - observations.append( - ExtractedObservation( - observed_kind="definition", subject_kind="symbol", - subject_key=key, referent_text=None, ordinal=1, evidence=ev, - ) - ) ``` -Also handle top-level `export const` bindings: in `visit`, before recursing, if `node.type == "lexical_declaration"`, find each `variable_declarator`'s `name` child and emit a symbol the same way (add this branch mirroring the `name is not None` block, using the declarator's identifier text). Keep it minimal — one symbol per top-level const. +The single `emit` closure is the only place a definition observation is created, so its `counter["n"]` gives every observation a distinct, monotonic ordinal — no hard-coded values. Methods, nested functions, top-level consts, and overload signatures all flow through it. - [ ] **Step 4: Run test to verify it passes** Run: `.venv/Scripts/python.exe -m pytest tests/extraction/test_typescript_symbols.py -v` -Expected: PASS. If `const` assertions in a later task need it, the `lexical_declaration` branch is already in place. +Expected: PASS (all five tests: two-method traversal, interface/type/enum, overload discriminators, top-level const with the exported flag, and the exported-function property). - [ ] **Step 5: Commit** @@ -1782,14 +1813,14 @@ git commit -m "feat(extraction): TypeScript extractor emits qualified symbols an --- -### Task B3: TypeScript — import/export observations + exported property +### Task B3: TypeScript — import/export-from observations **Files:** - Modify: `apps/backend/app/extraction/typescript.py` - Test: `apps/backend/tests/extraction/test_typescript_imports.py` **Interfaces:** -- Produces: an `import` observation (`referent_text` = the module specifier) for each `import`/`export … from`; symbols that are exported carry `properties={"exported": True}`. +- Produces: an `import` observation (`referent_text` = the module specifier) for each `import`/`export … from`. (The `exported` property on symbols is already emitted in Task B2.) - [ ] **Step 1: Write the failing test** @@ -1814,28 +1845,18 @@ def test_imports_become_observations(): o.referent_text for o in result.observations if o.observed_kind == "import" ) assert specifiers == ["./session", "./tokens"] - - -def test_exported_symbol_carries_exported_property(): - result = _extract("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 ``` - [ ] **Step 2: Run test to verify it fails** Run: `.venv/Scripts/python.exe -m pytest tests/extraction/test_typescript_imports.py -v` -Expected: FAIL — no import observations, no exported property. +Expected: FAIL — no import observations yet. -- [ ] **Step 3: Add import collection and export detection** +- [ ] **Step 3: Add import collection** -In `_collect_symbols` (or a new `_collect_imports` called from `extract`), walk the tree for `import_statement` and `export_statement` nodes that have a `source` field (a `string` node). Emit an `import` observation with `referent_text` set to the string literal's inner text (strip the surrounding quotes), `subject_kind="file"`, `subject_key=file_key`, one-based span from `start_point`/`end_point`, incrementing a file-level ordinal. +Add a `_collect_imports` called from `extract`. It walks the tree for `import_statement` and `export_statement` nodes that have a `source` field (a `string` node), emitting an `import` observation with `referent_text` set to the string literal's inner text (surrounding quotes stripped), `subject_kind="file"`, `subject_key=file_key`, a one-based span from `start_point`/`end_point`, and a running file-level ordinal that continues from the symbol observations. -For the exported property: when visiting a named declaration, set `exported=True` if the declaration's parent node type is `export_statement`. Attach `properties={"exported": True}` to that symbol's `ExtractedNode` (merge with any existing properties). - -Concretely, add to `extract` before the `return`: +Add to `extract` before the `return`: ```python self._collect_imports(tree.root_node, path, line_count, file_key, observations) @@ -1873,18 +1894,16 @@ Add: walk(root) ``` -For the `exported` property, in `visit`, compute `exported = node.parent is not None and node.parent.type == "export_statement"` and pass `properties={"exported": True} if exported else None` into the symbol's `ExtractedNode(...)`. - - [ ] **Step 4: Run test to verify it passes** Run: `.venv/Scripts/python.exe -m pytest tests/extraction/test_typescript_imports.py tests/extraction/test_typescript_symbols.py -v` -Expected: PASS (both). +Expected: PASS (both — B2's symbol/exported tests still pass, and imports are now observed). - [ ] **Step 5: Commit** ```bash git add apps/backend/app/extraction/typescript.py apps/backend/tests/extraction/test_typescript_imports.py -git commit -m "feat(extraction): TypeScript extractor emits imports and exported property" +git commit -m "feat(extraction): TypeScript extractor emits import observations" ``` --- @@ -2175,7 +2194,102 @@ git commit -m "feat(extraction): publish TypeScript support matrix" - [ ] **Step 1: Write the failing test** -Create `apps/backend/tests/extraction/test_typescript_snapshot_integration.py` — identical structure to Task A11's test but importing `TypeScriptExtractor`, using producer `typescript-ast@1.0.0`, path `src/auth/service.ts`, source `b"export function issueToken() {\n return 1;\n}\n"`, and a `repo:root` evidence with `logical_line_count=3`. Copy Task A11's `session`/`_repository`/`_to_evidence` helpers verbatim (change `extractor="typescript-ast"` in `_to_evidence`) and assert `sealed.state == "completed"`. +Create `apps/backend/tests/extraction/test_typescript_snapshot_integration.py`: + +```python +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.0.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.0.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.0.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.0.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:") +``` - [ ] **Step 2: Run and verify it passes** From 11c91d8ddf98f3a3428f0772314aeac22ab28a03 Mon Sep 17 00:00:00 2001 From: Shaurya K Sharma Date: Thu, 16 Jul 2026 15:30:38 +0100 Subject: [PATCH 064/347] build(extraction): add tree-sitter-typescript grammar dependency --- apps/backend/pyproject.toml | 1 + apps/backend/requirements.txt | 1 + 2 files changed, 2 insertions(+) diff --git a/apps/backend/pyproject.toml b/apps/backend/pyproject.toml index b679efe4..891d3e4d 100644 --- a/apps/backend/pyproject.toml +++ b/apps/backend/pyproject.toml @@ -20,6 +20,7 @@ dependencies = [ "psycopg[binary]>=3.2.0", "redis>=5.0.0", "tree-sitter>=0.22.0", + "tree-sitter-typescript>=0.23.0", "python-multipart>=0.0.9", "httpx>=0.27.0", "xhtml2pdf>=0.2.16", diff --git a/apps/backend/requirements.txt b/apps/backend/requirements.txt index 71bf6c1a..55befe9a 100644 --- a/apps/backend/requirements.txt +++ b/apps/backend/requirements.txt @@ -63,6 +63,7 @@ starlette==1.3.1 svglib==2.0.2 tinycss2==1.5.1 tree-sitter==0.26.0 +tree-sitter-typescript==0.23.2 typing-inspection==0.4.2 typing_extensions==4.16.0 tzdata==2026.3 From 5ca8fa3090eed2335d191cc1e0f3d9746bfc96e7 Mon Sep 17 00:00:00 2001 From: Shaurya K Sharma Date: Thu, 16 Jul 2026 15:34:12 +0100 Subject: [PATCH 065/347] feat(extraction): add shared result model and Extractor protocol --- apps/backend/app/extraction/__init__.py | 17 +++++ apps/backend/app/extraction/base.py | 63 +++++++++++++++++++ apps/backend/tests/extraction/__init__.py | 0 .../tests/extraction/test_base_model.py | 37 +++++++++++ 4 files changed, 117 insertions(+) create mode 100644 apps/backend/app/extraction/__init__.py create mode 100644 apps/backend/app/extraction/base.py create mode 100644 apps/backend/tests/extraction/__init__.py create mode 100644 apps/backend/tests/extraction/test_base_model.py diff --git a/apps/backend/app/extraction/__init__.py b/apps/backend/app/extraction/__init__.py new file mode 100644 index 00000000..e201e0d3 --- /dev/null +++ b/apps/backend/app/extraction/__init__.py @@ -0,0 +1,17 @@ +from app.extraction.base import ( + ExtractedDiagnostic, + ExtractedEvidence, + ExtractedNode, + ExtractedObservation, + ExtractionResult, + Extractor, +) + +__all__ = [ + "ExtractedDiagnostic", + "ExtractedEvidence", + "ExtractedNode", + "ExtractedObservation", + "ExtractionResult", + "Extractor", +] diff --git a/apps/backend/app/extraction/base.py b/apps/backend/app/extraction/base.py new file mode 100644 index 00000000..7bfc0317 --- /dev/null +++ b/apps/backend/app/extraction/base.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from dataclasses import dataclass, field +from typing import Protocol, runtime_checkable + + +@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: ... 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_model.py b/apps/backend/tests/extraction/test_base_model.py new file mode 100644 index 00000000..35cee51e --- /dev/null +++ b/apps/backend/tests/extraction/test_base_model.py @@ -0,0 +1,37 @@ +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] From bc665f27102dc80051264bbeddd0a7b3ce1be270 Mon Sep 17 00:00:00 2001 From: Shaurya K Sharma Date: Thu, 16 Jul 2026 15:38:18 +0100 Subject: [PATCH 066/347] feat(extraction): add source decode, line count, and source diagnostics --- apps/backend/app/extraction/base.py | 55 +++++++++++++++++++ .../tests/extraction/test_base_source.py | 41 ++++++++++++++ 2 files changed, 96 insertions(+) create mode 100644 apps/backend/tests/extraction/test_base_source.py diff --git a/apps/backend/app/extraction/base.py b/apps/backend/app/extraction/base.py index 7bfc0317..40d52658 100644 --- a/apps/backend/app/extraction/base.py +++ b/apps/backend/app/extraction/base.py @@ -61,3 +61,58 @@ class Extractor(Protocol): 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_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_SPAN_INVALID: "invalid span", + RI_SEC_PATH_ESCAPE: "path escape", + RI_KEY_DUP_SYMBOL: "duplicate symbol", +} + + +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. + """ + + 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=path, + ) + 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=path, + ) 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..f50cf6eb --- /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 From 491ab528bc450d021efda11b665995a7893ac86a Mon Sep 17 00:00:00 2001 From: Shaurya K Sharma Date: Thu, 16 Jul 2026 15:42:13 +0100 Subject: [PATCH 067/347] feat(extraction): add span- and path-validated evidence builder --- apps/backend/app/extraction/base.py | 50 +++++++++++++++++++ .../tests/extraction/test_base_evidence.py | 30 +++++++++++ 2 files changed, 80 insertions(+) create mode 100644 apps/backend/tests/extraction/test_base_evidence.py diff --git a/apps/backend/app/extraction/base.py b/apps/backend/app/extraction/base.py index 40d52658..707712e8 100644 --- a/apps/backend/app/extraction/base.py +++ b/apps/backend/app/extraction/base.py @@ -4,6 +4,8 @@ from dataclasses import dataclass, field from typing import Protocol, runtime_checkable +from app.intelligence import canonical + @dataclass(frozen=True) class ExtractedEvidence: @@ -116,3 +118,51 @@ def decode_source( message="file is not valid UTF-8 and could not be decoded", path=path, ) + + +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 ``name@version`` identifier, carried into the diagnostic + so callers do not have to duplicate it. + """ + + 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/tests/extraction/test_base_evidence.py b/apps/backend/tests/extraction/test_base_evidence.py new file mode 100644 index 00000000..fe8ac3ee --- /dev/null +++ b/apps/backend/tests/extraction/test_base_evidence.py @@ -0,0 +1,30 @@ +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.0.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" From f74a03ffe7903707c24cd45ca14e41185a31024b Mon Sep 17 00:00:00 2001 From: Shaurya K Sharma Date: Thu, 16 Jul 2026 15:46:55 +0100 Subject: [PATCH 068/347] docs(extraction): correct build_evidence producer docstring --- apps/backend/app/extraction/base.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/backend/app/extraction/base.py b/apps/backend/app/extraction/base.py index 707712e8..05af52a0 100644 --- a/apps/backend/app/extraction/base.py +++ b/apps/backend/app/extraction/base.py @@ -131,8 +131,10 @@ def build_evidence( ) -> tuple[ExtractedEvidence | None, ExtractedDiagnostic | None]: """Validate a span and path (RFC §4.2, §6.2), returning evidence or a diagnostic. - ``producer`` is the ``name@version`` identifier, carried into the diagnostic - so callers do not have to duplicate it. + ``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: From 617feeee96c5914521f50f5c8d8c07b7afb1ca5b Mon Sep 17 00:00:00 2001 From: Shaurya K Sharma Date: Thu, 16 Jul 2026 15:50:46 +0100 Subject: [PATCH 069/347] feat(extraction): add qualified-name and duplicate-discriminator helpers --- apps/backend/app/extraction/naming.py | 34 ++++++++++++++++++++ apps/backend/tests/extraction/test_naming.py | 20 ++++++++++++ 2 files changed, 54 insertions(+) create mode 100644 apps/backend/app/extraction/naming.py create mode 100644 apps/backend/tests/extraction/test_naming.py diff --git a/apps/backend/app/extraction/naming.py b/apps/backend/app/extraction/naming.py new file mode 100644 index 00000000..55beb198 --- /dev/null +++ b/apps/backend/app/extraction/naming.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from collections import defaultdict +from collections.abc import Sequence + +from app.intelligence import canonical + + +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}" + + +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/tests/extraction/test_naming.py b/apps/backend/tests/extraction/test_naming.py new file mode 100644 index 00000000..f7e27122 --- /dev/null +++ b/apps/backend/tests/extraction/test_naming.py @@ -0,0 +1,20 @@ +from app.extraction.naming import DiscriminatorAssigner, 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_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) From 86948c6c7009180274feab59225b3335300e5c42 Mon Sep 17 00:00:00 2001 From: Shaurya K Sharma Date: Thu, 16 Jul 2026 15:55:57 +0100 Subject: [PATCH 070/347] feat(extraction): Python extractor emits module node and import observations --- apps/backend/app/extraction/python.py | 122 ++++++++++++++++++ .../tests/extraction/test_python_extractor.py | 35 +++++ 2 files changed, 157 insertions(+) create mode 100644 apps/backend/app/extraction/python.py create mode 100644 apps/backend/tests/extraction/test_python_extractor.py diff --git a/apps/backend/app/extraction/python.py b/apps/backend/app/extraction/python.py new file mode 100644 index 00000000..be84a2e8 --- /dev/null +++ b/apps/backend/app/extraction/python.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +import ast +import posixpath + +from app.extraction.base import ( + ExtractedDiagnostic, + ExtractedNode, + ExtractedObservation, + ExtractionResult, + RI_SRC_MALFORMED, + build_evidence, + decode_source, + logical_line_count, +) +from app.intelligence import canonical + + +class PythonExtractor: + name = "python-ast" + version = "1.0.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,)) + + 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), + ), + ) + ) + + nodes: list[ExtractedNode] = [] + observations: list[ExtractedObservation] = [] + diagnostics: list[ExtractedDiagnostic] = [] + + module_key = self._module_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=posixpath.basename(canonical.normalize_repo_path(path)), + language="python", + 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 + ) + + return ExtractionResult( + nodes=tuple(nodes), + observations=tuple(observations), + diagnostics=tuple(diagnostics), + ) + + def _module_key(self, path: str) -> str: + directory = posixpath.dirname(canonical.normalize_repo_path(path)) + return canonical.normalize_stable_key("module", f"mod:{directory}") + + def _collect_imports( + self, tree, path, line_count, module_key, observations, diagnostics + ) -> None: + ordinal = 0 + for node in ast.walk(tree): + names: list[str] = [] + if isinstance(node, ast.Import): + names = [alias.name for alias in node.names] + elif isinstance(node, ast.ImportFrom): + base = node.module or "" + names = [ + f"{base}.{alias.name}" if base else alias.name + for alias in node.names + if alias.name != "*" + ] + else: + continue + for name in names: + ordinal += 1 + 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=ordinal, + evidence=ev, + ) + ) 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..ac78262e --- /dev/null +++ b/apps/backend/tests/extraction/test_python_extractor.py @@ -0,0 +1,35 @@ +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 From 0f96d5e5c8d017aed69b6615fdeb04ee91b6112c Mon Sep 17 00:00:00 2001 From: Shaurya K Sharma Date: Thu, 16 Jul 2026 16:06:00 +0100 Subject: [PATCH 071/347] fix(extraction): guard escaping paths and preserve relative-import level (A6) --- apps/backend/app/extraction/python.py | 19 ++++++++++++++++++- .../tests/extraction/test_python_extractor.py | 16 ++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/apps/backend/app/extraction/python.py b/apps/backend/app/extraction/python.py index be84a2e8..f586347a 100644 --- a/apps/backend/app/extraction/python.py +++ b/apps/backend/app/extraction/python.py @@ -8,6 +8,7 @@ ExtractedNode, ExtractedObservation, ExtractionResult, + RI_SEC_PATH_ESCAPE, RI_SRC_MALFORMED, build_evidence, decode_source, @@ -32,6 +33,21 @@ def extract(self, path: str, source: bytes) -> ExtractionResult: 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) @@ -92,9 +108,10 @@ def _collect_imports( 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 "" names = [ - f"{base}.{alias.name}" if base else alias.name + f"{level_prefix}{base}.{alias.name}" if base else f"{level_prefix}{alias.name}" for alias in node.names if alias.name != "*" ] diff --git a/apps/backend/tests/extraction/test_python_extractor.py b/apps/backend/tests/extraction/test_python_extractor.py index ac78262e..ef9b94ca 100644 --- a/apps/backend/tests/extraction/test_python_extractor.py +++ b/apps/backend/tests/extraction/test_python_extractor.py @@ -33,3 +33,19 @@ def test_imports_become_observations_with_referent_text(): 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 From 017604430f40051916a2a0d900d4ce8bac8a52eb Mon Sep 17 00:00:00 2001 From: Shaurya K Sharma Date: Thu, 16 Jul 2026 22:01:57 +0100 Subject: [PATCH 072/347] feat(extraction): Python extractor emits qualified symbol nodes and definitions --- apps/backend/app/extraction/python.py | 63 +++++++++++++++++++ .../tests/extraction/test_python_symbols.py | 36 +++++++++++ 2 files changed, 99 insertions(+) create mode 100644 apps/backend/tests/extraction/test_python_symbols.py diff --git a/apps/backend/app/extraction/python.py b/apps/backend/app/extraction/python.py index f586347a..8173ce25 100644 --- a/apps/backend/app/extraction/python.py +++ b/apps/backend/app/extraction/python.py @@ -8,12 +8,14 @@ ExtractedNode, ExtractedObservation, ExtractionResult, + RI_KEY_DUP_SYMBOL, RI_SEC_PATH_ESCAPE, RI_SRC_MALFORMED, build_evidence, decode_source, logical_line_count, ) +from app.extraction.naming import DiscriminatorAssigner, symbol_stable_key from app.intelligence import canonical @@ -88,6 +90,9 @@ def extract(self, path: str, source: bytes) -> ExtractionResult: self._collect_imports( tree, path, line_count, module_key, observations, diagnostics ) + self._collect_symbols( + tree, path, line_count, nodes, observations, diagnostics + ) return ExtractionResult( nodes=tuple(nodes), @@ -137,3 +142,61 @@ def _collect_imports( evidence=ev, ) ) + + _DEF_TYPES = (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef) + + def _collect_symbols( + self, tree, path, line_count, nodes, observations, diagnostics + ) -> None: + assigner = DiscriminatorAssigner() + ordinal = 0 + + def visit(scope: list[str], body) -> None: + nonlocal 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: + nodes.append( + ExtractedNode( + node_kind="symbol", + stable_key=canonical.normalize_stable_key("symbol", final_key), + name=child.name, + language="python", + evidence=(ev,), + ) + ) + ordinal += 1 + observations.append( + ExtractedObservation( + observed_kind="definition", + subject_kind="symbol", + subject_key=canonical.normalize_stable_key("symbol", final_key), + referent_text=None, + ordinal=ordinal, + evidence=ev, + ) + ) + if duplicate: + diagnostics.append( + ExtractedDiagnostic( + code=RI_KEY_DUP_SYMBOL, + category="duplicate symbol", + severity="info", + message=f"duplicate symbol name resolved with a discriminator: {final_key}", + path=canonical.normalize_repo_path(path), + subject=canonical.normalize_stable_key("symbol", final_key), + ) + ) + visit([*scope, child.name], child.body) + + visit([], tree.body) 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..82479c8d --- /dev/null +++ b/apps/backend/tests/extraction/test_python_symbols.py @@ -0,0 +1,36 @@ +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\n" + "def 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) From 28ac0d935cb45d4a8343f5a7eaae5cdcd49989c2 Mon Sep 17 00:00:00 2001 From: Shaurya K Sharma Date: Thu, 16 Jul 2026 22:03:13 +0100 Subject: [PATCH 073/347] feat(extraction): Python extractor records decorators and route observations --- apps/backend/app/extraction/python.py | 45 +++++++++++++++++++ .../tests/extraction/test_python_routes.py | 36 +++++++++++++++ 2 files changed, 81 insertions(+) create mode 100644 apps/backend/tests/extraction/test_python_routes.py diff --git a/apps/backend/app/extraction/python.py b/apps/backend/app/extraction/python.py index 8173ce25..54632b93 100644 --- a/apps/backend/app/extraction/python.py +++ b/apps/backend/app/extraction/python.py @@ -18,6 +18,8 @@ from app.extraction.naming import DiscriminatorAssigner, symbol_stable_key from app.intelligence import canonical +_ROUTE_METHODS = {"get", "post", "put", "patch", "delete", "options", "head"} + class PythonExtractor: name = "python-ast" @@ -166,6 +168,9 @@ def visit(scope: list[str], body) -> None: if diag is not None: diagnostics.append(diag) else: + decorators = [self._decorator_name(d) for d in getattr(child, "decorator_list", [])] + decorators = [d for d in decorators if d] + properties = {"decorators": decorators} if decorators else None nodes.append( ExtractedNode( node_kind="symbol", @@ -173,6 +178,7 @@ def visit(scope: list[str], body) -> None: name=child.name, language="python", evidence=(ev,), + properties=properties, ) ) ordinal += 1 @@ -186,6 +192,26 @@ def visit(scope: list[str], body) -> None: evidence=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 + ordinal += 1 + observations.append( + ExtractedObservation( + observed_kind="route", + subject_kind="symbol", + subject_key=canonical.normalize_stable_key("symbol", final_key), + referent_text=route_path, + ordinal=ordinal, + evidence=route_ev, + ) + ) if duplicate: diagnostics.append( ExtractedDiagnostic( @@ -200,3 +226,22 @@ def visit(scope: list[str], body) -> None: visit([*scope, child.name], child.body) visit([], tree.body) + + 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/tests/extraction/test_python_routes.py b/apps/backend/tests/extraction/test_python_routes.py new file mode 100644 index 00000000..927f52d9 --- /dev/null +++ b/apps/backend/tests/extraction/test_python_routes.py @@ -0,0 +1,36 @@ +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\n" + "def 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_fastapi_route_decorator_yields_literal_path_observation(): + result = _extract( + "router = APIRouter(prefix='/auth')\n" + "@router.post('/login')\n" + "def 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::login" From 22466685291252d3e67381de47ee97604169c69d Mon Sep 17 00:00:00 2001 From: Shaurya K Sharma Date: Thu, 16 Jul 2026 22:04:21 +0100 Subject: [PATCH 074/347] feat(extraction): Python extractor emits blind-spot diagnostics --- apps/backend/app/extraction/python.py | 31 +++++++++++++++++++ .../extraction/test_python_diagnostics.py | 31 +++++++++++++++++++ 2 files changed, 62 insertions(+) create mode 100644 apps/backend/tests/extraction/test_python_diagnostics.py diff --git a/apps/backend/app/extraction/python.py b/apps/backend/app/extraction/python.py index 54632b93..7f001ebb 100644 --- a/apps/backend/app/extraction/python.py +++ b/apps/backend/app/extraction/python.py @@ -8,6 +8,7 @@ ExtractedNode, ExtractedObservation, ExtractionResult, + RI_EXT_UNSUPPORTED, RI_KEY_DUP_SYMBOL, RI_SEC_PATH_ESCAPE, RI_SRC_MALFORMED, @@ -19,6 +20,8 @@ from app.intelligence import canonical _ROUTE_METHODS = {"get", "post", "put", "patch", "delete", "options", "head"} +_DYNAMIC_IMPORT_CALLS = {"import_module", "__import__"} +_REFLECTION_CALLS = {"getattr", "setattr", "delattr"} class PythonExtractor: @@ -95,6 +98,7 @@ def extract(self, path: str, source: bytes) -> ExtractionResult: self._collect_symbols( tree, path, line_count, nodes, observations, diagnostics ) + self._collect_blind_spots(tree, path, line_count, diagnostics) return ExtractionResult( nodes=tuple(nodes), @@ -227,6 +231,33 @@ def visit(scope: list[str], body) -> None: visit([], tree.body) + def _collect_blind_spots(self, tree, path, line_count, diagnostics) -> None: + normalized = canonical.normalize_repo_path(path) + + def flag(node, message: str) -> None: + 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), + ) + ) + + for node in ast.walk(tree): + if isinstance(node, ast.ImportFrom) and any(a.name == "*" for a in node.names): + flag(node, f"star-import from {node.module or '.'} is unsupported") + elif isinstance(node, ast.Call): + func = node.func + if isinstance(func, ast.Name) and func.id in _REFLECTION_CALLS: + flag(node, f"reflection via {func.id}() is unsupported") + elif isinstance(func, ast.Name) and func.id == "__import__": + flag(node, "dynamic import via __import__() is unsupported") + elif isinstance(func, ast.Attribute) and func.attr in _DYNAMIC_IMPORT_CALLS: + flag(node, f"dynamic import via {func.attr}() is unsupported") + def _decorator_name(self, decorator) -> str | None: target = decorator.func if isinstance(decorator, ast.Call) else decorator parts: list[str] = [] 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..2ba95235 --- /dev/null +++ b/apps/backend/tests/extraction/test_python_diagnostics.py @@ -0,0 +1,31 @@ +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_reflection_is_flagged(): + codes, _ = _codes("x = getattr(object(), 'name', None)\n") + assert "RI-EXT-UNSUPPORTED" 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 == () From d01da3f3af153589951a82e98efec7101d786edb Mon Sep 17 00:00:00 2001 From: Shaurya K Sharma Date: Thu, 16 Jul 2026 22:05:01 +0100 Subject: [PATCH 075/347] feat(extraction): publish Python support matrix with parity test --- apps/backend/app/extraction/support_matrix.py | 17 +++++++++++++++++ .../tests/extraction/test_support_matrix.py | 16 ++++++++++++++++ 2 files changed, 33 insertions(+) create mode 100644 apps/backend/app/extraction/support_matrix.py create mode 100644 apps/backend/tests/extraction/test_support_matrix.py diff --git a/apps/backend/app/extraction/support_matrix.py b/apps/backend/app/extraction/support_matrix.py new file mode 100644 index 00000000..4bb20e96 --- /dev/null +++ b/apps/backend/app/extraction/support_matrix.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class LanguageSupport: + supported: tuple[str, ...] + unsupported: tuple[str, ...] + + +SUPPORT_MATRIX: dict[str, LanguageSupport] = { + "python": LanguageSupport( + supported=("module", "import", "function", "class", "method", "decorator", "route"), + unsupported=("star-import", "dynamic-import", "reflection", "metaclass"), + ), +} 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..da9d3cbe --- /dev/null +++ b/apps/backend/tests/extraction/test_support_matrix.py @@ -0,0 +1,16 @@ +from app.extraction.support_matrix import SUPPORT_MATRIX + + +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) From d0463658a104b2f5c10205a1cfb972a32643b91e Mon Sep 17 00:00:00 2001 From: Shaurya K Sharma Date: Thu, 16 Jul 2026 22:06:05 +0100 Subject: [PATCH 076/347] test(extraction): prove Python extraction seals into a snapshot --- .../test_python_snapshot_integration.py | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 apps/backend/tests/extraction/test_python_snapshot_integration.py 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..49cd1ef8 --- /dev/null +++ b/apps/backend/tests/extraction/test_python_snapshot_integration.py @@ -0,0 +1,92 @@ +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.0.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.0.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.0.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.0.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:") From 3e360cf49d913c437b913406ab8da5883e9baa49 Mon Sep 17 00:00:00 2001 From: Shaurya K Sharma Date: Thu, 16 Jul 2026 22:07:17 +0100 Subject: [PATCH 077/347] feat(extraction): TypeScript extractor scaffold with file node --- apps/backend/app/extraction/typescript.py | 82 +++++++++++++++++++ .../extraction/test_typescript_extractor.py | 36 ++++++++ 2 files changed, 118 insertions(+) create mode 100644 apps/backend/app/extraction/typescript.py create mode 100644 apps/backend/tests/extraction/test_typescript_extractor.py diff --git a/apps/backend/app/extraction/typescript.py b/apps/backend/app/extraction/typescript.py new file mode 100644 index 00000000..56f3ea3d --- /dev/null +++ b/apps/backend/app/extraction/typescript.py @@ -0,0 +1,82 @@ +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, + ExtractionResult, + RI_SEC_PATH_ESCAPE, + build_evidence, + decode_source, + logical_line_count, +) +from app.intelligence import canonical + +_TS_LANGUAGE = Language(tsts.language_typescript()) +_TSX_LANGUAGE = Language(tsts.language_tsx()) + + +class TypeScriptExtractor: + name = "typescript-ast" + version = "1.0.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}") + 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,), + ) + ) + elif file_diag is not None: + diagnostics.append(file_diag) + + # tree is retained for construct queries added in later tasks. + _ = tree + return ExtractionResult(nodes=tuple(nodes), diagnostics=tuple(diagnostics)) 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..a418c00c --- /dev/null +++ b/apps/backend/tests/extraction/test_typescript_extractor.py @@ -0,0 +1,36 @@ +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 == () From 4466ac5c825aed61a963b8e84af2f571f4405ff5 Mon Sep 17 00:00:00 2001 From: Shaurya K Sharma Date: Thu, 16 Jul 2026 22:08:42 +0100 Subject: [PATCH 078/347] feat(extraction): TypeScript extractor emits qualified symbols and definitions --- apps/backend/app/extraction/typescript.py | 124 +++++++++++++++++- .../extraction/test_typescript_symbols.py | 67 ++++++++++ 2 files changed, 188 insertions(+), 3 deletions(-) create mode 100644 apps/backend/tests/extraction/test_typescript_symbols.py diff --git a/apps/backend/app/extraction/typescript.py b/apps/backend/app/extraction/typescript.py index 56f3ea3d..f6d4a767 100644 --- a/apps/backend/app/extraction/typescript.py +++ b/apps/backend/app/extraction/typescript.py @@ -8,17 +8,32 @@ from app.extraction.base import ( ExtractedDiagnostic, ExtractedNode, + ExtractedObservation, ExtractionResult, + RI_KEY_DUP_SYMBOL, RI_SEC_PATH_ESCAPE, build_evidence, decode_source, logical_line_count, ) +from app.extraction.naming import DiscriminatorAssigner, symbol_stable_key from app.intelligence import canonical _TS_LANGUAGE = Language(tsts.language_typescript()) _TSX_LANGUAGE = Language(tsts.language_tsx()) +_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" @@ -77,6 +92,109 @@ def extract(self, path: str, source: bytes) -> ExtractionResult: elif file_diag is not None: diagnostics.append(file_diag) - # tree is retained for construct queries added in later tasks. - _ = tree - return ExtractionResult(nodes=tuple(nodes), diagnostics=tuple(diagnostics)) + observations: list[ExtractedObservation] = [] + self._collect_symbols( + tree.root_node, path, line_count, file_key, nodes, observations, diagnostics + ) + return ExtractionResult( + nodes=tuple(nodes), + observations=tuple(observations), + diagnostics=tuple(diagnostics), + ) + + 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 _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 + counter = {"n": 0} # mutable box so the one running ordinal is shared + + 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 + counter["n"] += 1 + nodes.append( + ExtractedNode( + node_kind="symbol", stable_key=key, name=name, + language="typescript", evidence=(ev,), + properties={"exported": True} if exported else None, + ) + ) + observations.append( + ExtractedObservation( + observed_kind="definition", subject_kind="symbol", + subject_key=key, referent_text=None, ordinal=counter["n"], evidence=ev, + ) + ) + if duplicate: + diagnostics.append( + ExtractedDiagnostic( + code=RI_KEY_DUP_SYMBOL, category="duplicate symbol", + severity="info", + message=f"duplicate symbol name resolved with a discriminator: {final_key}", + 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/tests/extraction/test_typescript_symbols.py b/apps/backend/tests/extraction/test_typescript_symbols.py new file mode 100644 index 00000000..3c2b38d4 --- /dev/null +++ b/apps/backend/tests/extraction/test_typescript_symbols.py @@ -0,0 +1,67 @@ +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" + "}\n" + "export 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 {}\n" + "export type Id = string;\n" + "export 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([]);\n" + "const 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 From 0c5c76485a7d38f5e4f4b7ff74dea1e9e866cf2e Mon Sep 17 00:00:00 2001 From: Shaurya K Sharma Date: Thu, 16 Jul 2026 22:09:32 +0100 Subject: [PATCH 079/347] feat(extraction): TypeScript extractor emits import observations --- apps/backend/app/extraction/typescript.py | 29 +++++++++++++++++++ .../extraction/test_typescript_imports.py | 18 ++++++++++++ 2 files changed, 47 insertions(+) create mode 100644 apps/backend/tests/extraction/test_typescript_imports.py diff --git a/apps/backend/app/extraction/typescript.py b/apps/backend/app/extraction/typescript.py index f6d4a767..5b388141 100644 --- a/apps/backend/app/extraction/typescript.py +++ b/apps/backend/app/extraction/typescript.py @@ -96,12 +96,41 @@ def extract(self, path: str, source: bytes) -> ExtractionResult: self._collect_symbols( tree.root_node, path, line_count, file_key, nodes, observations, diagnostics ) + self._collect_imports(tree.root_node, path, line_count, file_key, observations) return ExtractionResult( nodes=tuple(nodes), observations=tuple(observations), diagnostics=tuple(diagnostics), ) + def _collect_imports(self, root, path, line_count, file_key, observations) -> None: + source = root.text + ordinal = len(observations) + + def walk(node): + nonlocal ordinal + 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: + ordinal += 1 + observations.append( + ExtractedObservation( + observed_kind="import", subject_kind="file", + subject_key=file_key, referent_text=literal, + ordinal=ordinal, evidence=ev, + ) + ) + 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") 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..ff23e27d --- /dev/null +++ b/apps/backend/tests/extraction/test_typescript_imports.py @@ -0,0 +1,18 @@ +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';\n" + "export { refresh } from './session';\n" + ) + specifiers = sorted( + o.referent_text for o in result.observations if o.observed_kind == "import" + ) + assert specifiers == ["./session", "./tokens"] From a088c05887f0b80f892be43f1af419427670a855 Mon Sep 17 00:00:00 2001 From: Shaurya K Sharma Date: Thu, 16 Jul 2026 22:10:19 +0100 Subject: [PATCH 080/347] feat(extraction): TypeScript extractor emits react-router route observations --- apps/backend/app/extraction/typescript.py | 39 +++++++++++++++++++ .../extraction/test_typescript_routes.py | 23 +++++++++++ 2 files changed, 62 insertions(+) create mode 100644 apps/backend/tests/extraction/test_typescript_routes.py diff --git a/apps/backend/app/extraction/typescript.py b/apps/backend/app/extraction/typescript.py index 5b388141..f7b1cd3e 100644 --- a/apps/backend/app/extraction/typescript.py +++ b/apps/backend/app/extraction/typescript.py @@ -97,6 +97,7 @@ def extract(self, path: str, source: bytes) -> ExtractionResult: tree.root_node, path, line_count, file_key, nodes, observations, diagnostics ) self._collect_imports(tree.root_node, path, line_count, file_key, observations) + self._collect_routes(tree.root_node, path, line_count, file_key, observations) return ExtractionResult( nodes=tuple(nodes), observations=tuple(observations), @@ -131,6 +132,44 @@ def walk(node): walk(root) + def _collect_routes(self, root, path, line_count, file_key, observations) -> None: + source = root.text + ordinal = len(observations) + + def emit(node, literal): + nonlocal ordinal + ev, _ = build_evidence( + path, node.start_point[0] + 1, node.end_point[0] + 1, + line_count, producer=self.producer, + ) + if ev is not None: + ordinal += 1 + observations.append( + ExtractedObservation( + observed_kind="route", subject_kind="file", + subject_key=file_key, referent_text=literal, + ordinal=ordinal, evidence=ev, + ) + ) + + def walk(node): + if node.type == "pair": + key = node.child_by_field_name("key") + value = node.child_by_field_name("value") + if (key is not None and value is not None + and self._node_text(key, source).strip("'\"") == "path" + and value.type in ("string",)): + emit(node, self._node_text(value, source).strip("'\"`")) + elif node.type == "jsx_attribute": + children = node.named_children + if children and self._node_text(children[0], source) == "path" and len(children) > 1: + literal = self._node_text(children[1], source).strip("'\"{}`") + emit(node, literal) + 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") 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..c9b23d23 --- /dev/null +++ b/apps/backend/tests/extraction/test_typescript_routes.py @@ -0,0 +1,23 @@ +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"] From 78840895c25dc982521702387aead1766f3f503c Mon Sep 17 00:00:00 2001 From: Shaurya K Sharma Date: Thu, 16 Jul 2026 22:11:46 +0100 Subject: [PATCH 081/347] feat(extraction): TypeScript extractor emits blind-spot diagnostics --- apps/backend/app/extraction/typescript.py | 41 +++++++++++++++++++ .../extraction/test_typescript_diagnostics.py | 24 +++++++++++ 2 files changed, 65 insertions(+) create mode 100644 apps/backend/tests/extraction/test_typescript_diagnostics.py diff --git a/apps/backend/app/extraction/typescript.py b/apps/backend/app/extraction/typescript.py index f7b1cd3e..fdbcec69 100644 --- a/apps/backend/app/extraction/typescript.py +++ b/apps/backend/app/extraction/typescript.py @@ -10,8 +10,10 @@ ExtractedNode, ExtractedObservation, ExtractionResult, + RI_EXT_UNSUPPORTED, RI_KEY_DUP_SYMBOL, RI_SEC_PATH_ESCAPE, + RI_SRC_MALFORMED, build_evidence, decode_source, logical_line_count, @@ -75,6 +77,15 @@ def extract(self, path: str, source: bytes) -> ExtractionResult: nodes: list[ExtractedNode] = [] diagnostics: list[ExtractedDiagnostic] = [] + if tree.root_node.has_error: + diagnostics.append( + ExtractedDiagnostic( + code=RI_SRC_MALFORMED, category="malformed source", + severity="error", message="file has TypeScript syntax errors", + path=normalized_path, + ) + ) + file_key = canonical.normalize_stable_key("file", f"file:{normalized_path}") file_ev, file_diag = build_evidence( path, 1, line_count, line_count, producer=self.producer, granularity="file" @@ -98,6 +109,7 @@ def extract(self, path: str, source: bytes) -> ExtractionResult: ) self._collect_imports(tree.root_node, path, line_count, file_key, observations) self._collect_routes(tree.root_node, path, line_count, file_key, observations) + self._collect_blind_spots(tree.root_node, path, line_count, diagnostics) return ExtractionResult( nodes=tuple(nodes), observations=tuple(observations), @@ -170,6 +182,35 @@ def walk(node): walk(root) + def _collect_blind_spots(self, root, path, line_count, diagnostics) -> None: + source = root.text + normalized = canonical.normalize_repo_path(path) + + 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), + ) + ) + + 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 == "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") 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..51082514 --- /dev/null +++ b/apps/backend/tests/extraction/test_typescript_diagnostics.py @@ -0,0 +1,24 @@ +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(): + assert "RI-SRC-MALFORMED" in _codes("class {{{ broken\n") From 0645dddbd8c925e8cf96c8d19f894453552e133f Mon Sep 17 00:00:00 2001 From: Shaurya K Sharma Date: Thu, 16 Jul 2026 22:12:27 +0100 Subject: [PATCH 082/347] feat(extraction): publish TypeScript support matrix --- apps/backend/app/extraction/support_matrix.py | 7 +++++++ apps/backend/tests/extraction/test_support_matrix.py | 9 +++++++++ 2 files changed, 16 insertions(+) diff --git a/apps/backend/app/extraction/support_matrix.py b/apps/backend/app/extraction/support_matrix.py index 4bb20e96..c947c717 100644 --- a/apps/backend/app/extraction/support_matrix.py +++ b/apps/backend/app/extraction/support_matrix.py @@ -14,4 +14,11 @@ class LanguageSupport: supported=("module", "import", "function", "class", "method", "decorator", "route"), unsupported=("star-import", "dynamic-import", "reflection", "metaclass"), ), + "typescript": LanguageSupport( + supported=( + "file", "import", "export", "function", "class", "method", + "interface", "type", "enum", "const", "route", + ), + unsupported=("dynamic-import", "decorator", "namespace", "commonjs-require", "ambient-module"), + ), } diff --git a/apps/backend/tests/extraction/test_support_matrix.py b/apps/backend/tests/extraction/test_support_matrix.py index da9d3cbe..7e214cf2 100644 --- a/apps/backend/tests/extraction/test_support_matrix.py +++ b/apps/backend/tests/extraction/test_support_matrix.py @@ -14,3 +14,12 @@ def test_python_matrix_lists_supported_and_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) From d53ed0e475084de91841e77393b5f3e962769151 Mon Sep 17 00:00:00 2001 From: Shaurya K Sharma Date: Thu, 16 Jul 2026 22:13:17 +0100 Subject: [PATCH 083/347] test(extraction): prove TypeScript extraction seals into a snapshot --- .../test_typescript_snapshot_integration.py | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 apps/backend/tests/extraction/test_typescript_snapshot_integration.py 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..68779b46 --- /dev/null +++ b/apps/backend/tests/extraction/test_typescript_snapshot_integration.py @@ -0,0 +1,92 @@ +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.0.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.0.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.0.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.0.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:") From 82b48d439453f8d3ecd5c9783b8ae85210267b1e Mon Sep 17 00:00:00 2001 From: Shaurya K Sharma Date: Thu, 16 Jul 2026 22:14:49 +0100 Subject: [PATCH 084/347] refactor(extraction): remove TreeSitterParser placeholder from engine --- apps/backend/app/intelligence/engine.py | 12 +++---- .../backend/app/parsers/tree_sitter_parser.py | 36 ------------------- 2 files changed, 5 insertions(+), 43 deletions(-) delete mode 100644 apps/backend/app/parsers/tree_sitter_parser.py diff --git a/apps/backend/app/intelligence/engine.py b/apps/backend/app/intelligence/engine.py index bbc6ec96..adf87ab7 100644 --- a/apps/backend/app/intelligence/engine.py +++ b/apps/backend/app/intelligence/engine.py @@ -22,7 +22,6 @@ 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"} @@ -66,10 +65,12 @@ class RepositoryIntelligenceEngine: - """Builds reusable repository intelligence from parser output and repository files.""" + """Builds reusable repository intelligence from repository files. - def __init__(self, syntax_parser: TreeSitterParser | None = None) -> None: - self.syntax_parser = syntax_parser or TreeSitterParser() + Extraction here is regex over file text and carries no line provenance. The + evidence-backed extractors in ``app.extraction`` supersede it for TypeScript + and Python; wiring those into this build path is #93. + """ def from_record(self, record: RepositoryRecord) -> RepositoryIntelligence: existing = self.load(record) @@ -141,9 +142,6 @@ def _file_intelligence(self, root: Path, node: FileTreeNode) -> SourceFileIntell 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) 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 9fbde65d..00000000 --- a/apps/backend/app/parsers/tree_sitter_parser.py +++ /dev/null @@ -1,36 +0,0 @@ -from dataclasses import dataclass - - -@dataclass(frozen=True) -class SyntaxParseResult: - language: str | None - symbols: list[str] - - -class TreeSitterParser: - """PLACEHOLDER — tree-sitter extraction is NOT implemented yet. - - This class only maps a file extension to a language name. It never parses - source or produces symbols: ``parse_symbols`` always returns an empty - ``symbols`` list. Real tree-sitter TS/Python extraction (with line spans) is - planned for the graph work (M2). Symbol extraction today is regex-based in - ``app.intelligence.engine``. Do not treat this as functional syntax parsing. - """ - - def parse_symbols(self, content: bytes, extension: str | None) -> SyntaxParseResult: - # Always returns symbols=[]: this is a not-yet-implemented placeholder. - 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) From 7d3969ff83a8175e6b922a434541c5a49e8a7402 Mon Sep 17 00:00:00 2001 From: Shaurya K Sharma Date: Thu, 16 Jul 2026 22:48:43 +0100 Subject: [PATCH 085/347] style(extraction): drop unused Sequence and field imports --- apps/backend/app/extraction/base.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/backend/app/extraction/base.py b/apps/backend/app/extraction/base.py index 05af52a0..62401be6 100644 --- a/apps/backend/app/extraction/base.py +++ b/apps/backend/app/extraction/base.py @@ -1,7 +1,7 @@ from __future__ import annotations -from collections.abc import Mapping, Sequence -from dataclasses import dataclass, field +from collections.abc import Mapping +from dataclasses import dataclass from typing import Protocol, runtime_checkable from app.intelligence import canonical From e663b5d58027f8234e50d78f74542e651189a998 Mon Sep 17 00:00:00 2001 From: Shaurya K Sharma Date: Thu, 16 Jul 2026 22:50:53 +0100 Subject: [PATCH 086/347] fix(extraction): only treat confirmed react-router contexts as routes A `path` key on any object literal and a `path` attribute on any JSX element were both emitted as route observations, so `{ path: '/tmp/cache' }` and `` produced fabricated routes. Anchor detection to router factory arguments and elements, and cover the negative cases. --- apps/backend/app/extraction/typescript.py | 49 ++++++++++++++++--- .../extraction/test_typescript_routes.py | 33 +++++++++++++ 2 files changed, 75 insertions(+), 7 deletions(-) diff --git a/apps/backend/app/extraction/typescript.py b/apps/backend/app/extraction/typescript.py index fdbcec69..25bea9ae 100644 --- a/apps/backend/app/extraction/typescript.py +++ b/apps/backend/app/extraction/typescript.py @@ -24,6 +24,10 @@ _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"} + _NAMED_DECLARATIONS = { "function_declaration": "name", "function_signature": "name", # ambient/overload signatures (no body) @@ -145,11 +149,24 @@ def walk(node): walk(root) def _collect_routes(self, root, path, line_count, file_key, observations) -> 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() ordinal = len(observations) def emit(node, literal): nonlocal 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, @@ -164,19 +181,37 @@ def emit(node, literal): ) ) - def walk(node): + def collect_path_pairs(node): + # Descends router-factory arguments only; nested `children` route + # tables are reached here, unrelated objects elsewhere are not. if node.type == "pair": key = node.child_by_field_name("key") value = node.child_by_field_name("value") if (key is not None and value is not None and self._node_text(key, source).strip("'\"") == "path" - and value.type in ("string",)): + and value.type == "string"): emit(node, self._node_text(value, source).strip("'\"`")) - elif node.type == "jsx_attribute": - children = node.named_children - if children and self._node_text(children[0], source) == "path" and len(children) > 1: - literal = self._node_text(children[1], source).strip("'\"{}`") - emit(node, literal) + for child in node.named_children: + collect_path_pairs(child) + + 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): + collect_path_pairs(arguments) + 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): + 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"): + emit(child, self._node_text(parts[1], source).strip("'\"{}`")) for child in node.named_children: walk(child) diff --git a/apps/backend/tests/extraction/test_typescript_routes.py b/apps/backend/tests/extraction/test_typescript_routes.py index c9b23d23..9d0b881d 100644 --- a/apps/backend/tests/extraction/test_typescript_routes.py +++ b/apps/backend/tests/extraction/test_typescript_routes.py @@ -21,3 +21,36 @@ def test_jsx_route_path_becomes_route_observation(): 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 _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_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' }]);\n" + "const opts = { path: '/not-a-route' };\n" + ) + assert _routes("src/app/routes/router.ts", source) == ["/login"] From 894726f1e52fc33921c326b14e1446a88be6c430 Mon Sep 17 00:00:00 2001 From: Shaurya K Sharma Date: Thu, 16 Jul 2026 22:52:37 +0100 Subject: [PATCH 087/347] fix(extraction): name Python module nodes after their directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The module stable key is directory-scoped (mod:app/api) but the name came from the file, so app/api/auth.py and app/api/users.py produced conflicting records for one key and add_node refused the second — no repository with sibling modules could seal. Name the module after its directory and cover multi-file snapshots. --- apps/backend/app/extraction/python.py | 14 ++++- .../test_python_snapshot_integration.py | 58 +++++++++++++++++++ 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/apps/backend/app/extraction/python.py b/apps/backend/app/extraction/python.py index 7f001ebb..c5ab16ab 100644 --- a/apps/backend/app/extraction/python.py +++ b/apps/backend/app/extraction/python.py @@ -84,7 +84,7 @@ def extract(self, path: str, source: bytes) -> ExtractionResult: ExtractedNode( node_kind="module", stable_key=module_key, - name=posixpath.basename(canonical.normalize_repo_path(path)), + name=self._module_name(path), language="python", evidence=(module_ev,), ) @@ -110,6 +110,18 @@ def _module_key(self, path: str) -> str: directory = posixpath.dirname(canonical.normalize_repo_path(path)) return canonical.normalize_stable_key("module", f"mod:{directory}") + def _module_name(self, path: str) -> str | None: + """Name the module after its directory, not the file that evidenced it. + + The stable key is directory-scoped (``mod:app/api``), 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 short name. + """ + + directory = posixpath.dirname(canonical.normalize_repo_path(path)) + return posixpath.basename(directory) or None + def _collect_imports( self, tree, path, line_count, module_key, observations, diagnostics ) -> None: diff --git a/apps/backend/tests/extraction/test_python_snapshot_integration.py b/apps/backend/tests/extraction/test_python_snapshot_integration.py index 49cd1ef8..f16edb30 100644 --- a/apps/backend/tests/extraction/test_python_snapshot_integration.py +++ b/apps/backend/tests/extraction/test_python_snapshot_integration.py @@ -90,3 +90,61 @@ def test_python_extraction_result_seals_into_a_snapshot(session): 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.0.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.0.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_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" From 299b25cfc3bd4e039d7a8591940f261751a51c27 Mon Sep 17 00:00:00 2001 From: Shaurya K Sharma Date: Thu, 16 Jul 2026 22:56:46 +0100 Subject: [PATCH 088/347] fix(extraction): scope observation ordinals to the RFC 6.4 identity group Each collector ran its own file-wide counter, so observations that already differed in kind, subject, referent text or span received 1,2,3... where the RFC defines ordinal as source order among observations whose OTHER identity fields are identical. Assign ordinals centrally over the identity tuple and pin an observation-id vector. --- apps/backend/app/extraction/base.py | 38 ++++++- apps/backend/app/extraction/python.py | 20 ++-- apps/backend/app/extraction/typescript.py | 23 ++-- .../backend/tests/extraction/test_ordinals.py | 100 ++++++++++++++++++ 4 files changed, 157 insertions(+), 24 deletions(-) create mode 100644 apps/backend/tests/extraction/test_ordinals.py diff --git a/apps/backend/app/extraction/base.py b/apps/backend/app/extraction/base.py index 62401be6..9c77c0ef 100644 --- a/apps/backend/app/extraction/base.py +++ b/apps/backend/app/extraction/base.py @@ -1,7 +1,8 @@ from __future__ import annotations -from collections.abc import Mapping -from dataclasses import dataclass +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 @@ -84,6 +85,39 @@ def extract(self, path: str, source: bytes) -> ExtractionResult: ... } +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.""" diff --git a/apps/backend/app/extraction/python.py b/apps/backend/app/extraction/python.py index c5ab16ab..bc9d6232 100644 --- a/apps/backend/app/extraction/python.py +++ b/apps/backend/app/extraction/python.py @@ -12,6 +12,7 @@ RI_KEY_DUP_SYMBOL, RI_SEC_PATH_ESCAPE, RI_SRC_MALFORMED, + assign_ordinals, build_evidence, decode_source, logical_line_count, @@ -23,6 +24,11 @@ _DYNAMIC_IMPORT_CALLS = {"import_module", "__import__"} _REFLECTION_CALLS = {"getattr", "setattr", "delattr"} +# 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 + class PythonExtractor: name = "python-ast" @@ -102,7 +108,7 @@ def extract(self, path: str, source: bytes) -> ExtractionResult: return ExtractionResult( nodes=tuple(nodes), - observations=tuple(observations), + observations=assign_ordinals(observations), diagnostics=tuple(diagnostics), ) @@ -125,7 +131,6 @@ def _module_name(self, path: str) -> str | None: def _collect_imports( self, tree, path, line_count, module_key, observations, diagnostics ) -> None: - ordinal = 0 for node in ast.walk(tree): names: list[str] = [] if isinstance(node, ast.Import): @@ -141,7 +146,6 @@ def _collect_imports( else: continue for name in names: - ordinal += 1 ev, diag = build_evidence( path, node.lineno, node.end_lineno or node.lineno, line_count, producer=self.producer, @@ -156,7 +160,7 @@ def _collect_imports( subject_kind="module", subject_key=module_key, referent_text=name, - ordinal=ordinal, + ordinal=_UNASSIGNED_ORDINAL, evidence=ev, ) ) @@ -167,10 +171,8 @@ def _collect_symbols( self, tree, path, line_count, nodes, observations, diagnostics ) -> None: assigner = DiscriminatorAssigner() - ordinal = 0 def visit(scope: list[str], body) -> None: - nonlocal ordinal for child in body: if not isinstance(child, self._DEF_TYPES): continue @@ -197,14 +199,13 @@ def visit(scope: list[str], body) -> None: properties=properties, ) ) - ordinal += 1 observations.append( ExtractedObservation( observed_kind="definition", subject_kind="symbol", subject_key=canonical.normalize_stable_key("symbol", final_key), referent_text=None, - ordinal=ordinal, + ordinal=_UNASSIGNED_ORDINAL, evidence=ev, ) ) @@ -217,14 +218,13 @@ def visit(scope: list[str], body) -> None: if route_diag is not None: diagnostics.append(route_diag) continue - ordinal += 1 observations.append( ExtractedObservation( observed_kind="route", subject_kind="symbol", subject_key=canonical.normalize_stable_key("symbol", final_key), referent_text=route_path, - ordinal=ordinal, + ordinal=_UNASSIGNED_ORDINAL, evidence=route_ev, ) ) diff --git a/apps/backend/app/extraction/typescript.py b/apps/backend/app/extraction/typescript.py index 25bea9ae..d0ae515d 100644 --- a/apps/backend/app/extraction/typescript.py +++ b/apps/backend/app/extraction/typescript.py @@ -14,6 +14,7 @@ RI_KEY_DUP_SYMBOL, RI_SEC_PATH_ESCAPE, RI_SRC_MALFORMED, + assign_ordinals, build_evidence, decode_source, logical_line_count, @@ -28,6 +29,11 @@ _ROUTER_FACTORIES = {"createBrowserRouter", "createHashRouter", "createMemoryRouter"} _ROUTE_ELEMENTS = {"Route"} +# 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 + _NAMED_DECLARATIONS = { "function_declaration": "name", "function_signature": "name", # ambient/overload signatures (no body) @@ -116,16 +122,14 @@ def extract(self, path: str, source: bytes) -> ExtractionResult: self._collect_blind_spots(tree.root_node, path, line_count, diagnostics) return ExtractionResult( nodes=tuple(nodes), - observations=tuple(observations), + observations=assign_ordinals(observations), diagnostics=tuple(diagnostics), ) def _collect_imports(self, root, path, line_count, file_key, observations) -> None: source = root.text - ordinal = len(observations) def walk(node): - nonlocal ordinal if node.type in ("import_statement", "export_statement"): source_node = node.child_by_field_name("source") if source_node is not None: @@ -135,12 +139,11 @@ def walk(node): line_count, producer=self.producer, ) if ev is not None: - ordinal += 1 observations.append( ExtractedObservation( observed_kind="import", subject_kind="file", subject_key=file_key, referent_text=literal, - ordinal=ordinal, evidence=ev, + ordinal=_UNASSIGNED_ORDINAL, evidence=ev, ) ) for child in node.named_children: @@ -160,10 +163,8 @@ def _collect_routes(self, root, path, line_count, file_key, observations) -> Non source = root.text seen: set[int] = set() - ordinal = len(observations) def emit(node, literal): - nonlocal ordinal if node.id in seen: return seen.add(node.id) @@ -172,12 +173,11 @@ def emit(node, literal): line_count, producer=self.producer, ) if ev is not None: - ordinal += 1 observations.append( ExtractedObservation( observed_kind="route", subject_kind="file", subject_key=file_key, referent_text=literal, - ordinal=ordinal, evidence=ev, + ordinal=_UNASSIGNED_ORDINAL, evidence=ev, ) ) @@ -269,7 +269,6 @@ def _collect_symbols( ) -> None: assigner = DiscriminatorAssigner() source = root.text # bytes of the whole tree - counter = {"n": 0} # mutable box so the one running ordinal is shared def emit(name_node, decl_node, scope, exported): """Emit one symbol node + its definition observation; return the name.""" @@ -286,7 +285,6 @@ def emit(name_node, decl_node, scope, exported): if diag is not None: diagnostics.append(diag) return None - counter["n"] += 1 nodes.append( ExtractedNode( node_kind="symbol", stable_key=key, name=name, @@ -297,7 +295,8 @@ def emit(name_node, decl_node, scope, exported): observations.append( ExtractedObservation( observed_kind="definition", subject_kind="symbol", - subject_key=key, referent_text=None, ordinal=counter["n"], evidence=ev, + subject_key=key, referent_text=None, + ordinal=_UNASSIGNED_ORDINAL, evidence=ev, ) ) if duplicate: 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" + ) From 2992242f5cf3a04ed1d7264a1c7a10214aed844b Mon Sep 17 00:00:00 2001 From: Shaurya K Sharma Date: Thu, 16 Jul 2026 22:58:58 +0100 Subject: [PATCH 089/347] feat(extraction): emit TypeScript module nodes and share module naming #89 requires module nodes alongside file nodes; the TypeScript extractor emitted only the file node. Move the directory-scoped module key and name into naming.py so both extractors share one implementation rather than parallel copies (#90). --- apps/backend/app/extraction/naming.py | 21 ++++++++++ apps/backend/app/extraction/python.py | 27 ++++-------- apps/backend/app/extraction/typescript.py | 18 +++++++- .../extraction/test_typescript_extractor.py | 28 +++++++++++++ .../test_typescript_snapshot_integration.py | 42 +++++++++++++++++++ 5 files changed, 116 insertions(+), 20 deletions(-) diff --git a/apps/backend/app/extraction/naming.py b/apps/backend/app/extraction/naming.py index 55beb198..0aecfe63 100644 --- a/apps/backend/app/extraction/naming.py +++ b/apps/backend/app/extraction/naming.py @@ -1,5 +1,6 @@ from __future__ import annotations +import posixpath from collections import defaultdict from collections.abc import Sequence @@ -14,6 +15,26 @@ def symbol_stable_key(path: str, scope: Sequence[str], name: str) -> str: 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 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. diff --git a/apps/backend/app/extraction/python.py b/apps/backend/app/extraction/python.py index bc9d6232..e309285c 100644 --- a/apps/backend/app/extraction/python.py +++ b/apps/backend/app/extraction/python.py @@ -17,7 +17,12 @@ decode_source, logical_line_count, ) -from app.extraction.naming import DiscriminatorAssigner, symbol_stable_key +from app.extraction.naming import ( + DiscriminatorAssigner, + module_name, + module_stable_key, + symbol_stable_key, +) from app.intelligence import canonical _ROUTE_METHODS = {"get", "post", "put", "patch", "delete", "options", "head"} @@ -81,7 +86,7 @@ def extract(self, path: str, source: bytes) -> ExtractionResult: observations: list[ExtractedObservation] = [] diagnostics: list[ExtractedDiagnostic] = [] - module_key = self._module_key(path) + module_key = module_stable_key(path) module_ev, module_ev_diag = build_evidence( path, 1, line_count, line_count, producer=self.producer, granularity="file" ) @@ -90,7 +95,7 @@ def extract(self, path: str, source: bytes) -> ExtractionResult: ExtractedNode( node_kind="module", stable_key=module_key, - name=self._module_name(path), + name=module_name(path), language="python", evidence=(module_ev,), ) @@ -112,22 +117,6 @@ def extract(self, path: str, source: bytes) -> ExtractionResult: diagnostics=tuple(diagnostics), ) - def _module_key(self, path: str) -> str: - directory = posixpath.dirname(canonical.normalize_repo_path(path)) - return canonical.normalize_stable_key("module", f"mod:{directory}") - - def _module_name(self, path: str) -> str | None: - """Name the module after its directory, not the file that evidenced it. - - The stable key is directory-scoped (``mod:app/api``), 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 short name. - """ - - directory = posixpath.dirname(canonical.normalize_repo_path(path)) - return posixpath.basename(directory) or None - def _collect_imports( self, tree, path, line_count, module_key, observations, diagnostics ) -> None: diff --git a/apps/backend/app/extraction/typescript.py b/apps/backend/app/extraction/typescript.py index d0ae515d..7e551088 100644 --- a/apps/backend/app/extraction/typescript.py +++ b/apps/backend/app/extraction/typescript.py @@ -19,7 +19,12 @@ decode_source, logical_line_count, ) -from app.extraction.naming import DiscriminatorAssigner, symbol_stable_key +from app.extraction.naming import ( + DiscriminatorAssigner, + module_name, + module_stable_key, + symbol_stable_key, +) from app.intelligence import canonical _TS_LANGUAGE = Language(tsts.language_typescript()) @@ -110,6 +115,17 @@ def extract(self, path: str, source: bytes) -> ExtractionResult: evidence=(file_ev,), ) ) + # #89 requires module nodes as well as file nodes. The module is + # directory-scoped and shared by every file in that directory. + nodes.append( + ExtractedNode( + node_kind="module", + stable_key=module_stable_key(path), + name=module_name(path), + language="typescript", + evidence=(file_ev,), + ) + ) elif file_diag is not None: diagnostics.append(file_diag) diff --git a/apps/backend/tests/extraction/test_typescript_extractor.py b/apps/backend/tests/extraction/test_typescript_extractor.py index a418c00c..80d93bd5 100644 --- a/apps/backend/tests/extraction/test_typescript_extractor.py +++ b/apps/backend/tests/extraction/test_typescript_extractor.py @@ -34,3 +34,31 @@ 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 diff --git a/apps/backend/tests/extraction/test_typescript_snapshot_integration.py b/apps/backend/tests/extraction/test_typescript_snapshot_integration.py index 68779b46..11fefcfb 100644 --- a/apps/backend/tests/extraction/test_typescript_snapshot_integration.py +++ b/apps/backend/tests/extraction/test_typescript_snapshot_integration.py @@ -90,3 +90,45 @@ def test_typescript_extraction_result_seals_into_a_snapshot(session): 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.0.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.0.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" From f579604318d291ce2723ed6a2e913bb3340db67f Mon Sep 17 00:00:00 2001 From: Shaurya K Sharma Date: Thu, 16 Jul 2026 23:00:29 +0100 Subject: [PATCH 090/347] feat(extraction): observe Python decorators with their own line spans A decorator sits above the def/class line, so it fell outside the symbol's evidence span and the decorators property carried no provenance for its own source. Emit a decorator observation per decorator using its AST span. Registers the observation kind in RFC 9.1, which permits new observation kinds within ri.v1 without a version bump. --- apps/backend/app/extraction/python.py | 31 +++++++++++++++++-- .../tests/extraction/test_python_routes.py | 30 ++++++++++++++++++ .../REPOSITORY_INTELLIGENCE_V1_RFC.md | 2 +- 3 files changed, 60 insertions(+), 3 deletions(-) diff --git a/apps/backend/app/extraction/python.py b/apps/backend/app/extraction/python.py index e309285c..33a72ece 100644 --- a/apps/backend/app/extraction/python.py +++ b/apps/backend/app/extraction/python.py @@ -175,8 +175,12 @@ def visit(scope: list[str], body) -> None: if diag is not None: diagnostics.append(diag) else: - decorators = [self._decorator_name(d) for d in getattr(child, "decorator_list", [])] - decorators = [d for d in decorators if d] + 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( @@ -198,6 +202,29 @@ def visit(scope: list[str], body) -> None: 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, diff --git a/apps/backend/tests/extraction/test_python_routes.py b/apps/backend/tests/extraction/test_python_routes.py index 927f52d9..434d4cc4 100644 --- a/apps/backend/tests/extraction/test_python_routes.py +++ b/apps/backend/tests/extraction/test_python_routes.py @@ -22,6 +22,36 @@ def test_decorators_are_recorded_as_a_symbol_property(): 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\n" + "def 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" diff --git a/docs/architecture/REPOSITORY_INTELLIGENCE_V1_RFC.md b/docs/architecture/REPOSITORY_INTELLIGENCE_V1_RFC.md index 61733977..7cd59294 100644 --- a/docs/architecture/REPOSITORY_INTELLIGENCE_V1_RFC.md +++ b/docs/architecture/REPOSITORY_INTELLIGENCE_V1_RFC.md @@ -674,7 +674,7 @@ is what a `RI-RES-UNRESOLVED`/`RI-RES-AMBIGUOUS` diagnostic points at); it simpl | Field | Rule | | --- | --- | | `observation_id` | REQUIRED. Deterministic id (below). | -| `observed_kind` | REQUIRED. One of `definition`, `import`, `call`, `route`, `implements`, `contains`. Extensible as a compatible addition. | +| `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. | From b0da1bb7675dc035cf93a090fdf5344a0efbfc2c Mon Sep 17 00:00:00 2001 From: Shaurya K Sharma Date: Thu, 16 Jul 2026 23:03:42 +0100 Subject: [PATCH 091/347] fix(extraction): back every unsupported matrix entry with a real diagnostic TypeScript decorators and Python metaclasses were declared unsupported but ignored silently, so the matrix promised a diagnostic it did not emit. Implement both, and make the parity test drive a fixture per unsupported entry so a label without a working diagnostic now fails. --- apps/backend/app/extraction/python.py | 6 ++ apps/backend/app/extraction/typescript.py | 4 ++ .../tests/extraction/test_support_matrix.py | 65 +++++++++++++++++++ 3 files changed, 75 insertions(+) diff --git a/apps/backend/app/extraction/python.py b/apps/backend/app/extraction/python.py index 33a72ece..b07655a6 100644 --- a/apps/backend/app/extraction/python.py +++ b/apps/backend/app/extraction/python.py @@ -277,6 +277,12 @@ def flag(node, message: str) -> None: for node in ast.walk(tree): if isinstance(node, ast.ImportFrom) and any(a.name == "*" for a in node.names): flag(node, f"star-import from {node.module or '.'} is unsupported") + elif isinstance(node, ast.ClassDef) and 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, f"metaclass on class {node.name} is unsupported") elif isinstance(node, ast.Call): func = node.func if isinstance(func, ast.Name) and func.id in _REFLECTION_CALLS: diff --git a/apps/backend/app/extraction/typescript.py b/apps/backend/app/extraction/typescript.py index 7e551088..a6803284 100644 --- a/apps/backend/app/extraction/typescript.py +++ b/apps/backend/app/extraction/typescript.py @@ -249,6 +249,10 @@ def flag(node, message): 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. + flag(node, f"TypeScript decorator {self._node_text(node, source)} is unsupported") elif node.type == "call_expression": fn = node.child_by_field_name("function") if fn is not None: diff --git a/apps/backend/tests/extraction/test_support_matrix.py b/apps/backend/tests/extraction/test_support_matrix.py index 7e214cf2..1b507ef1 100644 --- a/apps/backend/tests/extraction/test_support_matrix.py +++ b/apps/backend/tests/extraction/test_support_matrix.py @@ -1,4 +1,34 @@ +"""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"), + "metaclass": ("app/a.py", "class A(metaclass=Meta):\n pass\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"), +} def test_python_matrix_lists_supported_and_unsupported(): @@ -23,3 +53,38 @@ def test_typescript_matrix_lists_supported_and_unsupported(): 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" + ) + + +@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 From 60184fd2ee378f1cb91d52f10b20ee5a023a1518 Mon Sep 17 00:00:00 2001 From: Shaurya K Sharma Date: Thu, 16 Jul 2026 23:46:03 +0100 Subject: [PATCH 092/347] fix(extraction): make module nodes language-neutral so mixed directories seal A module is a directory and a directory can hold both languages. Both extractors emitted mod: tagged with their own language, so a directory holding app.py and app.ts produced conflicting records for one key and the snapshot refused to seal. The earlier fix corrected the name but not the rest of add_node's conflict tuple (node_kind, name, language, truth_class, properties). --- apps/backend/app/extraction/python.py | 25 ++++++++--- apps/backend/app/extraction/typescript.py | 25 ++++++++--- .../test_python_snapshot_integration.py | 45 +++++++++++++++++++ 3 files changed, 83 insertions(+), 12 deletions(-) diff --git a/apps/backend/app/extraction/python.py b/apps/backend/app/extraction/python.py index b07655a6..69a93eaa 100644 --- a/apps/backend/app/extraction/python.py +++ b/apps/backend/app/extraction/python.py @@ -96,7 +96,11 @@ def extract(self, path: str, source: bytes) -> ExtractionResult: node_kind="module", stable_key=module_key, name=module_name(path), - language="python", + # 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,), ) ) @@ -250,7 +254,10 @@ def visit(scope: list[str], body) -> None: code=RI_KEY_DUP_SYMBOL, category="duplicate symbol", severity="info", - message=f"duplicate symbol name resolved with a discriminator: {final_key}", + # 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), ) @@ -263,6 +270,10 @@ def _collect_blind_spots(self, tree, path, line_count, diagnostics) -> None: normalized = canonical.normalize_repo_path(path) 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, @@ -276,19 +287,21 @@ def flag(node, message: str) -> None: for node in ast.walk(tree): if isinstance(node, ast.ImportFrom) and any(a.name == "*" for a in node.names): - flag(node, f"star-import from {node.module or '.'} is unsupported") + flag(node, "star-import is unsupported") elif isinstance(node, ast.ClassDef) and 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, f"metaclass on class {node.name} is unsupported") + flag(node, "metaclass is unsupported") 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") - elif isinstance(func, ast.Name) and func.id == "__import__": - flag(node, "dynamic import via __import__() is unsupported") + elif isinstance(func, ast.Name) and func.id in _DYNAMIC_IMPORT_CALLS: + flag(node, f"dynamic import via {func.id}() is unsupported") elif isinstance(func, ast.Attribute) and func.attr in _DYNAMIC_IMPORT_CALLS: flag(node, f"dynamic import via {func.attr}() is unsupported") diff --git a/apps/backend/app/extraction/typescript.py b/apps/backend/app/extraction/typescript.py index a6803284..bc1d53cc 100644 --- a/apps/backend/app/extraction/typescript.py +++ b/apps/backend/app/extraction/typescript.py @@ -116,13 +116,15 @@ def extract(self, path: str, source: bytes) -> ExtractionResult: ) ) # #89 requires module nodes as well as file nodes. The module is - # directory-scoped and shared by every file in that directory. + # 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="typescript", + language=None, evidence=(file_ev,), ) ) @@ -216,7 +218,12 @@ def walk(node): 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): - collect_path_pairs(arguments) + # 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_path_pairs(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 @@ -251,8 +258,11 @@ def walk(node): 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. - flag(node, f"TypeScript decorator {self._node_text(node, source)} is unsupported") + # (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: @@ -324,7 +334,10 @@ def emit(name_node, decl_node, scope, exported): ExtractedDiagnostic( code=RI_KEY_DUP_SYMBOL, category="duplicate symbol", severity="info", - message=f"duplicate symbol name resolved with a discriminator: {final_key}", + # 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, ) ) diff --git a/apps/backend/tests/extraction/test_python_snapshot_integration.py b/apps/backend/tests/extraction/test_python_snapshot_integration.py index f16edb30..bc655acf 100644 --- a/apps/backend/tests/extraction/test_python_snapshot_integration.py +++ b/apps/backend/tests/extraction/test_python_snapshot_integration.py @@ -140,6 +140,51 @@ def test_multiple_python_files_in_one_directory_seal(session): 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.0.0", "typescript-ast@1.0.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.0.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="1.0.0", + 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", From 4e8b2177d790c63883efc517d8ea6eb759e56fc6 Mon Sep 17 00:00:00 2001 From: Shaurya K Sharma Date: Thu, 16 Jul 2026 23:46:04 +0100 Subject: [PATCH 093/347] fix(extraction): stop embedding source content in diagnostic messages RFC 13 forbids diagnostic message/details from carrying repository content or secrets. The TypeScript decorator diagnostic quoted the whole decorator source, so @sealed("s3cr3t-token") landed verbatim in a stored message. Swept every message: decorator and star-import no longer quote source, and the duplicate symbol key now travels only in the subject field that exists for it. The path and span still say where to look. --- .../extraction/test_diagnostic_content.py | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 apps/backend/tests/extraction/test_diagnostic_content.py 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..85add8ff --- /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 From 314eab1b64f246d15fef3cf815e258039a8a136d Mon Sep 17 00:00:00 2001 From: Shaurya K Sharma Date: Thu, 16 Jul 2026 23:46:04 +0100 Subject: [PATCH 094/347] fix(extraction): scope route tables to the first argument, catch bare import_module createBrowserRouter(routes, opts) takes a route table and an options object; the walk descended both, so a path key in options was reported as a route. Restrict it to the first argument. Separately, a bare import_module() imported via 'from importlib import import_module' was silent because only __import__ and attribute calls were matched. --- .../tests/extraction/test_python_diagnostics.py | 12 ++++++++++++ .../tests/extraction/test_typescript_routes.py | 17 +++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/apps/backend/tests/extraction/test_python_diagnostics.py b/apps/backend/tests/extraction/test_python_diagnostics.py index 2ba95235..58baa124 100644 --- a/apps/backend/tests/extraction/test_python_diagnostics.py +++ b/apps/backend/tests/extraction/test_python_diagnostics.py @@ -20,6 +20,18 @@ def test_dynamic_import_is_flagged(): 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_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 diff --git a/apps/backend/tests/extraction/test_typescript_routes.py b/apps/backend/tests/extraction/test_typescript_routes.py index 9d0b881d..d93c1b3d 100644 --- a/apps/backend/tests/extraction/test_typescript_routes.py +++ b/apps/backend/tests/extraction/test_typescript_routes.py @@ -47,6 +47,23 @@ def test_nested_router_children_are_routes(): 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 = ( From f9d2e34c75cb5953236f4a726afb3cd7deba0e7a Mon Sep 17 00:00:00 2001 From: Shaurya K Sharma Date: Thu, 16 Jul 2026 23:52:25 +0100 Subject: [PATCH 095/347] feat(extraction): declare and detect Python monkey-patching as a blind spot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #90 lists monkey-patching among the blind spots to declare, and the approved design maps it to RI-EXT-UNSUPPORTED, but the published matrix omitted it entirely — a construct we knew we could not model and said nothing about. Flags assignment to an attribute rooted at a name this file imported, which is what makes it somebody else's object. Assignment to self or to a local is ordinary and is not flagged; a scan of all 97 backend files reports none. --- apps/backend/app/extraction/python.py | 41 ++++++++++++++++ apps/backend/app/extraction/support_matrix.py | 2 +- .../extraction/test_python_diagnostics.py | 49 +++++++++++++++++++ .../tests/extraction/test_support_matrix.py | 1 + 4 files changed, 92 insertions(+), 1 deletion(-) diff --git a/apps/backend/app/extraction/python.py b/apps/backend/app/extraction/python.py index 69a93eaa..70f53dbb 100644 --- a/apps/backend/app/extraction/python.py +++ b/apps/backend/app/extraction/python.py @@ -266,8 +266,36 @@ def visit(scope: list[str], body) -> None: visit([], tree.body) + def _imported_bindings(self, tree) -> set[str]: + """Names this file binds via an import. + + ``import os`` binds ``os``; ``import os.path`` binds the top package + ``os``; ``import numpy as np`` binds ``np``; ``from m import Thing`` + binds ``Thing``. These are the names whose attributes belong to somebody + else, which is what makes rebinding them monkey-patching. + """ + + bindings: set[str] = set() + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + bindings.add(alias.asname or alias.name.split(".", 1)[0]) + elif isinstance(node, ast.ImportFrom): + for alias in node.names: + if alias.name != "*": + bindings.add(alias.asname or alias.name) + return bindings + + 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 _collect_blind_spots(self, tree, path, line_count, diagnostics) -> None: normalized = canonical.normalize_repo_path(path) + imported = self._imported_bindings(tree) def flag(node, message: str) -> None: # `message` names the construct; it never quotes source. Diagnostics @@ -294,6 +322,19 @@ def flag(node, message: str) -> None: # 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") + elif isinstance(node, (ast.Assign, ast.AugAssign, ast.AnnAssign)): + # Rebinding an attribute on a name this file imported mutates an + # object defined elsewhere, so any fact stated about that object's + # definition is incomplete. Assignment to a local or to `self` is + # ordinary and must not be flagged, or the diagnostic is noise. + targets = node.targets if isinstance(node, ast.Assign) else [node.target] + for target in targets: + if not isinstance(target, ast.Attribute): + continue + root = self._attribute_root(target) + if root is not None and root.id in imported: + flag(node, "monkey-patching an imported name is unsupported") + break elif isinstance(node, ast.Call): func = node.func # These names come from this module's own closed vocabulary, not diff --git a/apps/backend/app/extraction/support_matrix.py b/apps/backend/app/extraction/support_matrix.py index c947c717..040832d0 100644 --- a/apps/backend/app/extraction/support_matrix.py +++ b/apps/backend/app/extraction/support_matrix.py @@ -12,7 +12,7 @@ class LanguageSupport: SUPPORT_MATRIX: dict[str, LanguageSupport] = { "python": LanguageSupport( supported=("module", "import", "function", "class", "method", "decorator", "route"), - unsupported=("star-import", "dynamic-import", "reflection", "metaclass"), + unsupported=("star-import", "dynamic-import", "reflection", "monkeypatch", "metaclass"), ), "typescript": LanguageSupport( supported=( diff --git a/apps/backend/tests/extraction/test_python_diagnostics.py b/apps/backend/tests/extraction/test_python_diagnostics.py index 58baa124..bd5d231f 100644 --- a/apps/backend/tests/extraction/test_python_diagnostics.py +++ b/apps/backend/tests/extraction/test_python_diagnostics.py @@ -37,6 +37,55 @@ def test_reflection_is_flagged(): 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 + + +# 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_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"] diff --git a/apps/backend/tests/extraction/test_support_matrix.py b/apps/backend/tests/extraction/test_support_matrix.py index 1b507ef1..d55f7490 100644 --- a/apps/backend/tests/extraction/test_support_matrix.py +++ b/apps/backend/tests/extraction/test_support_matrix.py @@ -18,6 +18,7 @@ "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"), } From 11d8c2e4d354a58eb3866db93c2407b79afe20fe Mon Sep 17 00:00:00 2001 From: Shaurya K Sharma Date: Thu, 16 Jul 2026 23:55:23 +0100 Subject: [PATCH 096/347] docs(extraction): align the design matrix with the reviewed implementation The design tables had drifted from what shipped: the TypeScript side listed only a file node (the omission that let the missing module node through), Python decorators were described as a node property alone, and monkey-patching was folded into the reflection row rather than being its own construct. Also drops a posixpath import left stale when the module helpers moved to naming.py. --- apps/backend/app/extraction/python.py | 1 - .../2026-07-16-evidence-extractors-design.md | 21 ++++++++++--------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/apps/backend/app/extraction/python.py b/apps/backend/app/extraction/python.py index 70f53dbb..f4e3da45 100644 --- a/apps/backend/app/extraction/python.py +++ b/apps/backend/app/extraction/python.py @@ -1,7 +1,6 @@ from __future__ import annotations import ast -import posixpath from app.extraction.base import ( ExtractedDiagnostic, diff --git a/docs/superpowers/specs/2026-07-16-evidence-extractors-design.md b/docs/superpowers/specs/2026-07-16-evidence-extractors-design.md index 7a46f984..e85a8999 100644 --- a/docs/superpowers/specs/2026-07-16-evidence-extractors-design.md +++ b/docs/superpowers/specs/2026-07-16-evidence-extractors-design.md @@ -196,11 +196,12 @@ assert against it so they cannot drift. | Supported → node/observation | Not supported → diagnostic | | --- | --- | | file node (whole-file evidence) | dynamic `import()` → `RI-EXT-UNSUPPORTED` | -| `import`/`export … from` → `import` observation | decorators → `RI-EXT-UNSUPPORTED` | -| function decls (incl. nested, arrow assigned to const) → symbol node + `definition` obs | `namespace` / ambient `module` → `RI-EXT-UNSUPPORTED` | -| class decls + methods (incl. nested) → symbol nodes | `export =` / `require(...)` (CommonJS) → `RI-EXT-UNSUPPORTED` | +| module node (`mod:`, directory-scoped, language-neutral) | decorators → `RI-EXT-UNSUPPORTED` | +| `import`/`export … from` → `import` observation | `namespace` / ambient `module` → `RI-EXT-UNSUPPORTED` | +| function decls (incl. nested, arrow assigned to const) → symbol node + `definition` obs | `export =` / `require(...)` (CommonJS) → `RI-EXT-UNSUPPORTED` | +| class decls + methods (incl. nested) → symbol nodes | | | `interface` / `type` / `enum` / exported `const` → symbol nodes | | -| react-router routes (``, `createBrowserRouter` entries) → `route` obs | | +| react-router routes (``, router-factory route tables) → `route` obs | | > **Exports** (an explicit #89 deliverable) are represented as an `exported: true` > **node property** on the symbol they qualify — not a separate node kind. A @@ -222,12 +223,12 @@ assert against it so they cannot drift. | Supported → node/observation | Not supported → diagnostic | | --- | --- | -| module node (whole-file evidence) | `import *` (star-import) → `RI-EXT-UNSUPPORTED` | -| `import` / `from … import` → `import` observation | dynamic import (`importlib`, `__import__`) → `RI-EXT-UNSUPPORTED` | -| function defs (incl. nested, async) → symbol node + `definition` obs | monkey-patching / reflection (`setattr`/`getattr`) → `RI-EXT-UNSUPPORTED` | -| class defs + methods (incl. nested) → symbol nodes | metaclasses → `RI-EXT-UNSUPPORTED` | -| decorators → node property on the decorated symbol | syntax error → `RI-SRC-MALFORMED` (whole file, no facts) | -| FastAPI route decorators → `route` obs (literal path only) | | +| module node (`mod:`, directory-scoped, language-neutral) | `import *` (star-import) → `RI-EXT-UNSUPPORTED` | +| `import` / `from … import` → `import` observation | dynamic import (`import_module`, bare or via `importlib`; `__import__`) → `RI-EXT-UNSUPPORTED` | +| function defs (incl. nested, async) → symbol node + `definition` obs | reflection (`getattr`/`setattr`/`delattr`) → `RI-EXT-UNSUPPORTED` | +| class defs + methods (incl. nested) → symbol nodes | monkey-patching (rebinding an attribute on an imported name) → `RI-EXT-UNSUPPORTED` | +| decorators → `decorator` observation (own span) + node property | metaclasses → `RI-EXT-UNSUPPORTED` | +| FastAPI route decorators → `route` obs (literal path only) | syntax error → `RI-SRC-MALFORMED` (whole file, no facts) | ## 7. Stable keys, qualified names, spans (shared, `base.py`) From 5d93c3e5b694ea6fda44f76b8b46674886fb3356 Mon Sep 17 00:00:00 2001 From: Shaurya K Sharma Date: Fri, 17 Jul 2026 00:43:48 +0100 Subject: [PATCH 097/347] fix(extraction): resolve route and import bindings --- apps/backend/app/extraction/python.py | 363 +++++++++++++++--- apps/backend/app/extraction/typescript.py | 49 ++- .../extraction/test_python_diagnostics.py | 47 +++ .../extraction/test_typescript_routes.py | 15 + 4 files changed, 415 insertions(+), 59 deletions(-) diff --git a/apps/backend/app/extraction/python.py b/apps/backend/app/extraction/python.py index f4e3da45..3672cc6c 100644 --- a/apps/backend/app/extraction/python.py +++ b/apps/backend/app/extraction/python.py @@ -25,7 +25,6 @@ from app.intelligence import canonical _ROUTE_METHODS = {"get", "post", "put", "patch", "delete", "options", "head"} -_DYNAMIC_IMPORT_CALLS = {"import_module", "__import__"} _REFLECTION_CALLS = {"getattr", "setattr", "delattr"} # Collectors emit this; assign_ordinals sets the RFC §6.4 value on the way out. @@ -33,6 +32,102 @@ # 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" @@ -265,26 +360,6 @@ def visit(scope: list[str], body) -> None: visit([], tree.body) - def _imported_bindings(self, tree) -> set[str]: - """Names this file binds via an import. - - ``import os`` binds ``os``; ``import os.path`` binds the top package - ``os``; ``import numpy as np`` binds ``np``; ``from m import Thing`` - binds ``Thing``. These are the names whose attributes belong to somebody - else, which is what makes rebinding them monkey-patching. - """ - - bindings: set[str] = set() - for node in ast.walk(tree): - if isinstance(node, ast.Import): - for alias in node.names: - bindings.add(alias.asname or alias.name.split(".", 1)[0]) - elif isinstance(node, ast.ImportFrom): - for alias in node.names: - if alias.name != "*": - bindings.add(alias.asname or alias.name) - return bindings - def _attribute_root(self, node): """Resolve ``a.b.c`` to its root ``Name``, or None if not name-rooted.""" @@ -292,9 +367,63 @@ def _attribute_root(self, node): 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) - imported = self._imported_bindings(tree) def flag(node, message: str) -> None: # `message` names the construct; it never quotes source. Diagnostics @@ -312,38 +441,180 @@ def flag(node, message: str) -> None: ) ) - for node in ast.walk(tree): - if isinstance(node, ast.ImportFrom) and any(a.name == "*" for a in node.names): - flag(node, "star-import is unsupported") - elif isinstance(node, ast.ClassDef) and 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") - elif isinstance(node, (ast.Assign, ast.AugAssign, ast.AnnAssign)): - # Rebinding an attribute on a name this file imported mutates an - # object defined elsewhere, so any fact stated about that object's - # definition is incomplete. Assignment to a local or to `self` is - # ordinary and must not be flagged, or the diagnostic is noise. - targets = node.targets if isinstance(node, ast.Assign) else [node.target] - for target in targets: - if not isinstance(target, ast.Attribute): + 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 - root = self._attribute_root(target) - if root is not None and root.id in imported: + 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") - break + 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") - elif isinstance(func, ast.Name) and func.id in _DYNAMIC_IMPORT_CALLS: - flag(node, f"dynamic import via {func.id}() is unsupported") - elif isinstance(func, ast.Attribute) and func.attr in _DYNAMIC_IMPORT_CALLS: - flag(node, f"dynamic import via {func.attr}() 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 diff --git a/apps/backend/app/extraction/typescript.py b/apps/backend/app/extraction/typescript.py index bc1d53cc..ed7abd0f 100644 --- a/apps/backend/app/extraction/typescript.py +++ b/apps/backend/app/extraction/typescript.py @@ -199,18 +199,41 @@ def emit(node, literal): ) ) - def collect_path_pairs(node): - # Descends router-factory arguments only; nested `children` route - # tables are reached here, unrelated objects elsewhere are not. - if node.type == "pair": - key = node.child_by_field_name("key") - value = node.child_by_field_name("value") - if (key is not None and value is not None - and self._node_text(key, source).strip("'\"") == "path" - and value.type == "string"): - emit(node, self._node_text(value, source).strip("'\"`")) - for child in node.named_children: - collect_path_pairs(child) + 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 + for pair in node.named_children: + if pair.type != "pair": + continue + path_value = pair_value(pair, "path") + if path_value is not None and path_value.type == "string": + emit(pair, self._node_text(path_value, source).strip("'\"`")) + children_value = pair_value(pair, "children") + if children_value is not None: + collect_route_table(children_value) + + 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": @@ -223,7 +246,7 @@ def walk(node): # (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_path_pairs(route_table) + 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 diff --git a/apps/backend/tests/extraction/test_python_diagnostics.py b/apps/backend/tests/extraction/test_python_diagnostics.py index bd5d231f..f9912342 100644 --- a/apps/backend/tests/extraction/test_python_diagnostics.py +++ b/apps/backend/tests/extraction/test_python_diagnostics.py @@ -27,6 +27,16 @@ def test_bare_imported_import_module_is_flagged(): 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 @@ -62,6 +72,11 @@ def test_augmented_assignment_to_an_import_is_flagged(): 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. @@ -86,6 +101,38 @@ def test_reading_an_imported_attribute_is_not_monkeypatching(): 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"] diff --git a/apps/backend/tests/extraction/test_typescript_routes.py b/apps/backend/tests/extraction/test_typescript_routes.py index d93c1b3d..a0a1d80e 100644 --- a/apps/backend/tests/extraction/test_typescript_routes.py +++ b/apps/backend/tests/extraction/test_typescript_routes.py @@ -47,6 +47,21 @@ def test_nested_router_children_are_routes(): 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. From 8a54a7420899a354e4cfab2f2805156a484d7778 Mon Sep 17 00:00:00 2001 From: parthrohit22 Date: Thu, 16 Jul 2026 22:54:53 +0100 Subject: [PATCH 098/347] test(intelligence): add Repository Intelligence golden benchmark harness and corpus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Establish the golden-repository benchmark and regression harness for Issue #94: a versioned synthetic fixture corpus with independently authored expected facts, a precision/recall scorer, citation/provenance validity, and snapshot-hash determinism, all exercising the merged #86 evidence contract and the #88 SnapshotStore / canonical graph hash (no second parser). - Versioned fixtures in all three classes (minimal, realistic, adversarial) across Python and TypeScript, with expected facts committed alongside and a strict manifest loader that rejects bad schema versions, duplicate ids/identities, missing sources, absolute/escaping paths, invalid spans, undeclared constructs, inconsistent producers, and machine-blessed output. - Scorer computes TP/FP/FN, precision, and recall (multiset-aware, exact fractions, explicit zero-denominator behaviour) with per-language and per-fixture-class breakdowns and controlled-input unit tests. - Provenance validator independently re-derives RFC-0001 §6.2 checks from the stored bytes; determinism seals each fixture's node graph twice through the real SnapshotStore and compares the canonical graph hash. - Deterministic Markdown + JSON regression reports; the runner returns non-zero on invalid provenance, determinism failure, support-matrix parity failure, a missing required diagnostic, a broken manifest, or (when an extractor is available) precision/recall below threshold. A regression test proves the failure path fails. - CI runs the benchmark in the backend job, uploads the reports as an artifact even on failure, writes a job summary, and fails the job below threshold. Scope: the #89/#90 extractors and their published support matrices are not merged into dev yet, so live precision/recall scoring is deferred at the adapter boundary and reported as such rather than scored against the golden facts themselves. The provisional construct taxonomy and thresholds are versioned for review and must be reconciled with the real #89/#90 support matrices when they merge. Related to #94. --- .github/workflows/ci.yml | 32 ++ .gitignore | 5 + apps/backend/tests/benchmark/README.md | 136 ++++++ apps/backend/tests/benchmark/__init__.py | 25 + apps/backend/tests/benchmark/adapter.py | 57 +++ .../config/benchmark_support_matrix.json | 41 ++ .../tests/benchmark/config/thresholds.json | 8 + apps/backend/tests/benchmark/determinism.py | 175 +++++++ apps/backend/tests/benchmark/facts.py | 111 +++++ .../adv-py-blindspots/manifest.json | 36 ++ .../adv-py-blindspots/src/dynamic.py | 6 + .../adv-py-malformed/manifest.json | 21 + .../adv-py-malformed/src/broken.py | 2 + .../adversarial/adv-source-edgecases/blob.bin | Bin 0 -> 16 bytes .../adv-source-edgecases/manifest.json | 27 ++ .../adv-ts-blindspots/manifest.json | 30 ++ .../adv-ts-blindspots/src/dynamic.ts | 7 + .../adv-ts-malformed/manifest.json | 21 + .../adv-ts-malformed/src/broken.ts | 3 + .../fixtures/minimal/min-empty-file/empty.txt | 0 .../minimal/min-empty-file/manifest.json | 22 + .../minimal/min-py-async/manifest.json | 24 + .../minimal/min-py-async/src/tasks.py | 2 + .../minimal/min-py-class/manifest.json | 28 ++ .../minimal/min-py-class/src/models.py | 6 + .../min-py-decorator-route/manifest.json | 31 ++ .../minimal/min-py-decorator-route/src/api.py | 13 + .../minimal/min-py-function/README.md | 3 + .../minimal/min-py-function/manifest.json | 67 +++ .../minimal/min-py-function/src/greeting.py | 6 + .../minimal/min-py-imports/manifest.json | 31 ++ .../minimal/min-py-imports/src/wiring.py | 3 + .../minimal/min-py-nested-dup/manifest.json | 33 ++ .../minimal/min-py-nested-dup/src/util.py | 8 + .../minimal/min-trailing-newline/data.txt | 2 + .../min-trailing-newline/manifest.json | 22 + .../minimal/min-ts-class/manifest.json | 28 ++ .../minimal/min-ts-class/src/service.ts | 9 + .../minimal/min-ts-functions/manifest.json | 26 ++ .../minimal/min-ts-functions/src/util.ts | 7 + .../min-ts-imports-exports/manifest.json | 33 ++ .../min-ts-imports-exports/src/index.ts | 6 + .../minimal/min-ts-route/manifest.json | 27 ++ .../minimal/min-ts-route/src/router.ts | 7 + .../realistic/real-py-fastapi/manifest.json | 48 ++ .../realistic/real-py-fastapi/src/service.py | 21 + .../realistic/real-ts-service/manifest.json | 33 ++ .../realistic/real-ts-service/src/server.ts | 7 + apps/backend/tests/benchmark/loader.py | 435 ++++++++++++++++++ apps/backend/tests/benchmark/paths.py | 11 + apps/backend/tests/benchmark/provenance.py | 124 +++++ apps/backend/tests/benchmark/report.py | 236 ++++++++++ apps/backend/tests/benchmark/run.py | 61 +++ apps/backend/tests/benchmark/runner.py | 301 ++++++++++++ apps/backend/tests/benchmark/schema.py | 47 ++ apps/backend/tests/benchmark/scorer.py | 127 +++++ apps/backend/tests/benchmark/sourcefiles.py | 45 ++ .../tests/benchmark/test_determinism.py | 34 ++ apps/backend/tests/benchmark/test_loader.py | 155 +++++++ .../tests/benchmark/test_provenance.py | 89 ++++ .../benchmark/test_regression_failpath.py | 134 ++++++ apps/backend/tests/benchmark/test_runner.py | 51 ++ apps/backend/tests/benchmark/test_scorer.py | 141 ++++++ docs/README.md | 1 + 64 files changed, 3288 insertions(+) create mode 100644 apps/backend/tests/benchmark/README.md create mode 100644 apps/backend/tests/benchmark/__init__.py create mode 100644 apps/backend/tests/benchmark/adapter.py create mode 100644 apps/backend/tests/benchmark/config/benchmark_support_matrix.json create mode 100644 apps/backend/tests/benchmark/config/thresholds.json create mode 100644 apps/backend/tests/benchmark/determinism.py create mode 100644 apps/backend/tests/benchmark/facts.py create mode 100644 apps/backend/tests/benchmark/fixtures/adversarial/adv-py-blindspots/manifest.json create mode 100644 apps/backend/tests/benchmark/fixtures/adversarial/adv-py-blindspots/src/dynamic.py create mode 100644 apps/backend/tests/benchmark/fixtures/adversarial/adv-py-malformed/manifest.json create mode 100644 apps/backend/tests/benchmark/fixtures/adversarial/adv-py-malformed/src/broken.py create mode 100644 apps/backend/tests/benchmark/fixtures/adversarial/adv-source-edgecases/blob.bin create mode 100644 apps/backend/tests/benchmark/fixtures/adversarial/adv-source-edgecases/manifest.json create mode 100644 apps/backend/tests/benchmark/fixtures/adversarial/adv-ts-blindspots/manifest.json create mode 100644 apps/backend/tests/benchmark/fixtures/adversarial/adv-ts-blindspots/src/dynamic.ts create mode 100644 apps/backend/tests/benchmark/fixtures/adversarial/adv-ts-malformed/manifest.json create mode 100644 apps/backend/tests/benchmark/fixtures/adversarial/adv-ts-malformed/src/broken.ts create mode 100644 apps/backend/tests/benchmark/fixtures/minimal/min-empty-file/empty.txt create mode 100644 apps/backend/tests/benchmark/fixtures/minimal/min-empty-file/manifest.json create mode 100644 apps/backend/tests/benchmark/fixtures/minimal/min-py-async/manifest.json create mode 100644 apps/backend/tests/benchmark/fixtures/minimal/min-py-async/src/tasks.py create mode 100644 apps/backend/tests/benchmark/fixtures/minimal/min-py-class/manifest.json create mode 100644 apps/backend/tests/benchmark/fixtures/minimal/min-py-class/src/models.py create mode 100644 apps/backend/tests/benchmark/fixtures/minimal/min-py-decorator-route/manifest.json create mode 100644 apps/backend/tests/benchmark/fixtures/minimal/min-py-decorator-route/src/api.py create mode 100644 apps/backend/tests/benchmark/fixtures/minimal/min-py-function/README.md create mode 100644 apps/backend/tests/benchmark/fixtures/minimal/min-py-function/manifest.json create mode 100644 apps/backend/tests/benchmark/fixtures/minimal/min-py-function/src/greeting.py create mode 100644 apps/backend/tests/benchmark/fixtures/minimal/min-py-imports/manifest.json create mode 100644 apps/backend/tests/benchmark/fixtures/minimal/min-py-imports/src/wiring.py create mode 100644 apps/backend/tests/benchmark/fixtures/minimal/min-py-nested-dup/manifest.json create mode 100644 apps/backend/tests/benchmark/fixtures/minimal/min-py-nested-dup/src/util.py create mode 100644 apps/backend/tests/benchmark/fixtures/minimal/min-trailing-newline/data.txt create mode 100644 apps/backend/tests/benchmark/fixtures/minimal/min-trailing-newline/manifest.json create mode 100644 apps/backend/tests/benchmark/fixtures/minimal/min-ts-class/manifest.json create mode 100644 apps/backend/tests/benchmark/fixtures/minimal/min-ts-class/src/service.ts create mode 100644 apps/backend/tests/benchmark/fixtures/minimal/min-ts-functions/manifest.json create mode 100644 apps/backend/tests/benchmark/fixtures/minimal/min-ts-functions/src/util.ts create mode 100644 apps/backend/tests/benchmark/fixtures/minimal/min-ts-imports-exports/manifest.json create mode 100644 apps/backend/tests/benchmark/fixtures/minimal/min-ts-imports-exports/src/index.ts create mode 100644 apps/backend/tests/benchmark/fixtures/minimal/min-ts-route/manifest.json create mode 100644 apps/backend/tests/benchmark/fixtures/minimal/min-ts-route/src/router.ts create mode 100644 apps/backend/tests/benchmark/fixtures/realistic/real-py-fastapi/manifest.json create mode 100644 apps/backend/tests/benchmark/fixtures/realistic/real-py-fastapi/src/service.py create mode 100644 apps/backend/tests/benchmark/fixtures/realistic/real-ts-service/manifest.json create mode 100644 apps/backend/tests/benchmark/fixtures/realistic/real-ts-service/src/server.ts create mode 100644 apps/backend/tests/benchmark/loader.py create mode 100644 apps/backend/tests/benchmark/paths.py create mode 100644 apps/backend/tests/benchmark/provenance.py create mode 100644 apps/backend/tests/benchmark/report.py create mode 100644 apps/backend/tests/benchmark/run.py create mode 100644 apps/backend/tests/benchmark/runner.py create mode 100644 apps/backend/tests/benchmark/schema.py create mode 100644 apps/backend/tests/benchmark/scorer.py create mode 100644 apps/backend/tests/benchmark/sourcefiles.py create mode 100644 apps/backend/tests/benchmark/test_determinism.py create mode 100644 apps/backend/tests/benchmark/test_loader.py create mode 100644 apps/backend/tests/benchmark/test_provenance.py create mode 100644 apps/backend/tests/benchmark/test_regression_failpath.py create mode 100644 apps/backend/tests/benchmark/test_runner.py create mode 100644 apps/backend/tests/benchmark/test_scorer.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 366fe82e..13559b0e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -127,6 +127,38 @@ jobs: PARTHA_TEST_REDIS_URL: redis://localhost:6379/0 run: python -m pytest + # 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" + + - name: Upload benchmark reports + if: always() + 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() + 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." + docker-compose: name: Docker Compose needs: repository-hygiene diff --git a/.gitignore b/.gitignore index c667222f..c0d193ea 100644 --- a/.gitignore +++ b/.gitignore @@ -136,6 +136,11 @@ 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 # ========================================== diff --git a/apps/backend/tests/benchmark/README.md b/apps/backend/tests/benchmark/README.md new file mode 100644 index 00000000..10fce437 --- /dev/null +++ b/apps/backend/tests/benchmark/README.md @@ -0,0 +1,136 @@ +# 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. + +> **Scope / honesty note.** The syntax-aware TypeScript and Python extractors and +> their published support matrices land in **#89 / #90**, which are **not merged +> into `dev`** yet. This change implements everything that depends only on the +> merged #86 evidence contract and #88 `SnapshotStore`: the fixture corpus, +> independently-derived expected facts, the scorer, provenance validity, and +> determinism. **Precision/recall against a live extractor is deferred**: it plugs +> into the [`adapter.py`](adapter.py) boundary when #89/#90 merge, and is reported +> as `deferred` — never scored against the golden facts themselves, which would +> manufacture a meaningless perfect score. This benchmark does **not** prove the +> extractors are good yet; it proves the *corpus* and the *measurement machinery* +> are correct and ready to hold them to account. + +## 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) | The construct taxonomy (**provisional**, benchmark-owned; reconcile with #89/#90). | +| [`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) | Real `SnapshotStore` + canonical-hash determinism. | +| [`adapter.py`](adapter.py) | The seam where the real #89/#90 extractors plug in. | +| [`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. **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). + +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, and the full normalized evidence span set) — a +right-named fact with the wrong line span does **not** match. + +``` +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 provisional Phase-0 bar from Issue +#94: + +| Metric | Threshold | Enforced today? | +| --- | --- | --- | +| precision | ≥ 0.95 | when an extractor is available (#89/#90) | +| recall | ≥ 0.90 | when an extractor is available (#89/#90) | +| 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 support matrix 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 support matrix — + 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 support-matrix construct(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 does and does not prove + +- **Does:** the golden corpus is internally valid, every golden citation resolves + to a real span in the stored revision, the real snapshot pipeline is + deterministic over that corpus, the scorer is correct, and the whole gate fails + a bad extractor, invalid citation, broken manifest, or non-deterministic build. +- **Does not (yet):** measure real extraction precision/recall — no extractor is + merged. It also does not imply product output is generally evidence-backed; the + production engine still emits file-level evidence only (see + [`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..68f9f807 --- /dev/null +++ b/apps/backend/tests/benchmark/__init__.py @@ -0,0 +1,25 @@ +"""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). + +Dependency status (see ``README.md`` and the PR description): the syntax-aware +TypeScript/Python extractors and their published support matrices land in +**#89/#90**, which are not merged into ``dev`` yet. Everything here that depends +only on the merged #86 evidence contract and #88 persistence is implemented and +enforced now (fixtures, expected facts, the scorer, provenance validity, and +determinism). Live precision/recall scoring plugs a real extractor into the +:mod:`benchmark.adapter` boundary once #89/#90 merge; until then that stage is +reported as ``deferred`` and is never green-washed into a fabricated perfect +score. +""" diff --git a/apps/backend/tests/benchmark/adapter.py b/apps/backend/tests/benchmark/adapter.py new file mode 100644 index 00000000..21fec683 --- /dev/null +++ b/apps/backend/tests/benchmark/adapter.py @@ -0,0 +1,57 @@ +"""The extraction adapter boundary — where the real extractors plug in. + +Precision/recall scoring needs *actual* facts from a real Repository +Intelligence extractor. Those extractors (and their published support matrices) +land in #89/#90, which are not merged into ``dev`` yet. This module defines the +seam so that: + +- today, the runner uses :class:`UnavailableExtractionAdapter`, and the scoring + stage is reported as ``deferred`` — never a fabricated perfect score; and +- when #89/#90 merge, a thin real adapter maps the extractor's ``ExtractionResult`` + onto :class:`~benchmark.facts.Fact` and the exact same scorer, provenance + validator, and thresholds enforce quality with no other change. + +The benchmark deliberately does **not** ship a second repository parser +(CONTRIBUTING §11.2, Issue #94): an adapter adapts the real extractor's output; +it does not re-implement extraction. +""" + +from __future__ import annotations + +from typing import Protocol, runtime_checkable + +from benchmark.facts import Fact +from benchmark.loader import LoadedFixture + + +@runtime_checkable +class ExtractionAdapter(Protocol): + """Produces the actual facts an extractor emits for a fixture revision.""" + + name: str + available: bool + + def extract(self, fixture: LoadedFixture) -> list[Fact]: + ... + + +class UnavailableExtractionAdapter: + """The default adapter while #89/#90 are unmerged: no extractor is wired. + + ``available`` is ``False``, so the runner marks precision/recall ``deferred`` + rather than scoring golden facts against themselves (which would manufacture + a meaningless perfect score the product's own rules forbid). + """ + + name = "unavailable (extractors land in #89/#90)" + available = False + + def extract(self, fixture: LoadedFixture) -> list[Fact]: # pragma: no cover - never called + raise RuntimeError( + "No Repository Intelligence extractor is available yet; precision/recall " + "scoring is deferred until #89/#90 merge their extractors and support matrices." + ) + + +def default_adapter() -> ExtractionAdapter: + return UnavailableExtractionAdapter() 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..fad5d667 --- /dev/null +++ b/apps/backend/tests/benchmark/config/benchmark_support_matrix.json @@ -0,0 +1,41 @@ +{ + "schemaVersion": "ri-benchmark-support-matrix.v1", + "note": "PROVISIONAL, benchmark-owned construct taxonomy. The AUTHORITATIVE TypeScript and Python support matrices are published by issues #89 and #90, which are not merged into dev yet. Per Issue #94 and RFC-0001 §16, writing expected facts (and this taxonomy) down first is a deliberate test of whether the eventual support matrix is coherent. Reconcile these ids and the supported/unsupported split against the real #89/#90 matrices when they merge; treat any mismatch as a benchmark finding, not a silent edit.", + "constructs": { + "py.module": {"language": "python", "supported": true, "description": "A Python module (file) node."}, + "py.function.def": {"language": "python", "supported": true, "description": "A top-level function definition."}, + "py.async_function.def": {"language": "python", "supported": true, "description": "A top-level async function definition."}, + "py.class.def": {"language": "python", "supported": true, "description": "A class definition."}, + "py.method.def": {"language": "python", "supported": true, "description": "A method defined inside a class."}, + "py.nested_function": {"language": "python", "supported": true, "description": "A function nested inside another function."}, + "py.duplicate_symbol": {"language": "python", "supported": true, "description": "A redefined name resolved with a discriminator (RI-KEY-DUP-SYMBOL, informational)."}, + "py.import": {"language": "python", "supported": true, "description": "A plain 'import module' statement."}, + "py.import_alias": {"language": "python", "supported": true, "description": "An 'import module as alias' statement."}, + "py.from_import": {"language": "python", "supported": true, "description": "A 'from module import name' statement."}, + "py.decorator": {"language": "python", "supported": true, "description": "A decorator applied to a function or class."}, + "py.fastapi_route": {"language": "python", "supported": true, "description": "A FastAPI decorator-based route declaration."}, + "py.dynamic_import": {"language": "python", "supported": false, "expectedDiagnostic": "RI-EXT-UNSUPPORTED", "description": "importlib.import_module / __import__ dynamic import."}, + "py.monkeypatch": {"language": "python", "supported": false, "expectedDiagnostic": "RI-EXT-UNSUPPORTED", "description": "Runtime attribute reassignment (monkeypatching)."}, + "py.reflection": {"language": "python", "supported": false, "expectedDiagnostic": "RI-EXT-UNSUPPORTED", "description": "getattr/setattr reflection over dynamic names."}, + "py.star_import": {"language": "python", "supported": false, "expectedDiagnostic": "RI-EXT-UNSUPPORTED", "description": "'from module import *' wildcard import."}, + "py.syntax_error": {"language": "python", "supported": false, "expectedDiagnostic": "RI-SRC-MALFORMED", "description": "A file that fails to parse."}, + "ts.module": {"language": "typescript", "supported": true, "description": "A TypeScript module (file) node."}, + "ts.function": {"language": "typescript", "supported": true, "description": "A function declaration."}, + "ts.async_function": {"language": "typescript", "supported": true, "description": "An async function declaration."}, + "ts.class": {"language": "typescript", "supported": true, "description": "A class declaration."}, + "ts.method": {"language": "typescript", "supported": true, "description": "A method defined inside a class."}, + "ts.import": {"language": "typescript", "supported": true, "description": "An 'import ... from' statement."}, + "ts.import_alias": {"language": "typescript", "supported": true, "description": "An aliased import ('import { a as b }')."}, + "ts.export": {"language": "typescript", "supported": true, "description": "An 'export' declaration."}, + "ts.reexport": {"language": "typescript", "supported": true, "description": "A re-export ('export { x } from ...')."}, + "ts.route": {"language": "typescript", "supported": true, "description": "A router-style route declaration covered by the matrix."}, + "ts.dynamic_import": {"language": "typescript", "supported": false, "expectedDiagnostic": "RI-EXT-UNSUPPORTED", "description": "Dynamic import() expression."}, + "ts.namespace": {"language": "typescript", "supported": false, "expectedDiagnostic": "RI-EXT-UNSUPPORTED", "description": "A TypeScript 'namespace' declaration."}, + "ts.syntax_error": {"language": "typescript", "supported": false, "expectedDiagnostic": "RI-SRC-MALFORMED", "description": "A file that fails to parse."}, + "src.empty_file": {"language": "mixed", "supported": true, "description": "A zero-byte text file: one logical empty line, whole-file evidence 1..1."}, + "src.trailing_newline": {"language": "mixed", "supported": true, "description": "A file ending in a newline: final empty logical line counted."}, + "src.binary_file": {"language": "mixed", "supported": false, "expectedDiagnostic": "RI-SRC-BINARY", "description": "A non-empty file containing a NUL byte; excluded from line extraction."}, + "src.large_file": {"language": "mixed", "supported": false, "expectedDiagnostic": "RI-LIMIT-SKIP", "description": "A file above the resource budget; skipped by a bounded limit."}, + "src.path_escape": {"language": "mixed", "supported": false, "expectedDiagnostic": "RI-SEC-PATH-ESCAPE", "description": "A path that escapes the repository root; never produces a node."} + } +} diff --git a/apps/backend/tests/benchmark/config/thresholds.json b/apps/backend/tests/benchmark/config/thresholds.json new file mode 100644 index 00000000..01bb06dc --- /dev/null +++ b/apps/backend/tests/benchmark/config/thresholds.json @@ -0,0 +1,8 @@ +{ + "schemaVersion": "ri-benchmark-thresholds.v1", + "precision": "0.95", + "recall": "0.90", + "provenanceValidity": "1.00", + "determinism": "1.00", + "note": "Provisional Phase-0 acceptance bar from Issue #94. Values are exact fractions. Do NOT lower any threshold without documenting the reason on Issue #94 and obtaining maintainer agreement. precision/recall are enforced against a live extractor once #89/#90 merge; provenanceValidity and determinism are enforced now." +} diff --git a/apps/backend/tests/benchmark/determinism.py b/apps/backend/tests/benchmark/determinism.py new file mode 100644 index 00000000..453415be --- /dev/null +++ b/apps/backend/tests/benchmark/determinism.py @@ -0,0 +1,175 @@ +"""Snapshot determinism over the real SnapshotStore and canonical graph hash. + +For each fixture flagged ``deterministic``, this builds the fixture's observed +**node** graph twice through the *real* :class:`app.intelligence.snapshot_store.SnapshotStore` +— different owner, different repository id, reversed node and evidence insertion +order — and requires the two sealed ``canonical_graph_hash`` values to match. It +also recomputes the pure :func:`app.intelligence.canonical.compute_canonical_graph_hash` +over the same nodes in shuffled order as an independent ordering-independence +check. Both use the product's own hash; the benchmark never substitutes one of +its own (Issue #94 "Do not replace the canonical hash with a benchmark-specific +hash"). + +Edge / observation / assertion determinism is already proven by the #88 +persistence suite; the benchmark's contribution is proving the real pipeline is +deterministic over the golden corpus's node graphs, and reporting *both* hashes +when it is not. +""" + +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.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 +from benchmark.sourcefiles import logical_line_count_of_bytes + +SCHEMA_VERSION = canonical.SCHEMA_VERSION + + +@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 + + +def _node_facts(fixture: LoadedFixture) -> list: + return [expected for expected in fixture.expected if expected.group == "nodes"] + + +def _evidence_for(fixture: LoadedFixture, span) -> Evidence: + data = (fixture.directory / span.path).read_bytes() + return Evidence( + path=span.path, + start_line=span.start_line, + end_line=span.end_line, + extractor=span.extractor, + extractor_version=span.extractor_version, + logical_line_count=logical_line_count_of_bytes(data), + granularity=span.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_node_graph(session: Session, fixture: LoadedFixture, *, 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), + ) + nodes = _node_facts(fixture) + for expected in reversed(nodes) if reverse else nodes: + fact = expected.fact + evidence = [_evidence_for(fixture, span) for span in fact.evidence] + if reverse: + evidence = list(reversed(evidence)) + store.add_node( + snapshot, + node_kind=fact.kind, + stable_key=fact.subject, + name=expected.raw.get("name"), + language=expected.raw.get("language"), + evidence=evidence, + ) + return store.seal(snapshot).canonical_graph_hash + + +def _pure_node_hash(fixture: LoadedFixture, *, shuffle: bool) -> str: + records = [] + for expected in _node_facts(fixture): + fact = expected.fact + evidence = [ + { + "path": span.path, + "start_line": span.start_line, + "end_line": span.end_line, + "granularity": span.granularity, + "extractor": span.extractor, + "extractor_version": span.extractor_version, + } + for span in fact.evidence + ] + record = {"node_kind": fact.kind, "stable_key": fact.subject, "truth_class": "observed", "evidence": evidence} + if expected.raw.get("name") is not None: + record["name"] = expected.raw["name"] + if expected.raw.get("language") is not None: + record["language"] = expected.raw["language"] + records.append(record) + if shuffle: + records = list(reversed(records)) + for record in records: + record["evidence"] = list(reversed(record["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({}), + nodes=records, + edges=[], + assertions=[], + observations=[], + diagnostics=[], + schema_version=SCHEMA_VERSION, + ) + + +def check_fixture(fixture: LoadedFixture, db_path: Path) -> DeterminismResult: + """Seal ``fixture``'s node graph twice and confirm the canonical hash is stable.""" + + 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_node_graph(session, fixture, reverse=False) + with factory() as session: + sealed_b = _seal_node_graph(session, fixture, 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_node_hash(fixture, shuffle=False), + pure_hash_b=_pure_node_hash(fixture, shuffle=True), + ) diff --git a/apps/backend/tests/benchmark/facts.py b/apps/backend/tests/benchmark/facts.py new file mode 100644 index 00000000..33564a1f --- /dev/null +++ b/apps/backend/tests/benchmark/facts.py @@ -0,0 +1,111 @@ +"""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, and — for +facts that carry evidence — the full, normalized evidence span set (path + +one-based inclusive lines + granularity + extractor). A fact with the right name +but the wrong line span therefore does **not** match, which 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 + referent: str = "" # observation referent_text + truth_class: str = "" # observed | resolved | inferred + value: str = "" # canonical JCS of an assertion value, when relevant + severity: str = "" # diagnostic severity + 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.referent, + 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..df627aee --- /dev/null +++ b/apps/backend/tests/benchmark/fixtures/adversarial/adv-py-blindspots/manifest.json @@ -0,0 +1,36 @@ +{ + "schemaVersion": "ri-benchmark.v1", + "fixtureId": "adv-py-blindspots", + "fixtureClass": "adversarial", + "language": "python", + "title": "Adversarial Python — declared blind spots", + "description": "Star import, dynamic import, monkeypatching, and reflection. Each is outside the support matrix and MUST produce an RI-EXT-UNSUPPORTED diagnostic rather than an invented fact.", + "sourceRoot": ".", + "revisionIdentity": "upload-sha256", + "producerVersionSet": ["python-ast@1.0.0", "repository-inventory@1.0.0"], + "constructsCovered": ["py.module", "py.star_import", "py.dynamic_import", "py.monkeypatch", "py.reflection"], + "deterministic": false, + "expected": { + "nodes": [ + {"nodeKind": "repository", "stableKey": "repo:root", "name": "repository", + "evidence": [{"path": "src/dynamic.py", "startLine": 1, "endLine": 1, "granularity": "span", "extractor": "repository-inventory", "extractorVersion": "1.0.0"}]}, + {"nodeKind": "file", "stableKey": "file:src/dynamic.py", "name": "dynamic.py", "language": "python", "constructs": ["py.module"], + "evidence": [{"path": "src/dynamic.py", "startLine": 1, "endLine": 7, "granularity": "file", "extractor": "repository-inventory", "extractorVersion": "1.0.0"}]} + ], + "edges": [], "observations": [], "assertions": [], + "diagnostics": [ + {"code": "RI-EXT-UNSUPPORTED", "category": "unsupported construct", "severity": "info", + "message": "Star import is outside the Python support matrix.", "producer": "python-ast@1.0.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 is outside the Python support matrix.", "producer": "python-ast@1.0.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": "Monkeypatching (setattr) is outside the Python support matrix.", "producer": "python-ast@1.0.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 (getattr) is outside the Python support matrix.", "producer": "python-ast@1.0.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..81b6389d --- /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") +setattr(module, "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..7d3189e2 --- /dev/null +++ b/apps/backend/tests/benchmark/fixtures/adversarial/adv-py-malformed/manifest.json @@ -0,0 +1,21 @@ +{ + "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 an RI-SRC-MALFORMED diagnostic and no line-addressed facts (RFC-0001 §6.2, §8). Exercises py.syntax_error.", + "sourceRoot": ".", + "revisionIdentity": "upload-sha256", + "producerVersionSet": ["python-ast@1.0.0"], + "constructsCovered": ["py.syntax_error"], + "deterministic": false, + "expected": { + "nodes": [], "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.0.0", + "path": "src/broken.py", "span": {"startLine": 1, "endLine": 1}, "subject": "file:src/broken.py", "constructs": ["py.syntax_error"]} + ] + } +} 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-source-edgecases/blob.bin b/apps/backend/tests/benchmark/fixtures/adversarial/adv-source-edgecases/blob.bin new file mode 100644 index 0000000000000000000000000000000000000000..4adfaf16e070ea5427c7dab66353cd1afca0fba6 GIT binary patch literal 16 XcmWIWW?)Xr%u6h))J>`^Ni7BdD8L1H literal 0 HcmV?d00001 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..3f88d36a --- /dev/null +++ b/apps/backend/tests/benchmark/fixtures/adversarial/adv-source-edgecases/manifest.json @@ -0,0 +1,27 @@ +{ + "schemaVersion": "ri-benchmark.v1", + "fixtureId": "adv-source-edgecases", + "fixtureClass": "adversarial", + "language": "mixed", + "title": "Adversarial — binary, oversized, and path-escape source", + "description": "A committed small binary file (NUL byte) plus manifest-declared oversized and path-escaping cases represented as diagnostics — no huge file or unsafe path is committed. Exercises src.binary_file, src.large_file, src.path_escape.", + "sourceRoot": ".", + "revisionIdentity": "upload-sha256", + "producerVersionSet": ["repository-inventory@1.0.0"], + "constructsCovered": ["src.binary_file", "src.large_file", "src.path_escape"], + "deterministic": false, + "expected": { + "nodes": [], "edges": [], "observations": [], "assertions": [], + "diagnostics": [ + {"code": "RI-SRC-BINARY", "category": "binary source", "severity": "info", + "message": "blob.bin contains a NUL byte and is excluded from line extraction.", "producer": "repository-inventory@1.0.0", + "path": "blob.bin", "subject": "file:blob.bin", "constructs": ["src.binary_file"]}, + {"code": "RI-LIMIT-SKIP", "category": "resource-limit skip", "severity": "info", + "message": "src/huge.generated.js exceeds the file-size budget and was skipped.", "producer": "repository-inventory@1.0.0", + "details": {"budgetBytes": 524288, "reportedBytes": 1048576}, "constructs": ["src.large_file"]}, + {"code": "RI-SEC-PATH-ESCAPE", "category": "path escape", "severity": "warning", + "message": "An entry escaped the repository root and produced no node.", "producer": "repository-inventory@1.0.0", + "details": {"attemptedPath": "../../etc/passwd"}, "constructs": ["src.path_escape"]} + ] + } +} 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..ea3e8788 --- /dev/null +++ b/apps/backend/tests/benchmark/fixtures/adversarial/adv-ts-blindspots/manifest.json @@ -0,0 +1,30 @@ +{ + "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.0.0", "typescript-ast@1.0.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.0.0"}]}, + {"nodeKind": "file", "stableKey": "file:src/dynamic.ts", "name": "dynamic.ts", "language": "typescript", "constructs": ["ts.module"], + "evidence": [{"path": "src/dynamic.ts", "startLine": 1, "endLine": 8, "granularity": "file", "extractor": "repository-inventory", "extractorVersion": "1.0.0"}]} + ], + "edges": [], "observations": [], "assertions": [], + "diagnostics": [ + {"code": "RI-EXT-UNSUPPORTED", "category": "unsupported construct", "severity": "info", + "message": "TypeScript namespace is outside the support matrix.", "producer": "typescript-ast@1.0.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.0.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..fc728be8 --- /dev/null +++ b/apps/backend/tests/benchmark/fixtures/adversarial/adv-ts-malformed/manifest.json @@ -0,0 +1,21 @@ +{ + "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 an RI-SRC-MALFORMED diagnostic and no line-addressed facts. Exercises ts.syntax_error.", + "sourceRoot": ".", + "revisionIdentity": "upload-sha256", + "producerVersionSet": ["typescript-ast@1.0.0"], + "constructsCovered": ["ts.syntax_error"], + "deterministic": false, + "expected": { + "nodes": [], "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.0.0", + "path": "src/broken.ts", "span": {"startLine": 1, "endLine": 1}, "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/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..37841df9 --- /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.0.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.0.0"}]}, + {"nodeKind": "file", "stableKey": "file:empty.txt", "name": "empty.txt", "constructs": ["src.empty_file"], + "evidence": [{"path": "empty.txt", "startLine": 1, "endLine": 1, "granularity": "file", "extractor": "repository-inventory", "extractorVersion": "1.0.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..8d6b2b46 --- /dev/null +++ b/apps/backend/tests/benchmark/fixtures/minimal/min-py-async/manifest.json @@ -0,0 +1,24 @@ +{ + "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.0.0", "repository-inventory@1.0.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.0.0"}]}, + {"nodeKind": "file", "stableKey": "file:src/tasks.py", "name": "tasks.py", "language": "python", "constructs": ["py.module"], + "evidence": [{"path": "src/tasks.py", "startLine": 1, "endLine": 3, "granularity": "file", "extractor": "repository-inventory", "extractorVersion": "1.0.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.0.0"}]} + ], + "edges": [], "observations": [], "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..324d8450 --- /dev/null +++ b/apps/backend/tests/benchmark/fixtures/minimal/min-py-class/manifest.json @@ -0,0 +1,28 @@ +{ + "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.0.0", "repository-inventory@1.0.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.0.0"}]}, + {"nodeKind": "file", "stableKey": "file:src/models.py", "name": "models.py", "language": "python", "constructs": ["py.module"], + "evidence": [{"path": "src/models.py", "startLine": 1, "endLine": 7, "granularity": "file", "extractor": "repository-inventory", "extractorVersion": "1.0.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.0.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.0.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.0.0"}]} + ], + "edges": [], "observations": [], "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..d904da2b --- /dev/null +++ b/apps/backend/tests/benchmark/fixtures/minimal/min-py-decorator-route/manifest.json @@ -0,0 +1,31 @@ +{ + "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.0.0", "repository-inventory@1.0.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.0.0"}]}, + {"nodeKind": "file", "stableKey": "file:src/api.py", "name": "api.py", "language": "python", "constructs": ["py.module"], + "evidence": [{"path": "src/api.py", "startLine": 1, "endLine": 14, "granularity": "file", "extractor": "repository-inventory", "extractorVersion": "1.0.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.0.0"}]}, + {"nodeKind": "symbol", "stableKey": "src/api.py::health", "name": "health", "language": "python", "constructs": ["py.function.def", "py.decorator"], + "evidence": [{"path": "src/api.py", "startLine": 10, "endLine": 13, "granularity": "span", "extractor": "python-ast", "extractorVersion": "1.0.0"}]} + ], + "edges": [], + "observations": [ + {"observedKind": "routes", "subjectKind": "file", "subjectKey": "file:src/api.py", "referentText": "GET /health", "constructs": ["py.fastapi_route"], + "evidence": {"path": "src/api.py", "startLine": 11, "endLine": 11, "granularity": "span", "extractor": "python-ast", "extractorVersion": "1.0.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..f3b8d996 --- /dev/null +++ b/apps/backend/tests/benchmark/fixtures/minimal/min-py-function/manifest.json @@ -0,0 +1,67 @@ +{ + "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.0.0", "repository-inventory@1.0.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.0.0"} + ] + }, + { + "nodeKind": "file", + "stableKey": "file:README.md", + "name": "README.md", + "evidence": [ + {"path": "README.md", "startLine": 1, "endLine": 4, "granularity": "file", "extractor": "repository-inventory", "extractorVersion": "1.0.0"} + ] + }, + { + "nodeKind": "file", + "stableKey": "file:src/greeting.py", + "name": "greeting.py", + "language": "python", + "constructs": ["py.module"], + "evidence": [ + {"path": "src/greeting.py", "startLine": 1, "endLine": 7, "granularity": "file", "extractor": "repository-inventory", "extractorVersion": "1.0.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.0.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.0.0"} + ] + } + ], + "edges": [], + "observations": [], + "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..1d4bad16 --- /dev/null +++ b/apps/backend/tests/benchmark/fixtures/minimal/min-py-imports/manifest.json @@ -0,0 +1,31 @@ +{ + "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.0.0", "repository-inventory@1.0.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.0.0"}]}, + {"nodeKind": "file", "stableKey": "file:src/wiring.py", "name": "wiring.py", "language": "python", "constructs": ["py.module"], + "evidence": [{"path": "src/wiring.py", "startLine": 1, "endLine": 4, "granularity": "file", "extractor": "repository-inventory", "extractorVersion": "1.0.0"}]} + ], + "edges": [], + "observations": [ + {"observedKind": "imports", "subjectKind": "file", "subjectKey": "file:src/wiring.py", "referentText": "os", "constructs": ["py.import"], + "evidence": {"path": "src/wiring.py", "startLine": 1, "endLine": 1, "granularity": "span", "extractor": "python-ast", "extractorVersion": "1.0.0"}}, + {"observedKind": "imports", "subjectKind": "file", "subjectKey": "file:src/wiring.py", "referentText": "json", "constructs": ["py.import_alias"], + "evidence": {"path": "src/wiring.py", "startLine": 2, "endLine": 2, "granularity": "span", "extractor": "python-ast", "extractorVersion": "1.0.0"}}, + {"observedKind": "imports", "subjectKind": "file", "subjectKey": "file:src/wiring.py", "referentText": "typing.List", "constructs": ["py.from_import"], + "evidence": {"path": "src/wiring.py", "startLine": 3, "endLine": 3, "granularity": "span", "extractor": "python-ast", "extractorVersion": "1.0.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..80183eca --- /dev/null +++ b/apps/backend/tests/benchmark/fixtures/minimal/min-py-nested-dup/manifest.json @@ -0,0 +1,33 @@ +{ + "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.0.0", "repository-inventory@1.0.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.0.0"}]}, + {"nodeKind": "file", "stableKey": "file:src/util.py", "name": "util.py", "language": "python", "constructs": ["py.module"], + "evidence": [{"path": "src/util.py", "startLine": 1, "endLine": 9, "granularity": "file", "extractor": "repository-inventory", "extractorVersion": "1.0.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.0.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.0.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.0.0"}]} + ], + "edges": [], "observations": [], "assertions": [], + "diagnostics": [ + {"code": "RI-KEY-DUP-SYMBOL", "category": "duplicate symbol", "severity": "info", + "message": "Redefined name 'outer' resolved with discriminator #2.", "producer": "python-ast@1.0.0", + "path": "src/util.py", "span": {"startLine": 7, "endLine": 8}, "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..eaceaac5 --- /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.0.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.0.0"}]}, + {"nodeKind": "file", "stableKey": "file:data.txt", "name": "data.txt", "constructs": ["src.trailing_newline"], + "evidence": [{"path": "data.txt", "startLine": 1, "endLine": 3, "granularity": "file", "extractor": "repository-inventory", "extractorVersion": "1.0.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..41884d2b --- /dev/null +++ b/apps/backend/tests/benchmark/fixtures/minimal/min-ts-class/manifest.json @@ -0,0 +1,28 @@ +{ + "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.0.0", "typescript-ast@1.0.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.0.0"}]}, + {"nodeKind": "file", "stableKey": "file:src/service.ts", "name": "service.ts", "language": "typescript", "constructs": ["ts.module"], + "evidence": [{"path": "src/service.ts", "startLine": 1, "endLine": 10, "granularity": "file", "extractor": "repository-inventory", "extractorVersion": "1.0.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.0.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.0.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.0.0"}]} + ], + "edges": [], "observations": [], "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..c9c7ad64 --- /dev/null +++ b/apps/backend/tests/benchmark/fixtures/minimal/min-ts-functions/manifest.json @@ -0,0 +1,26 @@ +{ + "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.0.0", "typescript-ast@1.0.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.0.0"}]}, + {"nodeKind": "file", "stableKey": "file:src/util.ts", "name": "util.ts", "language": "typescript", "constructs": ["ts.module"], + "evidence": [{"path": "src/util.ts", "startLine": 1, "endLine": 8, "granularity": "file", "extractor": "repository-inventory", "extractorVersion": "1.0.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.0.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.0.0"}]} + ], + "edges": [], "observations": [], "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..f7f4736e --- /dev/null +++ b/apps/backend/tests/benchmark/fixtures/minimal/min-ts-imports-exports/manifest.json @@ -0,0 +1,33 @@ +{ + "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.0.0", "typescript-ast@1.0.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.0.0"}]}, + {"nodeKind": "file", "stableKey": "file:src/index.ts", "name": "index.ts", "language": "typescript", "constructs": ["ts.module"], + "evidence": [{"path": "src/index.ts", "startLine": 1, "endLine": 7, "granularity": "file", "extractor": "repository-inventory", "extractorVersion": "1.0.0"}]} + ], + "edges": [], + "observations": [ + {"observedKind": "imports", "subjectKind": "file", "subjectKey": "file:src/index.ts", "referentText": "fs.readFile", "constructs": ["ts.import"], + "evidence": {"path": "src/index.ts", "startLine": 1, "endLine": 1, "granularity": "span", "extractor": "typescript-ast", "extractorVersion": "1.0.0"}}, + {"observedKind": "imports", "subjectKind": "file", "subjectKey": "file:src/index.ts", "referentText": "path.join", "constructs": ["ts.import_alias"], + "evidence": {"path": "src/index.ts", "startLine": 2, "endLine": 2, "granularity": "span", "extractor": "typescript-ast", "extractorVersion": "1.0.0"}}, + {"observedKind": "exports", "subjectKind": "file", "subjectKey": "file:src/index.ts", "referentText": "VERSION", "constructs": ["ts.export"], + "evidence": {"path": "src/index.ts", "startLine": 4, "endLine": 4, "granularity": "span", "extractor": "typescript-ast", "extractorVersion": "1.0.0"}}, + {"observedKind": "exports", "subjectKind": "file", "subjectKey": "file:src/index.ts", "referentText": "fs.readFile", "constructs": ["ts.reexport"], + "evidence": {"path": "src/index.ts", "startLine": 6, "endLine": 6, "granularity": "span", "extractor": "typescript-ast", "extractorVersion": "1.0.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..e0e2a75a --- /dev/null +++ b/apps/backend/tests/benchmark/fixtures/minimal/min-ts-route/manifest.json @@ -0,0 +1,27 @@ +{ + "schemaVersion": "ri-benchmark.v1", + "fixtureId": "min-ts-route", + "fixtureClass": "minimal", + "language": "typescript", + "title": "Minimal TypeScript — route declaration", + "description": "An Express-style route registration observed as a route occurrence. Exercises ts.route.", + "sourceRoot": ".", + "revisionIdentity": "upload-sha256", + "producerVersionSet": ["repository-inventory@1.0.0", "typescript-ast@1.0.0"], + "constructsCovered": ["ts.module", "ts.route"], + "deterministic": false, + "expected": { + "nodes": [ + {"nodeKind": "repository", "stableKey": "repo:root", "name": "repository", + "evidence": [{"path": "src/router.ts", "startLine": 1, "endLine": 1, "granularity": "span", "extractor": "repository-inventory", "extractorVersion": "1.0.0"}]}, + {"nodeKind": "file", "stableKey": "file:src/router.ts", "name": "router.ts", "language": "typescript", "constructs": ["ts.module"], + "evidence": [{"path": "src/router.ts", "startLine": 1, "endLine": 8, "granularity": "file", "extractor": "repository-inventory", "extractorVersion": "1.0.0"}]} + ], + "edges": [], + "observations": [ + {"observedKind": "routes", "subjectKind": "file", "subjectKey": "file:src/router.ts", "referentText": "GET /status", "constructs": ["ts.route"], + "evidence": {"path": "src/router.ts", "startLine": 5, "endLine": 5, "granularity": "span", "extractor": "typescript-ast", "extractorVersion": "1.0.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..a5b1aad1 --- /dev/null +++ b/apps/backend/tests/benchmark/fixtures/minimal/min-ts-route/src/router.ts @@ -0,0 +1,7 @@ +import { Router } from "express"; + +const router = Router(); + +router.get("/status", (req, res) => { + res.send("ok"); +}); 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..19d185f4 --- /dev/null +++ b/apps/backend/tests/benchmark/fixtures/realistic/real-py-fastapi/manifest.json @@ -0,0 +1,48 @@ +{ + "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.0.0", "relationship-resolver@1.0.0", "repository-inventory@1.0.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.0.0"}]}, + {"nodeKind": "file", "stableKey": "file:src/service.py", "name": "service.py", "language": "python", "constructs": ["py.module"], + "evidence": [{"path": "src/service.py", "startLine": 1, "endLine": 22, "granularity": "file", "extractor": "repository-inventory", "extractorVersion": "1.0.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.0.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.0.0"}]}, + {"nodeKind": "symbol", "stableKey": "src/service.py::read_user", "name": "read_user", "language": "python", "constructs": ["py.function.def", "py.decorator"], + "evidence": [{"path": "src/service.py", "startLine": 14, "endLine": 16, "granularity": "span", "extractor": "python-ast", "extractorVersion": "1.0.0"}]}, + {"nodeKind": "symbol", "stableKey": "src/service.py::create_user", "name": "create_user", "language": "python", "constructs": ["py.function.def", "py.decorator"], + "evidence": [{"path": "src/service.py", "startLine": 19, "endLine": 21, "granularity": "span", "extractor": "python-ast", "extractorVersion": "1.0.0"}]} + ], + "edges": [ + {"subjectKind": "repository", "subjectKey": "repo:root", "predicate": "contains", "objectKind": "file", "objectKey": "file:src/service.py", + "producer": "relationship-resolver", "producerVersion": "1.0.0", + "evidence": [{"path": "src/service.py", "startLine": 1, "endLine": 22, "granularity": "file", "extractor": "relationship-resolver", "extractorVersion": "1.0.0"}]} + ], + "observations": [ + {"observedKind": "imports", "subjectKind": "file", "subjectKey": "file:src/service.py", "referentText": "fastapi.FastAPI", "constructs": ["py.from_import"], + "evidence": {"path": "src/service.py", "startLine": 1, "endLine": 1, "granularity": "span", "extractor": "python-ast", "extractorVersion": "1.0.0"}}, + {"observedKind": "routes", "subjectKind": "file", "subjectKey": "file:src/service.py", "referentText": "GET /users/{user_id}", "constructs": ["py.fastapi_route"], + "evidence": {"path": "src/service.py", "startLine": 14, "endLine": 14, "granularity": "span", "extractor": "python-ast", "extractorVersion": "1.0.0"}}, + {"observedKind": "routes", "subjectKind": "file", "subjectKey": "file:src/service.py", "referentText": "POST /users", "constructs": ["py.fastapi_route"], + "evidence": {"path": "src/service.py", "startLine": 19, "endLine": 19, "granularity": "span", "extractor": "python-ast", "extractorVersion": "1.0.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-ts-service/manifest.json b/apps/backend/tests/benchmark/fixtures/realistic/real-ts-service/manifest.json new file mode 100644 index 00000000..51c08361 --- /dev/null +++ b/apps/backend/tests/benchmark/fixtures/realistic/real-ts-service/manifest.json @@ -0,0 +1,33 @@ +{ + "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.0.0", "typescript-ast@1.0.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.0.0"}]}, + {"nodeKind": "file", "stableKey": "file:src/server.ts", "name": "server.ts", "language": "typescript", "constructs": ["ts.module"], + "evidence": [{"path": "src/server.ts", "startLine": 1, "endLine": 8, "granularity": "file", "extractor": "repository-inventory", "extractorVersion": "1.0.0"}]}, + {"nodeKind": "symbol", "stableKey": "src/server.ts::start", "name": "start", "language": "typescript", "constructs": ["ts.function"], + "evidence": [{"path": "src/server.ts", "startLine": 3, "endLine": 5, "granularity": "span", "extractor": "typescript-ast", "extractorVersion": "1.0.0"}]} + ], + "edges": [], + "observations": [ + {"observedKind": "imports", "subjectKind": "file", "subjectKey": "file:src/server.ts", "referentText": "http.createServer", "constructs": ["ts.import"], + "evidence": {"path": "src/server.ts", "startLine": 1, "endLine": 1, "granularity": "span", "extractor": "typescript-ast", "extractorVersion": "1.0.0"}}, + {"observedKind": "exports", "subjectKind": "file", "subjectKey": "file:src/server.ts", "referentText": "start", "constructs": ["ts.export"], + "evidence": {"path": "src/server.ts", "startLine": 3, "endLine": 3, "granularity": "span", "extractor": "typescript-ast", "extractorVersion": "1.0.0"}}, + {"observedKind": "exports", "subjectKind": "file", "subjectKey": "file:src/server.ts", "referentText": "server", "constructs": ["ts.export"], + "evidence": {"path": "src/server.ts", "startLine": 7, "endLine": 7, "granularity": "span", "extractor": "typescript-ast", "extractorVersion": "1.0.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..601c3894 --- /dev/null +++ b/apps/backend/tests/benchmark/loader.py @@ -0,0 +1,435 @@ +"""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 support-matrix 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, field +from fractions import Fraction +from pathlib import Path +from typing import Any + +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, + read_bytes, +) + + +class ManifestError(ValueError): + """A fixture, support matrix, or thresholds file failed strict validation.""" + + +# --------------------------------------------------------------------------- +# Support matrix and thresholds +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class ConstructSpec: + construct_id: str + language: str + supported: bool + description: str + expected_diagnostic: str | None + + +@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 support-matrix 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") + for construct_id, spec in sorted(raw.items()): + language = spec.get("language") + if language not in schema.LANGUAGES: + raise ManifestError(f"{path}: construct {construct_id!r} has unsupported language {language!r}") + supported = spec.get("supported") + if not isinstance(supported, bool): + raise ManifestError(f"{path}: construct {construct_id!r} 'supported' must be boolean") + expected_diagnostic = spec.get("expectedDiagnostic") + if not supported and expected_diagnostic not in schema.DIAGNOSTIC_CODES: + raise ManifestError( + f"{path}: unsupported construct {construct_id!r} must declare a valid 'expectedDiagnostic'" + ) + if expected_diagnostic is not None and expected_diagnostic not in schema.DIAGNOSTIC_CODES: + raise ManifestError(f"{path}: construct {construct_id!r} has unknown diagnostic {expected_diagnostic!r}") + constructs[construct_id] = ConstructSpec( + construct_id=construct_id, + language=language, + supported=supported, + description=str(spec.get("description", "")), + expected_diagnostic=expected_diagnostic, + ) + 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] + + +@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, ...] + + def source_files(self) -> dict[str, bytes]: + """Every stored byte of the synthetic repository (everything but the manifest).""" + + files: dict[str, bytes] = {} + for path in sorted(self.directory.rglob("*")): + if not path.is_file() or path.name == "manifest.json": + continue + relative = path.relative_to(self.directory).as_posix() + files[relative] = path.read_bytes() + return 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, + truth_class="observed", + 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", "")), + 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( + {"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, + producer=producer, + value=location, + ) + raise ManifestError(f"{where}: unknown fact group {group!r}") + + +def _validate_evidence_against_source( + fixture_dir: Path, span: EvidenceSpan, *, where: str +) -> None: + """Enforce RFC-0001 §6.2: the cited file exists, decodes, and the span is in range.""" + + source_path = fixture_dir / span.path + _require(source_path.is_file(), f"{where}: evidence cites missing source file {span.path!r}") + data = read_bytes(source_path) + _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_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) + + constructs_covered = tuple(data.get("constructsCovered", [])) + for construct_id in constructs_covered: + _require(construct_id in support_matrix, f"{where0}: undeclared support-matrix construct {construct_id!r}") + _require( + support_matrix.constructs[construct_id].language in (language, "mixed") or language == "mixed", + 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(directory, 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), + ) + + +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..9226f30f --- /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. That makes it (a) a real gate on the golden corpus now and (b) the exact +validator that runs over a live extractor's citations once #89/#90 merge. + +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..82f6eef1 --- /dev/null +++ b/apps/backend/tests/benchmark/report.py @@ -0,0 +1,236 @@ +"""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 + + +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 to_json_dict(report: BenchmarkReport) -> dict[str, object]: + scoring: dict[str, object] + if report.scoring.deferred: + scoring = { + "status": "deferred", + "reason": "No extractor available; precision/recall scoring lands with #89/#90.", + } + else: + assert report.scoring.report is not None + scoring = { + "status": "scored", + "overall": _counts_dict(report.scoring.report.overall), + "byLanguage": {k: _counts_dict(v) for k, v in sorted(report.scoring.report.by_language.items())}, + "byClass": {k: _counts_dict(v) for k, v in sorted(report.scoring.report.by_class.items())}, + "actualProvenanceValidity": ( + _ratio(report.scoring.actual_provenance.validity) if report.scoring.actual_provenance 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 + ], + "provenance": { + "total": report.provenance.total, + "valid": report.provenance.valid_count, + "validity": _ratio(report.provenance.validity), + "invalid": [ + {"fixtureId": c.fixture_id, "subject": c.subject, "path": c.path, + "startLine": c.start_line, "endLine": c.end_line, "reason": c.reason} + for c in report.provenance.invalid + ], + }, + "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}, + "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 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), "when extractor available (#89/#90)"], + ["recall", _ratio(report.thresholds.recall), "when extractor available (#89/#90)"], + ["provenance validity", _ratio(report.thresholds.provenance_validity), "yes"], + ["determinism", _ratio(report.thresholds.determinism), "yes"], + ], + ), + "", + "## Provenance / 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 += ["", "## Snapshot determinism", ""] + if report.determinism: + lines.extend( + _table( + ["Fixture", "Deterministic", "Canonical graph hash"], + [ + [r.fixture_id, "yes" if r.deterministic else "**NO**", f"`{r.sealed_hash_a}`"] + 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 += ["", "## Extraction quality (precision / recall)", ""] + if report.scoring.deferred: + lines.append( + "> **Deferred.** No Repository Intelligence extractor is merged yet. Precision/recall " + "scoring plugs into the adapter boundary once **#89/#90** merge their extractors and " + "support matrices; it is intentionally **not** scored against the golden facts themselves." + ) + else: + assert report.scoring.report is not None + overall = report.scoring.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(report.scoring.report.by_language.items()) + ], + ) + ) + + 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 ❌" + scoring = "deferred (#89/#90)" if report.scoring.deferred else "scored" + return ( + f"### RI Golden Benchmark: {status}\n\n" + f"- Fixtures: {report.corpus.total}\n" + f"- Provenance validity: {_ratio(report.provenance.validity)} " + f"({report.provenance.valid_count}/{report.provenance.total})\n" + f"- Determinism: {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..502050db --- /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_step_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..ff4c67a2 --- /dev/null +++ b/apps/backend/tests/benchmark/runner.py @@ -0,0 +1,301 @@ +"""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/support-matrix parity check fails; +- an expected (required) diagnostic is missing for a declared blind spot; +- precision or recall is below threshold **when an extractor is available**. + +Precision/recall is *deferred* (reported, never green-washed, and not counted as +a pass) while the #89/#90 extractors are unmerged and the adapter reports +``available = False``. +""" + +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.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 + + @property + def deferred(self) -> bool: + return not self.available + + +@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: + """Scoring gates the build only when a real extractor is available.""" + + if self.scoring.deferred or self.scoring.report is None: + return True + overall = self.scoring.report.overall + provenance_ok = ( + self.scoring.actual_provenance is None + or 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 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.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.scoring_gate_passed and self.scoring.report is not None: + overall = self.scoring.report.overall + reasons.append( + f"scoring: precision {float(overall.precision):.4f} / recall {float(overall.recall):.4f} " + f"below thresholds {float(self.thresholds.precision):.4f}/{float(self.thresholds.recall):.4f}" + ) + 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() + for fixture in fixtures: + for record in fixture.expected: + 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() + support_matrix = load_support_matrix(support_matrix_path) + thresholds = load_thresholds(thresholds_path) + + try: + 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.note, + 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..f21c08b0 --- /dev/null +++ b/apps/backend/tests/benchmark/schema.py @@ -0,0 +1,47 @@ +"""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.v1" +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") + +# 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..9744e20c --- /dev/null +++ b/apps/backend/tests/benchmark/scorer.py @@ -0,0 +1,127 @@ +"""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). +- **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.fact.key()].append(labeled) + for labeled in actual: + actual_by_key[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_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_loader.py b/apps/backend/tests/benchmark/test_loader.py new file mode 100644 index 00000000..6702fd3d --- /dev/null +++ b/apps/backend/tests/benchmark/test_loader.py @@ -0,0 +1,155 @@ +"""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 pathlib import Path + +import pytest + +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_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 _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.0.0"], + "constructsCovered": ["py.function.def"], + "deterministic": False, + "expected": { + "nodes": [ + { + "nodeKind": "repository", + "stableKey": "repo:root", + "evidence": [ + {"path": "README.md", "startLine": 1, "endLine": 1, "extractor": "repository-inventory", "extractorVersion": "1.0.0"} + ], + }, + { + "nodeKind": "symbol", + "stableKey": "src/a.py::f", + "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" + + +@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 support-matrix 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..3e6bd909 --- /dev/null +++ b/apps/backend/tests/benchmark/test_provenance.py @@ -0,0 +1,89 @@ +"""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.0.0",), + constructs_covered=(), + deterministic=False, + expected=(), + ) + + +def _node(path: str, start: int, end: int, *, extractor: str = "python-ast", version: str = "1.0.0", granularity: str = "span") -> Fact: + return Fact( + fact_type="node", + kind="symbol", + subject=f"{path}::sym", + 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..978d2047 --- /dev/null +++ b/apps/backend/tests/benchmark/test_regression_failpath.py @@ -0,0 +1,134 @@ +"""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 determinism as determinism_module +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", + 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, referent=fact.referent, truth_class=fact.truth_class, + value=fact.value, severity=fact.severity, producer=fact.producer, evidence=(broken,), + ) + break + return facts + + +def test_a_perfect_extractor_passes_scoring(): + result = runner.run(adapter=PerfectAdapter()) + assert not result.scoring.deferred + 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 + + +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 + + +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_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..d34fe55e --- /dev/null +++ b/apps/backend/tests/benchmark/test_runner.py @@ -0,0 +1,51 @@ +"""End-to-end benchmark runner tests over the committed corpus (Issue #94).""" + +from __future__ import annotations + +import json +from pathlib import Path + +from benchmark import report as report_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 + # 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_deferred_not_greenwashed(): + result = runner.run() + # With no extractor merged, scoring is deferred — never a fabricated 1.0. + assert result.scoring.deferred + assert result.scoring.report is None + # A deferred stage must not, by itself, fail the build. + assert result.scoring_gate_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["provenance"]["validity"] == "1.0000" + markdown = report_module.to_markdown(runner.run()) + assert "Repository Intelligence Golden Benchmark" in markdown + + +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" diff --git a/apps/backend/tests/benchmark/test_scorer.py b/apps/backend/tests/benchmark/test_scorer.py new file mode 100644 index 00000000..fc6dbdbe --- /dev/null +++ b/apps/backend/tests/benchmark/test_scorer.py @@ -0,0 +1,141 @@ +"""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") -> Fact: + return Fact( + fact_type="node", + kind=kind, + subject=stable_key, + 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_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_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/docs/README.md b/docs/README.md index 5977e515..ac4ec768 100644 --- a/docs/README.md +++ b/docs/README.md @@ -15,6 +15,7 @@ Every document listed here is maintained and describes the system as it currentl | [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, planned producer identity, 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. Acceptance records the governing contract; it does not make unimplemented downstream functionality current product behaviour. The #87 revision identity and #88 immutable snapshot-persistence boundary are implemented against this accepted contract; syntax-aware producers, queries, durable jobs, benchmarks, and consumer migration remain #89–#95. §17 tracks implementation status. Governs downstream issues #87–#95. | +| [Repository Intelligence golden benchmark](../apps/backend/tests/benchmark/README.md) | Contributors on the intelligence track | The versioned golden fixture corpus, independently-authored expected facts, the precision/recall scorer, provenance-validity and canonical-hash determinism checks, and the CI regression report (Issue [#94](https://github.com/Second-Origin/PARTHA/issues/94)). The harness, corpus, provenance, and determinism gates are implemented against the merged #86/#88 contracts; **live precision/recall scoring is deferred until the #89/#90 extractors merge** and is reported as such, never green-washed. | | [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. | From afcea1f6d65cc423485b3f52d2c528052cd9c69e Mon Sep 17 00:00:00 2001 From: parthrohit22 Date: Thu, 16 Jul 2026 23:54:36 +0100 Subject: [PATCH 099/347] fix(benchmark): address PR review findings --- .github/workflows/ci.yml | 6 +- apps/backend/tests/benchmark/README.md | 7 +- apps/backend/tests/benchmark/adapter.py | 4 ++ apps/backend/tests/benchmark/facts.py | 16 +++-- apps/backend/tests/benchmark/loader.py | 2 + apps/backend/tests/benchmark/report.py | 16 +++++ apps/backend/tests/benchmark/run.py | 2 +- apps/backend/tests/benchmark/scorer.py | 6 +- apps/backend/tests/benchmark/test_loader.py | 6 ++ .../tests/benchmark/test_provenance.py | 2 + .../benchmark/test_regression_failpath.py | 14 +++- apps/backend/tests/benchmark/test_runner.py | 17 +++++ apps/backend/tests/benchmark/test_scorer.py | 66 ++++++++++++++++++- 13 files changed, 147 insertions(+), 17 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 13559b0e..0e8723de 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -143,7 +143,7 @@ jobs: echo "status=$?" >> "$GITHUB_OUTPUT" - name: Upload benchmark reports - if: always() + if: always() && steps.ri_benchmark.outcome != 'skipped' uses: actions/upload-artifact@v4 with: name: ri-golden-benchmark @@ -151,7 +151,7 @@ jobs: if-no-files-found: error - name: Enforce benchmark gate - if: always() + 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 }})." @@ -192,4 +192,4 @@ jobs: - name: Stop Compose stack if: always() - run: docker compose down -v \ No newline at end of file + run: docker compose down -v diff --git a/apps/backend/tests/benchmark/README.md b/apps/backend/tests/benchmark/README.md index 10fce437..4569dfa9 100644 --- a/apps/backend/tests/benchmark/README.md +++ b/apps/backend/tests/benchmark/README.md @@ -69,8 +69,11 @@ mandatory evidence, and machine-blessed output. ## Metrics and thresholds Comparison is by each fact's exact semantic identity (fact type, kind, -subject/object/predicate, and the full normalized evidence span set) — a -right-named fact with the wrong line span does **not** match. +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) diff --git a/apps/backend/tests/benchmark/adapter.py b/apps/backend/tests/benchmark/adapter.py index 21fec683..71f19af6 100644 --- a/apps/backend/tests/benchmark/adapter.py +++ b/apps/backend/tests/benchmark/adapter.py @@ -14,6 +14,10 @@ The benchmark deliberately does **not** ship a second repository parser (CONTRIBUTING §11.2, Issue #94): an adapter adapts the real extractor's output; it does not re-implement extraction. + +The future real adapter must populate ``Fact.name`` and ``Fact.language`` for +node output exactly as emitted by the extractor. These values are part of the +node comparison identity; non-node facts leave both fields empty. """ from __future__ import annotations diff --git a/apps/backend/tests/benchmark/facts.py b/apps/backend/tests/benchmark/facts.py index 33564a1f..250d9ee9 100644 --- a/apps/backend/tests/benchmark/facts.py +++ b/apps/backend/tests/benchmark/facts.py @@ -6,11 +6,13 @@ 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, and — for -facts that carry evidence — the full, normalized evidence span set (path + -one-based inclusive lines + granularity + extractor). A fact with the right name -but the wrong line span therefore does **not** match, which is the whole point -of a provenance-aware benchmark (RFC-0001 §6.2, Issue #94). +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"). @@ -71,6 +73,8 @@ class Fact: 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 truth_class: str = "" # observed | resolved | inferred value: str = "" # canonical JCS of an assertion value, when relevant @@ -90,6 +94,8 @@ def key(self) -> tuple[Any, ...]: self.subject, self.object, self.predicate, + self.name, + self.language, self.referent, self.truth_class, self.value, diff --git a/apps/backend/tests/benchmark/loader.py b/apps/backend/tests/benchmark/loader.py index 601c3894..59a0754a 100644 --- a/apps/backend/tests/benchmark/loader.py +++ b/apps/backend/tests/benchmark/loader.py @@ -229,6 +229,8 @@ def _build_fact(group: str, raw: dict[str, Any], *, where: str, producers: set[s fact_type="node", kind=node_kind, subject=stable_key, + name=str(raw.get("name", "")), + language=str(raw.get("language", "")), truth_class="observed", evidence=evidence, ) diff --git a/apps/backend/tests/benchmark/report.py b/apps/backend/tests/benchmark/report.py index 82f6eef1..26bb29fd 100644 --- a/apps/backend/tests/benchmark/report.py +++ b/apps/backend/tests/benchmark/report.py @@ -227,6 +227,22 @@ def to_step_summary(report: BenchmarkReport) -> str: ) +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" + scoring = "deferred (#89/#90)" if report.scoring.deferred else "scored" + return ( + f"RI Golden Benchmark: {status}\n" + f"- Fixtures: {report.corpus.total}\n" + f"- Provenance validity: {_ratio(report.provenance.validity)} " + f"({report.provenance.valid_count}/{report.provenance.total})\n" + f"- Determinism: {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" diff --git a/apps/backend/tests/benchmark/run.py b/apps/backend/tests/benchmark/run.py index 502050db..18b140e8 100644 --- a/apps/backend/tests/benchmark/run.py +++ b/apps/backend/tests/benchmark/run.py @@ -51,7 +51,7 @@ def main(argv: list[str] | None = None) -> int: with summary_target.open("a", encoding="utf-8") as handle: handle.write(report_module.to_step_summary(result)) - print(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 diff --git a/apps/backend/tests/benchmark/scorer.py b/apps/backend/tests/benchmark/scorer.py index 9744e20c..9bbdec26 100644 --- a/apps/backend/tests/benchmark/scorer.py +++ b/apps/backend/tests/benchmark/scorer.py @@ -12,6 +12,8 @@ - **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). @@ -100,9 +102,9 @@ def score(expected: list[LabeledFact], actual: list[LabeledFact]) -> 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.fact.key()].append(labeled) + expected_by_key[(labeled.fixture_id, labeled.fact.key())].append(labeled) for labeled in actual: - actual_by_key[labeled.fact.key()].append(labeled) + actual_by_key[(labeled.fixture_id, labeled.fact.key())].append(labeled) true_positives: list[LabeledFact] = [] false_negatives: list[LabeledFact] = [] diff --git a/apps/backend/tests/benchmark/test_loader.py b/apps/backend/tests/benchmark/test_loader.py index 6702fd3d..0691c137 100644 --- a/apps/backend/tests/benchmark/test_loader.py +++ b/apps/backend/tests/benchmark/test_loader.py @@ -58,6 +58,7 @@ def _base_manifest() -> dict: { "nodeKind": "repository", "stableKey": "repo:root", + "name": "repository", "evidence": [ {"path": "README.md", "startLine": 1, "endLine": 1, "extractor": "repository-inventory", "extractorVersion": "1.0.0"} ], @@ -65,6 +66,8 @@ def _base_manifest() -> dict: { "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"} @@ -99,6 +102,9 @@ 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" @pytest.mark.parametrize( diff --git a/apps/backend/tests/benchmark/test_provenance.py b/apps/backend/tests/benchmark/test_provenance.py index 3e6bd909..a522c6b5 100644 --- a/apps/backend/tests/benchmark/test_provenance.py +++ b/apps/backend/tests/benchmark/test_provenance.py @@ -34,6 +34,8 @@ def _node(path: str, start: int, end: int, *, extractor: str = "python-ast", ver fact_type="node", kind="symbol", subject=f"{path}::sym", + name="sym", + language="python", truth_class="observed", evidence=(EvidenceSpan(path, start, end, extractor, version, granularity),), ) diff --git a/apps/backend/tests/benchmark/test_regression_failpath.py b/apps/backend/tests/benchmark/test_regression_failpath.py index 978d2047..c40f64e3 100644 --- a/apps/backend/tests/benchmark/test_regression_failpath.py +++ b/apps/backend/tests/benchmark/test_regression_failpath.py @@ -51,8 +51,15 @@ def extract(self, fixture: LoadedFixture) -> list[Fact]: if record.fact.evidence: span = record.fact.evidence[0] ghosts.append( - Fact(fact_type="node", kind="symbol", subject=f"{span.path}::ghost", - truth_class="observed", evidence=(span,)) + 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 @@ -71,7 +78,8 @@ def extract(self, fixture: LoadedFixture) -> list[Fact]: 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, referent=fact.referent, truth_class=fact.truth_class, + predicate=fact.predicate, name=fact.name, language=fact.language, + referent=fact.referent, truth_class=fact.truth_class, value=fact.value, severity=fact.severity, producer=fact.producer, evidence=(broken,), ) break diff --git a/apps/backend/tests/benchmark/test_runner.py b/apps/backend/tests/benchmark/test_runner.py index d34fe55e..0057f73e 100644 --- a/apps/backend/tests/benchmark/test_runner.py +++ b/apps/backend/tests/benchmark/test_runner.py @@ -2,10 +2,12 @@ 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 @@ -49,3 +51,18 @@ 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 index fc6dbdbe..290319a8 100644 --- a/apps/backend/tests/benchmark/test_scorer.py +++ b/apps/backend/tests/benchmark/test_scorer.py @@ -18,11 +18,21 @@ def _span(path: str, start: int, end: int, granularity: str = "span") -> Evidenc return EvidenceSpan(path, start, end, "python-ast", "1.0.0", granularity) -def _node(stable_key: str, start: int, end: int, *, kind: str = "symbol") -> Fact: +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),), ) @@ -88,6 +98,38 @@ def test_wrong_fact_kind_does_not_match(): 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))] @@ -98,6 +140,28 @@ def test_duplicate_actual_fact_counts_one_tp_and_one_fp(): 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))] From 0a3ab1bc3bfbabc3a5108989b402c4cfc7541c26 Mon Sep 17 00:00:00 2001 From: parthrohit22 Date: Fri, 17 Jul 2026 01:46:43 +0100 Subject: [PATCH 100/347] test(intelligence): complete real golden benchmark --- .github/workflows/ci.yml | 20 ++ apps/backend/app/extraction/__init__.py | 8 + apps/backend/app/extraction/base.py | 15 +- apps/backend/app/extraction/pipeline.py | 231 +++++++++++++++ apps/backend/app/extraction/python.py | 5 + apps/backend/app/extraction/support_matrix.py | 6 +- apps/backend/app/extraction/typescript.py | 19 +- apps/backend/tests/benchmark/README.md | 53 ++-- apps/backend/tests/benchmark/__init__.py | 13 +- apps/backend/tests/benchmark/adapter.py | 195 ++++++++++--- .../config/benchmark_support_matrix.json | 117 ++++++-- .../tests/benchmark/config/thresholds.json | 2 +- apps/backend/tests/benchmark/determinism.py | 273 +++++++++++++----- apps/backend/tests/benchmark/facts.py | 4 + .../adv-py-blindspots/manifest.json | 37 ++- .../adv-py-blindspots/src/dynamic.py | 2 +- .../adv-py-malformed/manifest.json | 4 +- .../adv-py-metaclass/manifest.json | 16 + .../adversarial/adv-py-metaclass/src/meta.py | 2 + .../adv-source-edgecases/..\\escape.py" | 2 + .../adv-source-edgecases/manifest.json | 18 +- .../adv-source-edgecases/src/large.py | 1 + .../adv-ts-blindspots/manifest.json | 8 +- .../adv-ts-malformed/manifest.json | 2 +- .../adv-ts-more-blindspots/manifest.json | 20 ++ .../adv-ts-more-blindspots/src/legacy.ts | 6 + .../minimal/min-py-async/manifest.json | 9 +- .../minimal/min-py-class/manifest.json | 10 +- .../min-py-decorator-route/manifest.json | 16 +- .../minimal/min-py-function/manifest.json | 16 +- .../minimal/min-py-imports/manifest.json | 10 +- .../minimal/min-py-nested-dup/manifest.json | 12 +- .../minimal/min-ts-class/manifest.json | 10 +- .../minimal/min-ts-functions/manifest.json | 9 +- .../min-ts-imports-exports/manifest.json | 12 +- .../minimal/min-ts-route/manifest.json | 24 +- .../minimal/min-ts-route/src/router.ts | 10 +- .../minimal/min-ts-types/manifest.json | 24 ++ .../minimal/min-ts-types/src/types.ts | 4 + .../realistic/real-py-fastapi/manifest.json | 24 +- .../realistic/real-ts-service/manifest.json | 14 +- apps/backend/tests/benchmark/loader.py | 59 +++- apps/backend/tests/benchmark/provenance.py | 4 +- apps/backend/tests/benchmark/report.py | 174 ++++++++--- apps/backend/tests/benchmark/runner.py | 56 ++-- apps/backend/tests/benchmark/test_adapter.py | 131 +++++++++ .../benchmark/test_regression_failpath.py | 23 +- apps/backend/tests/benchmark/test_runner.py | 29 +- .../backend/tests/extraction/test_pipeline.py | 33 +++ .../extraction/test_typescript_diagnostics.py | 6 +- docs/README.md | 2 +- docs/architecture/REPOSITORY_INTELLIGENCE.md | 38 ++- .../REPOSITORY_INTELLIGENCE_V1_RFC.md | 20 +- docs/architecture/SYSTEM_OVERVIEW.md | 2 +- 54 files changed, 1482 insertions(+), 378 deletions(-) create mode 100644 apps/backend/app/extraction/pipeline.py create mode 100644 apps/backend/tests/benchmark/fixtures/adversarial/adv-py-metaclass/manifest.json create mode 100644 apps/backend/tests/benchmark/fixtures/adversarial/adv-py-metaclass/src/meta.py create mode 100644 "apps/backend/tests/benchmark/fixtures/adversarial/adv-source-edgecases/..\\escape.py" create mode 100644 apps/backend/tests/benchmark/fixtures/adversarial/adv-source-edgecases/src/large.py create mode 100644 apps/backend/tests/benchmark/fixtures/adversarial/adv-ts-more-blindspots/manifest.json create mode 100644 apps/backend/tests/benchmark/fixtures/adversarial/adv-ts-more-blindspots/src/legacy.ts create mode 100644 apps/backend/tests/benchmark/fixtures/minimal/min-ts-types/manifest.json create mode 100644 apps/backend/tests/benchmark/fixtures/minimal/min-ts-types/src/types.ts create mode 100644 apps/backend/tests/benchmark/test_adapter.py create mode 100644 apps/backend/tests/extraction/test_pipeline.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0e8723de..aa13afe0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -142,6 +142,26 @@ jobs: python tests/benchmark/run.py --report-dir "$RUNNER_TEMP/ri-benchmark" echo "status=$?" >> "$GITHUB_OUTPUT" + - 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 diff --git a/apps/backend/app/extraction/__init__.py b/apps/backend/app/extraction/__init__.py index e201e0d3..1ffa0a3c 100644 --- a/apps/backend/app/extraction/__init__.py +++ b/apps/backend/app/extraction/__init__.py @@ -6,6 +6,11 @@ ExtractionResult, Extractor, ) +from app.extraction.pipeline import ( + DEFAULT_MAX_SOURCE_BYTES, + ExtractionPipeline, + ProducedExtraction, +) __all__ = [ "ExtractedDiagnostic", @@ -14,4 +19,7 @@ "ExtractedObservation", "ExtractionResult", "Extractor", + "DEFAULT_MAX_SOURCE_BYTES", + "ExtractionPipeline", + "ProducedExtraction", ] diff --git a/apps/backend/app/extraction/base.py b/apps/backend/app/extraction/base.py index 9c77c0ef..2da8bc0f 100644 --- a/apps/backend/app/extraction/base.py +++ b/apps/backend/app/extraction/base.py @@ -71,6 +71,7 @@ def extract(self, path: str, source: bytes) -> ExtractionResult: ... 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" @@ -79,6 +80,7 @@ def extract(self, path: str, source: bytes) -> ExtractionResult: ... 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", @@ -134,13 +136,21 @@ def decode_source( 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=path, + path=normalized_path, + subject=subject, ) try: return source.decode("utf-8"), None @@ -150,7 +160,8 @@ def decode_source( category=_CATEGORY[RI_SRC_MALFORMED], severity="error", message="file is not valid UTF-8 and could not be decoded", - path=path, + path=normalized_path, + subject=subject, ) diff --git a/apps/backend/app/extraction/pipeline.py b/apps/backend/app/extraction/pipeline.py new file mode 100644 index 00000000..a1c532ef --- /dev/null +++ b/apps/backend/app/extraction/pipeline.py @@ -0,0 +1,231 @@ +"""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, path normalization, and the output-affecting file-size +budget required by RFC-0001 sections 6.2, 8.2, and 12.7. +""" + +from __future__ import annotations + +import posixpath +from collections.abc import 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.0.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]) -> tuple[ProducedExtraction, ...]: + inventory_nodes: list[ExtractedNode] = [] + inventory_diagnostics: list[ExtractedDiagnostic] = [] + produced: list[ProducedExtraction] = [] + repository_evidence: ExtractedEvidence | None = None + + for raw_path, source in sorted(sources.items()): + 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", + ) + ) + continue + + 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), + }, + ) + ) + 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) + produced.append( + ProducedExtraction(extractor.name, extractor.version, result) + ) + # 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)) + ) + repository_evidence = repository_evidence or self._repo_evidence( + evidence + ) + elif result.nodes: + evidence = self._whole_file_evidence(path, source) + if evidence is not None: + repository_evidence = repository_evidence or self._repo_evidence( + evidence + ) + 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, + ) + ) + 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))) + repository_evidence = repository_evidence or self._repo_evidence(evidence) + + if repository_evidence is not None: + inventory_nodes.insert( + 0, + ExtractedNode( + node_kind="repository", + stable_key="repo:root", + name="repository", + language=None, + evidence=(repository_evidence,), + ), + ) + + if inventory_nodes or inventory_diagnostics: + produced.insert( + 0, + ProducedExtraction( + self.inventory_name, + self.inventory_version, + ExtractionResult( + nodes=tuple(inventory_nodes), + diagnostics=tuple(inventory_diagnostics), + ), + ), + ) + return tuple(produced) + + 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 + ) -> ExtractedNode: + return ExtractedNode( + node_kind="file", + stable_key=canonical.normalize_stable_key("file", f"file:{path}"), + name=posixpath.basename(path), + language=language, + evidence=(evidence,), + ) diff --git a/apps/backend/app/extraction/python.py b/apps/backend/app/extraction/python.py index 3672cc6c..0141a6a8 100644 --- a/apps/backend/app/extraction/python.py +++ b/apps/backend/app/extraction/python.py @@ -172,6 +172,9 @@ def extract(self, path: str, source: bytes) -> ExtractionResult: 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)}" + ), ), ) ) @@ -424,6 +427,7 @@ def _target_names(target): 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 @@ -438,6 +442,7 @@ def flag(node, message: str) -> None: message=message, path=normalized, span=(node.lineno, node.end_lineno or node.lineno), + subject=file_subject, ) ) diff --git a/apps/backend/app/extraction/support_matrix.py b/apps/backend/app/extraction/support_matrix.py index 040832d0..d50cf419 100644 --- a/apps/backend/app/extraction/support_matrix.py +++ b/apps/backend/app/extraction/support_matrix.py @@ -16,9 +16,13 @@ class LanguageSupport: ), "typescript": LanguageSupport( supported=( - "file", "import", "export", "function", "class", "method", + "file", "module", "import", "export", "function", "class", "method", "interface", "type", "enum", "const", "route", ), unsupported=("dynamic-import", "decorator", "namespace", "commonjs-require", "ambient-module"), ), + "source": LanguageSupport( + supported=("repository", "file", "empty-file", "trailing-newline"), + unsupported=("binary-file", "malformed-source", "large-file", "path-escape"), + ), } diff --git a/apps/backend/app/extraction/typescript.py b/apps/backend/app/extraction/typescript.py index ed7abd0f..1ad0afce 100644 --- a/apps/backend/app/extraction/typescript.py +++ b/apps/backend/app/extraction/typescript.py @@ -91,17 +91,22 @@ def extract(self, path: str, source: bytes) -> ExtractionResult: nodes: list[ExtractedNode] = [] diagnostics: list[ExtractedDiagnostic] = [] + file_key = canonical.normalize_stable_key("file", f"file:{normalized_path}") if tree.root_node.has_error: - diagnostics.append( - ExtractedDiagnostic( - code=RI_SRC_MALFORMED, category="malformed source", - severity="error", message="file has TypeScript syntax errors", - path=normalized_path, + 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_key = canonical.normalize_stable_key("file", f"file:{normalized_path}") file_ev, file_diag = build_evidence( path, 1, line_count, line_count, producer=self.producer, granularity="file" ) @@ -266,6 +271,7 @@ def walk(node): 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( @@ -273,6 +279,7 @@ def flag(node, message): 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, ) ) diff --git a/apps/backend/tests/benchmark/README.md b/apps/backend/tests/benchmark/README.md index 4569dfa9..4206b686 100644 --- a/apps/backend/tests/benchmark/README.md +++ b/apps/backend/tests/benchmark/README.md @@ -6,30 +6,24 @@ 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. -> **Scope / honesty note.** The syntax-aware TypeScript and Python extractors and -> their published support matrices land in **#89 / #90**, which are **not merged -> into `dev`** yet. This change implements everything that depends only on the -> merged #86 evidence contract and #88 `SnapshotStore`: the fixture corpus, -> independently-derived expected facts, the scorer, provenance validity, and -> determinism. **Precision/recall against a live extractor is deferred**: it plugs -> into the [`adapter.py`](adapter.py) boundary when #89/#90 merge, and is reported -> as `deferred` — never scored against the golden facts themselves, which would -> manufacture a meaningless perfect score. This benchmark does **not** prove the -> extractors are good yet; it proves the *corpus* and the *measurement machinery* -> are correct and ready to hold them to account. +The default runner sends every applicable fixture's stored bytes through the +production source-policy pipeline and the merged Python and TypeScript +extractors. It compares only real emitted nodes, observations, and diagnostics +with the independently authored golden facts; it never copies expected output +into the actual side. ## 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) | The construct taxonomy (**provisional**, benchmark-owned; reconcile with #89/#90). | +| [`config/benchmark_support_matrix.json`](config/benchmark_support_matrix.json) | The benchmark construct taxonomy and validated mapping to every production support-matrix entry. | | [`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) | Real `SnapshotStore` + canonical-hash determinism. | -| [`adapter.py`](adapter.py) | The seam where the real #89/#90 extractors plug in. | +| [`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 @@ -81,13 +75,13 @@ 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 provisional Phase-0 bar from Issue +thresholds (`config/thresholds.json`) are the enforced acceptance bar from Issue #94: | Metric | Threshold | Enforced today? | | --- | --- | --- | -| precision | ≥ 0.95 | when an extractor is available (#89/#90) | -| recall | ≥ 0.90 | when an extractor is available (#89/#90) | +| precision | ≥ 0.95 | **yes** | +| recall | ≥ 0.90 | **yes** | | provenance validity | = 1.00 | **yes** | | determinism | = 1.00 | **yes** | @@ -127,13 +121,18 @@ invariant and failure-path test) and then a dedicated step runs `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 does and does not prove - -- **Does:** the golden corpus is internally valid, every golden citation resolves - to a real span in the stored revision, the real snapshot pipeline is - deterministic over that corpus, the scorer is correct, and the whole gate fails - a bad extractor, invalid citation, broken manifest, or non-deterministic build. -- **Does not (yet):** measure real extraction precision/recall — no extractor is - merged. It also does not imply product output is generally evidence-backed; the - production engine still emits file-level evidence only (see - [`docs/architecture/REPOSITORY_INTELLIGENCE.md`](../../../../docs/architecture/REPOSITORY_INTELLIGENCE.md)). +## 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; +- support-matrix drift, duplicate/cross-fixture facts, invalid citations, bad + precision/recall, and nondeterminism fail the build. + +This benchmark does not claim the legacy product ingestion path has migrated to +the normalized snapshot graph. That orchestration and consumer cutover remain +separate work; see +[`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 index 68f9f807..06df6096 100644 --- a/apps/backend/tests/benchmark/__init__.py +++ b/apps/backend/tests/benchmark/__init__.py @@ -13,13 +13,8 @@ - ``app.intelligence.snapshot_store`` / ``app.models.snapshot`` — the immutable ``ri.v1`` ``SnapshotStore`` (RFC-0001 §11). -Dependency status (see ``README.md`` and the PR description): the syntax-aware -TypeScript/Python extractors and their published support matrices land in -**#89/#90**, which are not merged into ``dev`` yet. Everything here that depends -only on the merged #86 evidence contract and #88 persistence is implemented and -enforced now (fixtures, expected facts, the scorer, provenance validity, and -determinism). Live precision/recall scoring plugs a real extractor into the -:mod:`benchmark.adapter` boundary once #89/#90 merge; until then that stage is -reported as ``deferred`` and is never green-washed into a fabricated perfect -score. +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 index 71f19af6..e8e5eb77 100644 --- a/apps/backend/tests/benchmark/adapter.py +++ b/apps/backend/tests/benchmark/adapter.py @@ -1,61 +1,180 @@ -"""The extraction adapter boundary — where the real extractors plug in. - -Precision/recall scoring needs *actual* facts from a real Repository -Intelligence extractor. Those extractors (and their published support matrices) -land in #89/#90, which are not merged into ``dev`` yet. This module defines the -seam so that: - -- today, the runner uses :class:`UnavailableExtractionAdapter`, and the scoring - stage is reported as ``deferred`` — never a fabricated perfect score; and -- when #89/#90 merge, a thin real adapter maps the extractor's ``ExtractionResult`` - onto :class:`~benchmark.facts.Fact` and the exact same scorer, provenance - validator, and thresholds enforce quality with no other change. - -The benchmark deliberately does **not** ship a second repository parser -(CONTRIBUTING §11.2, Issue #94): an adapter adapts the real extractor's output; -it does not re-implement extraction. - -The future real adapter must populate ``Fact.name`` and ``Fact.language`` for -node output exactly as emitted by the extractor. These values are part of the -node comparison identity; non-node facts leave both fields empty. +"""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 benchmark.facts import Fact +from app.extraction.pipeline import ExtractionPipeline, ProducedExtraction +from app.extraction.python import PythonExtractor +from app.extraction.typescript import TypeScriptExtractor + +from benchmark.facts import EvidenceSpan, Fact, canonical_value from benchmark.loader import LoadedFixture @runtime_checkable class ExtractionAdapter(Protocol): - """Produces the actual facts an extractor emits for a fixture revision.""" + """Produces the actual facts the product pipeline emits for a fixture.""" name: str available: bool - def extract(self, fixture: LoadedFixture) -> list[Fact]: - ... - + def extract(self, fixture: LoadedFixture) -> list[Fact]: ... -class UnavailableExtractionAdapter: - """The default adapter while #89/#90 are unmerged: no extractor is wired. - ``available`` is ``False``, so the runner marks precision/recall ``deferred`` - rather than scoring golden facts against themselves (which would manufacture - a meaningless perfect score the product's own rules forbid). - """ +class RealExtractionAdapter: + """Run the real Python/TypeScript extractors through production source policy.""" - name = "unavailable (extractors land in #89/#90)" - available = False + name = "repository-intelligence-real-extractors" + available = True + scored_fact_types = frozenset({"node", "observation", "diagnostic"}) - def extract(self, fixture: LoadedFixture) -> list[Fact]: # pragma: no cover - never called - raise RuntimeError( - "No Repository Intelligence extractor is available yet; precision/recall " - "scoring is deferred until #89/#90 merge their extractors and support matrices." + def extract(self, fixture: LoadedFixture) -> list[Fact]: + pipeline = ExtractionPipeline( + (PythonExtractor(), TypeScriptExtractor()), + max_source_bytes=fixture.max_source_bytes, + ) + facts: list[Fact] = [] + for produced in 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 UnavailableExtractionAdapter() + return RealExtractionAdapter() diff --git a/apps/backend/tests/benchmark/config/benchmark_support_matrix.json b/apps/backend/tests/benchmark/config/benchmark_support_matrix.json index fad5d667..b6655ddc 100644 --- a/apps/backend/tests/benchmark/config/benchmark_support_matrix.json +++ b/apps/backend/tests/benchmark/config/benchmark_support_matrix.json @@ -1,41 +1,102 @@ { "schemaVersion": "ri-benchmark-support-matrix.v1", - "note": "PROVISIONAL, benchmark-owned construct taxonomy. The AUTHORITATIVE TypeScript and Python support matrices are published by issues #89 and #90, which are not merged into dev yet. Per Issue #94 and RFC-0001 §16, writing expected facts (and this taxonomy) down first is a deliberate test of whether the eventual support matrix is coherent. Reconcile these ids and the supported/unsupported split against the real #89/#90 matrices when they merge; treat any mismatch as a benchmark finding, not a silent edit.", + "note": "Benchmark construct names are explicitly mapped to the authoritative production extractor/source-policy matrices. Several benchmark ids intentionally map to one production construct when the corpus tests a semantic variant such as async, aliasing, nesting, or duplicate identity.", "constructs": { - "py.module": {"language": "python", "supported": true, "description": "A Python module (file) node."}, + "py.module": {"language": "python", "supported": true, "description": "A directory-scoped Python module node."}, "py.function.def": {"language": "python", "supported": true, "description": "A top-level function definition."}, "py.async_function.def": {"language": "python", "supported": true, "description": "A top-level async function definition."}, "py.class.def": {"language": "python", "supported": true, "description": "A class definition."}, "py.method.def": {"language": "python", "supported": true, "description": "A method defined inside a class."}, "py.nested_function": {"language": "python", "supported": true, "description": "A function nested inside another function."}, - "py.duplicate_symbol": {"language": "python", "supported": true, "description": "A redefined name resolved with a discriminator (RI-KEY-DUP-SYMBOL, informational)."}, - "py.import": {"language": "python", "supported": true, "description": "A plain 'import module' statement."}, - "py.import_alias": {"language": "python", "supported": true, "description": "An 'import module as alias' statement."}, - "py.from_import": {"language": "python", "supported": true, "description": "A 'from module import name' statement."}, - "py.decorator": {"language": "python", "supported": true, "description": "A decorator applied to a function or class."}, - "py.fastapi_route": {"language": "python", "supported": true, "description": "A FastAPI decorator-based route declaration."}, - "py.dynamic_import": {"language": "python", "supported": false, "expectedDiagnostic": "RI-EXT-UNSUPPORTED", "description": "importlib.import_module / __import__ dynamic import."}, - "py.monkeypatch": {"language": "python", "supported": false, "expectedDiagnostic": "RI-EXT-UNSUPPORTED", "description": "Runtime attribute reassignment (monkeypatching)."}, - "py.reflection": {"language": "python", "supported": false, "expectedDiagnostic": "RI-EXT-UNSUPPORTED", "description": "getattr/setattr reflection over dynamic names."}, - "py.star_import": {"language": "python", "supported": false, "expectedDiagnostic": "RI-EXT-UNSUPPORTED", "description": "'from module import *' wildcard import."}, - "py.syntax_error": {"language": "python", "supported": false, "expectedDiagnostic": "RI-SRC-MALFORMED", "description": "A file that fails to parse."}, - "ts.module": {"language": "typescript", "supported": true, "description": "A TypeScript module (file) node."}, + "py.duplicate_symbol": {"language": "python", "supported": true, "description": "A redefined name resolved with an ordinal discriminator."}, + "py.import": {"language": "python", "supported": true, "description": "A plain import statement."}, + "py.import_alias": {"language": "python", "supported": true, "description": "An aliased import statement."}, + "py.from_import": {"language": "python", "supported": true, "description": "A from-import statement."}, + "py.decorator": {"language": "python", "supported": true, "description": "A decorator observation and symbol property."}, + "py.fastapi_route": {"language": "python", "supported": true, "description": "A FastAPI decorator route observation."}, + "py.dynamic_import": {"language": "python", "supported": false, "expectedDiagnostic": "RI-EXT-UNSUPPORTED", "description": "Dynamic import."}, + "py.monkeypatch": {"language": "python", "supported": false, "expectedDiagnostic": "RI-EXT-UNSUPPORTED", "description": "Attribute rebinding on an imported name."}, + "py.reflection": {"language": "python", "supported": false, "expectedDiagnostic": "RI-EXT-UNSUPPORTED", "description": "Reflection calls."}, + "py.star_import": {"language": "python", "supported": false, "expectedDiagnostic": "RI-EXT-UNSUPPORTED", "description": "Wildcard import."}, + "py.metaclass": {"language": "python", "supported": false, "expectedDiagnostic": "RI-EXT-UNSUPPORTED", "description": "Metaclass declaration."}, + "py.syntax_error": {"language": "python", "supported": false, "expectedDiagnostic": "RI-SRC-MALFORMED", "description": "Malformed Python source."}, + "ts.module": {"language": "typescript", "supported": true, "description": "A TypeScript file node."}, + "ts.directory_module": {"language": "typescript", "supported": true, "description": "A directory-scoped TypeScript module node."}, "ts.function": {"language": "typescript", "supported": true, "description": "A function declaration."}, "ts.async_function": {"language": "typescript", "supported": true, "description": "An async function declaration."}, "ts.class": {"language": "typescript", "supported": true, "description": "A class declaration."}, - "ts.method": {"language": "typescript", "supported": true, "description": "A method defined inside a class."}, - "ts.import": {"language": "typescript", "supported": true, "description": "An 'import ... from' statement."}, - "ts.import_alias": {"language": "typescript", "supported": true, "description": "An aliased import ('import { a as b }')."}, - "ts.export": {"language": "typescript", "supported": true, "description": "An 'export' declaration."}, - "ts.reexport": {"language": "typescript", "supported": true, "description": "A re-export ('export { x } from ...')."}, - "ts.route": {"language": "typescript", "supported": true, "description": "A router-style route declaration covered by the matrix."}, - "ts.dynamic_import": {"language": "typescript", "supported": false, "expectedDiagnostic": "RI-EXT-UNSUPPORTED", "description": "Dynamic import() expression."}, - "ts.namespace": {"language": "typescript", "supported": false, "expectedDiagnostic": "RI-EXT-UNSUPPORTED", "description": "A TypeScript 'namespace' declaration."}, - "ts.syntax_error": {"language": "typescript", "supported": false, "expectedDiagnostic": "RI-SRC-MALFORMED", "description": "A file that fails to parse."}, - "src.empty_file": {"language": "mixed", "supported": true, "description": "A zero-byte text file: one logical empty line, whole-file evidence 1..1."}, - "src.trailing_newline": {"language": "mixed", "supported": true, "description": "A file ending in a newline: final empty logical line counted."}, - "src.binary_file": {"language": "mixed", "supported": false, "expectedDiagnostic": "RI-SRC-BINARY", "description": "A non-empty file containing a NUL byte; excluded from line extraction."}, - "src.large_file": {"language": "mixed", "supported": false, "expectedDiagnostic": "RI-LIMIT-SKIP", "description": "A file above the resource budget; skipped by a bounded limit."}, - "src.path_escape": {"language": "mixed", "supported": false, "expectedDiagnostic": "RI-SEC-PATH-ESCAPE", "description": "A path that escapes the repository root; never produces a node."} + "ts.method": {"language": "typescript", "supported": true, "description": "A class method."}, + "ts.interface": {"language": "typescript", "supported": true, "description": "An interface declaration."}, + "ts.type": {"language": "typescript", "supported": true, "description": "A type alias declaration."}, + "ts.enum": {"language": "typescript", "supported": true, "description": "An enum declaration."}, + "ts.const": {"language": "typescript", "supported": true, "description": "A top-level const binding."}, + "ts.import": {"language": "typescript", "supported": true, "description": "An import statement."}, + "ts.import_alias": {"language": "typescript", "supported": true, "description": "An aliased import statement."}, + "ts.export": {"language": "typescript", "supported": true, "description": "An exported symbol property."}, + "ts.reexport": {"language": "typescript", "supported": true, "description": "A re-export import observation."}, + "ts.route": {"language": "typescript", "supported": true, "description": "A react-router route observation."}, + "ts.dynamic_import": {"language": "typescript", "supported": false, "expectedDiagnostic": "RI-EXT-UNSUPPORTED", "description": "Dynamic import expression."}, + "ts.decorator": {"language": "typescript", "supported": false, "expectedDiagnostic": "RI-EXT-UNSUPPORTED", "description": "TypeScript decorator."}, + "ts.namespace": {"language": "typescript", "supported": false, "expectedDiagnostic": "RI-EXT-UNSUPPORTED", "description": "Namespace declaration."}, + "ts.commonjs_require": {"language": "typescript", "supported": false, "expectedDiagnostic": "RI-EXT-UNSUPPORTED", "description": "CommonJS require call."}, + "ts.ambient_module": {"language": "typescript", "supported": false, "expectedDiagnostic": "RI-EXT-UNSUPPORTED", "description": "Ambient module declaration."}, + "ts.syntax_error": {"language": "typescript", "supported": false, "expectedDiagnostic": "RI-SRC-MALFORMED", "description": "Malformed TypeScript source."}, + "src.repository": {"language": "mixed", "supported": true, "description": "The repository root inventory node."}, + "src.file": {"language": "mixed", "supported": true, "description": "A repository inventory file node."}, + "src.empty_file": {"language": "mixed", "supported": true, "description": "A zero-byte text file with one logical line."}, + "src.trailing_newline": {"language": "mixed", "supported": true, "description": "A trailing newline contributes the final logical line."}, + "src.binary_file": {"language": "mixed", "supported": false, "expectedDiagnostic": "RI-SRC-BINARY", "description": "A NUL-containing source file."}, + "src.malformed_source": {"language": "mixed", "supported": false, "expectedDiagnostic": "RI-SRC-MALFORMED", "description": "Undecodable source bytes."}, + "src.large_file": {"language": "mixed", "supported": false, "expectedDiagnostic": "RI-LIMIT-SKIP", "description": "A source above the configured byte budget."}, + "src.path_escape": {"language": "mixed", "supported": false, "expectedDiagnostic": "RI-SEC-PATH-ESCAPE", "description": "A source path escaping the repository root."} + }, + "productionMappings": { + "py.module": {"language": "python", "construct": "module"}, + "py.function.def": {"language": "python", "construct": "function"}, + "py.async_function.def": {"language": "python", "construct": "function"}, + "py.class.def": {"language": "python", "construct": "class"}, + "py.method.def": {"language": "python", "construct": "method"}, + "py.nested_function": {"language": "python", "construct": "function"}, + "py.duplicate_symbol": {"language": "python", "construct": "function"}, + "py.import": {"language": "python", "construct": "import"}, + "py.import_alias": {"language": "python", "construct": "import"}, + "py.from_import": {"language": "python", "construct": "import"}, + "py.decorator": {"language": "python", "construct": "decorator"}, + "py.fastapi_route": {"language": "python", "construct": "route"}, + "py.dynamic_import": {"language": "python", "construct": "dynamic-import"}, + "py.monkeypatch": {"language": "python", "construct": "monkeypatch"}, + "py.reflection": {"language": "python", "construct": "reflection"}, + "py.star_import": {"language": "python", "construct": "star-import"}, + "py.metaclass": {"language": "python", "construct": "metaclass"}, + "py.syntax_error": {"language": "source", "construct": "malformed-source"}, + "ts.module": {"language": "typescript", "construct": "file"}, + "ts.directory_module": {"language": "typescript", "construct": "module"}, + "ts.function": {"language": "typescript", "construct": "function"}, + "ts.async_function": {"language": "typescript", "construct": "function"}, + "ts.class": {"language": "typescript", "construct": "class"}, + "ts.method": {"language": "typescript", "construct": "method"}, + "ts.interface": {"language": "typescript", "construct": "interface"}, + "ts.type": {"language": "typescript", "construct": "type"}, + "ts.enum": {"language": "typescript", "construct": "enum"}, + "ts.const": {"language": "typescript", "construct": "const"}, + "ts.import": {"language": "typescript", "construct": "import"}, + "ts.import_alias": {"language": "typescript", "construct": "import"}, + "ts.export": {"language": "typescript", "construct": "export"}, + "ts.reexport": {"language": "typescript", "construct": "import"}, + "ts.route": {"language": "typescript", "construct": "route"}, + "ts.dynamic_import": {"language": "typescript", "construct": "dynamic-import"}, + "ts.decorator": {"language": "typescript", "construct": "decorator"}, + "ts.namespace": {"language": "typescript", "construct": "namespace"}, + "ts.commonjs_require": {"language": "typescript", "construct": "commonjs-require"}, + "ts.ambient_module": {"language": "typescript", "construct": "ambient-module"}, + "ts.syntax_error": {"language": "source", "construct": "malformed-source"}, + "src.repository": {"language": "source", "construct": "repository"}, + "src.file": {"language": "source", "construct": "file"}, + "src.empty_file": {"language": "source", "construct": "empty-file"}, + "src.trailing_newline": {"language": "source", "construct": "trailing-newline"}, + "src.binary_file": {"language": "source", "construct": "binary-file"}, + "src.malformed_source": {"language": "source", "construct": "malformed-source"}, + "src.large_file": {"language": "source", "construct": "large-file"}, + "src.path_escape": {"language": "source", "construct": "path-escape"} } } diff --git a/apps/backend/tests/benchmark/config/thresholds.json b/apps/backend/tests/benchmark/config/thresholds.json index 01bb06dc..7738fc9e 100644 --- a/apps/backend/tests/benchmark/config/thresholds.json +++ b/apps/backend/tests/benchmark/config/thresholds.json @@ -4,5 +4,5 @@ "recall": "0.90", "provenanceValidity": "1.00", "determinism": "1.00", - "note": "Provisional Phase-0 acceptance bar from Issue #94. Values are exact fractions. Do NOT lower any threshold without documenting the reason on Issue #94 and obtaining maintainer agreement. precision/recall are enforced against a live extractor once #89/#90 merge; provenanceValidity and determinism are enforced now." + "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 index 453415be..f6c36104 100644 --- a/apps/backend/tests/benchmark/determinism.py +++ b/apps/backend/tests/benchmark/determinism.py @@ -1,20 +1,4 @@ -"""Snapshot determinism over the real SnapshotStore and canonical graph hash. - -For each fixture flagged ``deterministic``, this builds the fixture's observed -**node** graph twice through the *real* :class:`app.intelligence.snapshot_store.SnapshotStore` -— different owner, different repository id, reversed node and evidence insertion -order — and requires the two sealed ``canonical_graph_hash`` values to match. It -also recomputes the pure :func:`app.intelligence.canonical.compute_canonical_graph_hash` -over the same nodes in shuffled order as an independent ordering-independence -check. Both use the product's own hash; the benchmark never substitutes one of -its own (Issue #94 "Do not replace the canonical hash with a benchmark-specific -hash"). - -Edge / observation / assertion determinism is already proven by the #88 -persistence suite; the benchmark's contribution is proving the real pipeline is -deterministic over the golden corpus's node graphs, and reporting *both* hashes -when it is not. -""" +"""Real-extraction determinism through SnapshotStore's canonical graph hash.""" from __future__ import annotations @@ -26,13 +10,15 @@ from sqlalchemy.orm import Session, sessionmaker 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.snapshot_store import Evidence, Revision, SnapshotStore from app.models import RepositoryRecord, User from app.models.base import Base from benchmark.loader import LoadedFixture -from benchmark.sourcefiles import logical_line_count_of_bytes SCHEMA_VERSION = canonical.SCHEMA_VERSION @@ -47,23 +33,30 @@ class DeterminismResult: @property def deterministic(self) -> bool: - return self.sealed_hash_a == self.sealed_hash_b and self.pure_hash_a == self.pure_hash_b + 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 _node_facts(fixture: LoadedFixture) -> list: - return [expected for expected in fixture.expected if expected.group == "nodes"] +def _extract(fixture: LoadedFixture) -> tuple[ProducedExtraction, ...]: + return ExtractionPipeline( + (PythonExtractor(), TypeScriptExtractor()), + max_source_bytes=fixture.max_source_bytes, + ).run(fixture.source_files()) -def _evidence_for(fixture: LoadedFixture, span) -> Evidence: - data = (fixture.directory / span.path).read_bytes() +def _evidence(record, produced: ProducedExtraction) -> Evidence: return Evidence( - path=span.path, - start_line=span.start_line, - end_line=span.end_line, - extractor=span.extractor, - extractor_version=span.extractor_version, - logical_line_count=logical_line_count_of_bytes(data), - granularity=span.granularity, + 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, ) @@ -85,7 +78,13 @@ def _make_repository(session: Session, owner: User, revision_value: str) -> Repo return record -def _seal_node_graph(session: Session, fixture: LoadedFixture, *, reverse: bool) -> str: +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() @@ -95,81 +94,207 @@ def _seal_node_graph(session: Session, fixture: LoadedFixture, *, reverse: bool) 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}, ) - nodes = _node_facts(fixture) - for expected in reversed(nodes) if reverse else nodes: - fact = expected.fact - evidence = [_evidence_for(fixture, span) for span in fact.evidence] + + 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: - evidence = list(reversed(evidence)) + records.reverse() store.add_node( snapshot, - node_kind=fact.kind, - stable_key=fact.subject, - name=expected.raw.get("name"), - language=expected.raw.get("language"), - evidence=evidence, + node_kind=node.node_kind, + stable_key=node.stable_key, + name=node.name, + language=node.language, + properties=node.properties, + evidence=records, + set_array_keys=( + frozenset({"decorators"}) + if node.properties and "decorators" in node.properties + else frozenset() + ), + ) + + 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 _pure_node_hash(fixture: LoadedFixture, *, shuffle: bool) -> str: - records = [] - for expected in _node_facts(fixture): - fact = expected.fact - evidence = [ - { - "path": span.path, - "start_line": span.start_line, - "end_line": span.end_line, - "granularity": span.granularity, - "extractor": span.extractor, - "extractor_version": span.extractor_version, +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, + "properties": dict(node.properties) if node.properties is not None else None, + "evidence": evidence, } - for span in fact.evidence - ] - record = {"node_kind": fact.kind, "stable_key": fact.subject, "truth_class": "observed", "evidence": evidence} - if expected.raw.get("name") is not None: - record["name"] = expected.raw["name"] - if expected.raw.get("language") is not None: - record["language"] = expected.raw["language"] - records.append(record) - if shuffle: - records = list(reversed(records)) - for record in records: - record["evidence"] = list(reversed(record["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({}), - nodes=records, + config_hash=canonical.compute_config_hash( + {"max_source_bytes": fixture.max_source_bytes} + ), + nodes=nodes, edges=[], assertions=[], - observations=[], - diagnostics=[], + observations=observations, + diagnostics=diagnostics, schema_version=SCHEMA_VERSION, ) def check_fixture(fixture: LoadedFixture, db_path: Path) -> DeterminismResult: - """Seal ``fixture``'s node graph twice and confirm the canonical hash is stable.""" + """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_node_graph(session, fixture, reverse=False) + sealed_a = _seal_real_graph(session, fixture, runs_a, reverse=False) with factory() as session: - sealed_b = _seal_node_graph(session, fixture, reverse=True) + 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_node_hash(fixture, shuffle=False), - pure_hash_b=_pure_node_hash(fixture, shuffle=True), + 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 index 250d9ee9..d518b345 100644 --- a/apps/backend/tests/benchmark/facts.py +++ b/apps/backend/tests/benchmark/facts.py @@ -76,9 +76,12 @@ class Fact: 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, ...] = () @@ -97,6 +100,7 @@ def key(self) -> tuple[Any, ...]: self.name, self.language, self.referent, + self.ordinal, self.truth_class, self.value, self.severity, 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 index df627aee..0473cbec 100644 --- a/apps/backend/tests/benchmark/fixtures/adversarial/adv-py-blindspots/manifest.json +++ b/apps/backend/tests/benchmark/fixtures/adversarial/adv-py-blindspots/manifest.json @@ -4,33 +4,32 @@ "fixtureClass": "adversarial", "language": "python", "title": "Adversarial Python — declared blind spots", - "description": "Star import, dynamic import, monkeypatching, and reflection. Each is outside the support matrix and MUST produce an RI-EXT-UNSUPPORTED diagnostic rather than an invented fact.", + "description": "Star import, dynamic import, imported-name monkey-patching, and reflection produce exact real diagnostics.", "sourceRoot": ".", "revisionIdentity": "upload-sha256", "producerVersionSet": ["python-ast@1.0.0", "repository-inventory@1.0.0"], - "constructsCovered": ["py.module", "py.star_import", "py.dynamic_import", "py.monkeypatch", "py.reflection"], + "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", - "evidence": [{"path": "src/dynamic.py", "startLine": 1, "endLine": 1, "granularity": "span", "extractor": "repository-inventory", "extractorVersion": "1.0.0"}]}, - {"nodeKind": "file", "stableKey": "file:src/dynamic.py", "name": "dynamic.py", "language": "python", "constructs": ["py.module"], - "evidence": [{"path": "src/dynamic.py", "startLine": 1, "endLine": 7, "granularity": "file", "extractor": "repository-inventory", "extractorVersion": "1.0.0"}]} + {"nodeKind": "repository", "stableKey": "repo:root", "name": "repository", "constructs": ["src.repository"], + "evidence": [{"path": "src/dynamic.py", "startLine": 1, "endLine": 1, "extractor": "repository-inventory", "extractorVersion": "1.0.0"}]}, + {"nodeKind": "file", "stableKey": "file:src/dynamic.py", "name": "dynamic.py", "language": "python", "constructs": ["src.file"], + "evidence": [{"path": "src/dynamic.py", "startLine": 1, "endLine": 7, "granularity": "file", "extractor": "repository-inventory", "extractorVersion": "1.0.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.0.0"}]} ], - "edges": [], "observations": [], "assertions": [], + "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.0.0"}} + ], + "assertions": [], "diagnostics": [ - {"code": "RI-EXT-UNSUPPORTED", "category": "unsupported construct", "severity": "info", - "message": "Star import is outside the Python support matrix.", "producer": "python-ast@1.0.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 is outside the Python support matrix.", "producer": "python-ast@1.0.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": "Monkeypatching (setattr) is outside the Python support matrix.", "producer": "python-ast@1.0.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 (getattr) is outside the Python support matrix.", "producer": "python-ast@1.0.0", - "path": "src/dynamic.py", "span": {"startLine": 6, "endLine": 6}, "subject": "file:src/dynamic.py", "constructs": ["py.reflection"]} + {"code": "RI-EXT-UNSUPPORTED", "category": "unsupported construct", "severity": "info", "message": "star-import is unsupported", "producer": "python-ast@1.0.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.0.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.0.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.0.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 index 81b6389d..6cc0ba39 100644 --- 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 @@ -2,5 +2,5 @@ import importlib module = importlib.import_module("json") -setattr(module, "custom", 1) +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 index 7d3189e2..28ade692 100644 --- a/apps/backend/tests/benchmark/fixtures/adversarial/adv-py-malformed/manifest.json +++ b/apps/backend/tests/benchmark/fixtures/adversarial/adv-py-malformed/manifest.json @@ -8,14 +8,14 @@ "sourceRoot": ".", "revisionIdentity": "upload-sha256", "producerVersionSet": ["python-ast@1.0.0"], - "constructsCovered": ["py.syntax_error"], + "constructsCovered": ["py.syntax_error", "src.malformed_source"], "deterministic": false, "expected": { "nodes": [], "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.0.0", - "path": "src/broken.py", "span": {"startLine": 1, "endLine": 1}, "subject": "file:src/broken.py", "constructs": ["py.syntax_error"]} + "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-metaclass/manifest.json b/apps/backend/tests/benchmark/fixtures/adversarial/adv-py-metaclass/manifest.json new file mode 100644 index 00000000..50900ede --- /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.0.0","repository-inventory@1.0.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.0.0"}]}, + {"nodeKind":"file","stableKey":"file:src/meta.py","name":"meta.py","language":"python","evidence":[{"path":"src/meta.py","startLine":1,"endLine":3,"granularity":"file","extractor":"repository-inventory","extractorVersion":"1.0.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.0.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.0.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.0.0"}} + ],"assertions":[],"diagnostics":[ + {"code":"RI-EXT-UNSUPPORTED","category":"unsupported construct","severity":"info","message":"metaclass is unsupported","producer":"python-ast@1.0.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/..\\escape.py" "b/apps/backend/tests/benchmark/fixtures/adversarial/adv-source-edgecases/..\\escape.py" new file mode 100644 index 00000000..ab93399c --- /dev/null +++ "b/apps/backend/tests/benchmark/fixtures/adversarial/adv-source-edgecases/..\\escape.py" @@ -0,0 +1,2 @@ +def must_not_extract(): + pass 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 index 3f88d36a..7c529fa1 100644 --- a/apps/backend/tests/benchmark/fixtures/adversarial/adv-source-edgecases/manifest.json +++ b/apps/backend/tests/benchmark/fixtures/adversarial/adv-source-edgecases/manifest.json @@ -3,25 +3,27 @@ "fixtureId": "adv-source-edgecases", "fixtureClass": "adversarial", "language": "mixed", - "title": "Adversarial — binary, oversized, and path-escape source", - "description": "A committed small binary file (NUL byte) plus manifest-declared oversized and path-escaping cases represented as diagnostics — no huge file or unsafe path is committed. Exercises src.binary_file, src.large_file, src.path_escape.", + "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 safe POSIX filename containing a backslash that normalizes to an escaping path.", "sourceRoot": ".", "revisionIdentity": "upload-sha256", "producerVersionSet": ["repository-inventory@1.0.0"], + "maxSourceBytes": 64, "constructsCovered": ["src.binary_file", "src.large_file", "src.path_escape"], "deterministic": false, "expected": { "nodes": [], "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.0.0", + "constructs": ["src.path_escape"]}, {"code": "RI-SRC-BINARY", "category": "binary source", "severity": "info", - "message": "blob.bin contains a NUL byte and is excluded from line extraction.", "producer": "repository-inventory@1.0.0", + "message": "file contains a NUL byte and is excluded from line-addressed extraction", "producer": "repository-inventory@1.0.0", "path": "blob.bin", "subject": "file:blob.bin", "constructs": ["src.binary_file"]}, {"code": "RI-LIMIT-SKIP", "category": "resource-limit skip", "severity": "info", - "message": "src/huge.generated.js exceeds the file-size budget and was skipped.", "producer": "repository-inventory@1.0.0", - "details": {"budgetBytes": 524288, "reportedBytes": 1048576}, "constructs": ["src.large_file"]}, - {"code": "RI-SEC-PATH-ESCAPE", "category": "path escape", "severity": "warning", - "message": "An entry escaped the repository root and produced no node.", "producer": "repository-inventory@1.0.0", - "details": {"attemptedPath": "../../etc/passwd"}, "constructs": ["src.path_escape"]} + "message": "file exceeds the configured source-size budget", "producer": "repository-inventory@1.0.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-ts-blindspots/manifest.json b/apps/backend/tests/benchmark/fixtures/adversarial/adv-ts-blindspots/manifest.json index ea3e8788..9e64322a 100644 --- a/apps/backend/tests/benchmark/fixtures/adversarial/adv-ts-blindspots/manifest.json +++ b/apps/backend/tests/benchmark/fixtures/adversarial/adv-ts-blindspots/manifest.json @@ -15,9 +15,13 @@ {"nodeKind": "repository", "stableKey": "repo:root", "name": "repository", "evidence": [{"path": "src/dynamic.ts", "startLine": 1, "endLine": 1, "granularity": "span", "extractor": "repository-inventory", "extractorVersion": "1.0.0"}]}, {"nodeKind": "file", "stableKey": "file:src/dynamic.ts", "name": "dynamic.ts", "language": "typescript", "constructs": ["ts.module"], - "evidence": [{"path": "src/dynamic.ts", "startLine": 1, "endLine": 8, "granularity": "file", "extractor": "repository-inventory", "extractorVersion": "1.0.0"}]} + "evidence": [{"path": "src/dynamic.ts", "startLine": 1, "endLine": 8, "granularity": "file", "extractor": "typescript-ast", "extractorVersion": "1.0.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.0.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.0.0"}]} ], - "edges": [], "observations": [], "assertions": [], + "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.0.0"}} + ], "assertions": [], "diagnostics": [ {"code": "RI-EXT-UNSUPPORTED", "category": "unsupported construct", "severity": "info", "message": "TypeScript namespace is outside the support matrix.", "producer": "typescript-ast@1.0.0", 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 index fc728be8..86e5b897 100644 --- a/apps/backend/tests/benchmark/fixtures/adversarial/adv-ts-malformed/manifest.json +++ b/apps/backend/tests/benchmark/fixtures/adversarial/adv-ts-malformed/manifest.json @@ -15,7 +15,7 @@ "diagnostics": [ {"code": "RI-SRC-MALFORMED", "category": "malformed source", "severity": "error", "message": "src/broken.ts could not be parsed.", "producer": "typescript-ast@1.0.0", - "path": "src/broken.ts", "span": {"startLine": 1, "endLine": 1}, "subject": "file:src/broken.ts", "constructs": ["ts.syntax_error"]} + "path": "src/broken.ts", "subject": "file:src/broken.ts", "constructs": ["ts.syntax_error"]} ] } } 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..df13dc77 --- /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.0.0","typescript-ast@1.0.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.0.0"}]}, + {"nodeKind":"file","stableKey":"file:src/legacy.ts","name":"legacy.ts","language":"typescript","constructs":["ts.module"],"evidence":[{"path":"src/legacy.ts","startLine":1,"endLine":7,"granularity":"file","extractor":"typescript-ast","extractorVersion":"1.0.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.0.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.0.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.0.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.0.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.0.0"}} + ],"assertions":[],"diagnostics":[ + {"code":"RI-EXT-UNSUPPORTED","category":"unsupported construct","severity":"info","message":"namespace/module declaration is unsupported","producer":"typescript-ast@1.0.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.0.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.0.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-py-async/manifest.json b/apps/backend/tests/benchmark/fixtures/minimal/min-py-async/manifest.json index 8d6b2b46..caf387fa 100644 --- a/apps/backend/tests/benchmark/fixtures/minimal/min-py-async/manifest.json +++ b/apps/backend/tests/benchmark/fixtures/minimal/min-py-async/manifest.json @@ -14,11 +14,16 @@ "nodes": [ {"nodeKind": "repository", "stableKey": "repo:root", "name": "repository", "evidence": [{"path": "src/tasks.py", "startLine": 1, "endLine": 1, "granularity": "span", "extractor": "repository-inventory", "extractorVersion": "1.0.0"}]}, - {"nodeKind": "file", "stableKey": "file:src/tasks.py", "name": "tasks.py", "language": "python", "constructs": ["py.module"], + {"nodeKind": "file", "stableKey": "file:src/tasks.py", "name": "tasks.py", "language": "python", "constructs": ["src.file"], "evidence": [{"path": "src/tasks.py", "startLine": 1, "endLine": 3, "granularity": "file", "extractor": "repository-inventory", "extractorVersion": "1.0.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.0.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.0.0"}]} ], - "edges": [], "observations": [], "assertions": [], "diagnostics": [] + "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.0.0"}} + ], "assertions": [], "diagnostics": [] } } 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 index 324d8450..25bbd697 100644 --- a/apps/backend/tests/benchmark/fixtures/minimal/min-py-class/manifest.json +++ b/apps/backend/tests/benchmark/fixtures/minimal/min-py-class/manifest.json @@ -14,8 +14,10 @@ "nodes": [ {"nodeKind": "repository", "stableKey": "repo:root", "name": "repository", "evidence": [{"path": "src/models.py", "startLine": 1, "endLine": 1, "granularity": "span", "extractor": "repository-inventory", "extractorVersion": "1.0.0"}]}, - {"nodeKind": "file", "stableKey": "file:src/models.py", "name": "models.py", "language": "python", "constructs": ["py.module"], + {"nodeKind": "file", "stableKey": "file:src/models.py", "name": "models.py", "language": "python", "constructs": ["src.file"], "evidence": [{"path": "src/models.py", "startLine": 1, "endLine": 7, "granularity": "file", "extractor": "repository-inventory", "extractorVersion": "1.0.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.0.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.0.0"}]}, {"nodeKind": "symbol", "stableKey": "src/models.py::Account.deposit", "name": "deposit", "language": "python", "constructs": ["py.method.def"], @@ -23,6 +25,10 @@ {"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.0.0"}]} ], - "edges": [], "observations": [], "assertions": [], "diagnostics": [] + "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.0.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.0.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.0.0"}} + ], "assertions": [], "diagnostics": [] } } 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 index d904da2b..6d98a99f 100644 --- 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 @@ -14,17 +14,23 @@ "nodes": [ {"nodeKind": "repository", "stableKey": "repo:root", "name": "repository", "evidence": [{"path": "src/api.py", "startLine": 1, "endLine": 1, "granularity": "span", "extractor": "repository-inventory", "extractorVersion": "1.0.0"}]}, - {"nodeKind": "file", "stableKey": "file:src/api.py", "name": "api.py", "language": "python", "constructs": ["py.module"], + {"nodeKind": "file", "stableKey": "file:src/api.py", "name": "api.py", "language": "python", "constructs": ["src.file"], "evidence": [{"path": "src/api.py", "startLine": 1, "endLine": 14, "granularity": "file", "extractor": "repository-inventory", "extractorVersion": "1.0.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.0.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.0.0"}]}, - {"nodeKind": "symbol", "stableKey": "src/api.py::health", "name": "health", "language": "python", "constructs": ["py.function.def", "py.decorator"], - "evidence": [{"path": "src/api.py", "startLine": 10, "endLine": 13, "granularity": "span", "extractor": "python-ast", "extractorVersion": "1.0.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.0.0"}]} ], "edges": [], "observations": [ - {"observedKind": "routes", "subjectKind": "file", "subjectKey": "file:src/api.py", "referentText": "GET /health", "constructs": ["py.fastapi_route"], - "evidence": {"path": "src/api.py", "startLine": 11, "endLine": 11, "granularity": "span", "extractor": "python-ast", "extractorVersion": "1.0.0"}} + {"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.0.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.0.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.0.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.0.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.0.0"}}, + {"observedKind":"route","subjectKind":"symbol","subjectKey":"src/api.py::health","referentText":"/health","ordinal":1,"constructs":["py.fastapi_route"],"evidence":{"path":"src/api.py","startLine":11,"endLine":11,"extractor":"python-ast","extractorVersion":"1.0.0"}} ], "assertions": [], "diagnostics": [] } 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 index f3b8d996..7be23dc4 100644 --- a/apps/backend/tests/benchmark/fixtures/minimal/min-py-function/manifest.json +++ b/apps/backend/tests/benchmark/fixtures/minimal/min-py-function/manifest.json @@ -33,11 +33,20 @@ "stableKey": "file:src/greeting.py", "name": "greeting.py", "language": "python", - "constructs": ["py.module"], + "constructs": ["src.file"], "evidence": [ {"path": "src/greeting.py", "startLine": 1, "endLine": 7, "granularity": "file", "extractor": "repository-inventory", "extractorVersion": "1.0.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.0.0"} + ] + }, { "nodeKind": "symbol", "stableKey": "src/greeting.py::greet", @@ -60,7 +69,10 @@ } ], "edges": [], - "observations": [], + "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.0.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.0.0"}} + ], "assertions": [], "diagnostics": [] } 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 index 1d4bad16..50984982 100644 --- a/apps/backend/tests/benchmark/fixtures/minimal/min-py-imports/manifest.json +++ b/apps/backend/tests/benchmark/fixtures/minimal/min-py-imports/manifest.json @@ -14,16 +14,18 @@ "nodes": [ {"nodeKind": "repository", "stableKey": "repo:root", "name": "repository", "evidence": [{"path": "src/wiring.py", "startLine": 1, "endLine": 1, "granularity": "span", "extractor": "repository-inventory", "extractorVersion": "1.0.0"}]}, - {"nodeKind": "file", "stableKey": "file:src/wiring.py", "name": "wiring.py", "language": "python", "constructs": ["py.module"], + {"nodeKind": "file", "stableKey": "file:src/wiring.py", "name": "wiring.py", "language": "python", "constructs": ["src.file"], "evidence": [{"path": "src/wiring.py", "startLine": 1, "endLine": 4, "granularity": "file", "extractor": "repository-inventory", "extractorVersion": "1.0.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.0.0"}]} ], "edges": [], "observations": [ - {"observedKind": "imports", "subjectKind": "file", "subjectKey": "file:src/wiring.py", "referentText": "os", "constructs": ["py.import"], + {"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.0.0"}}, - {"observedKind": "imports", "subjectKind": "file", "subjectKey": "file:src/wiring.py", "referentText": "json", "constructs": ["py.import_alias"], + {"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.0.0"}}, - {"observedKind": "imports", "subjectKind": "file", "subjectKey": "file:src/wiring.py", "referentText": "typing.List", "constructs": ["py.from_import"], + {"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.0.0"}} ], "assertions": [], "diagnostics": [] 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 index 80183eca..1255741a 100644 --- 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 @@ -14,8 +14,10 @@ "nodes": [ {"nodeKind": "repository", "stableKey": "repo:root", "name": "repository", "evidence": [{"path": "src/util.py", "startLine": 1, "endLine": 1, "granularity": "span", "extractor": "repository-inventory", "extractorVersion": "1.0.0"}]}, - {"nodeKind": "file", "stableKey": "file:src/util.py", "name": "util.py", "language": "python", "constructs": ["py.module"], + {"nodeKind": "file", "stableKey": "file:src/util.py", "name": "util.py", "language": "python", "constructs": ["src.file"], "evidence": [{"path": "src/util.py", "startLine": 1, "endLine": 9, "granularity": "file", "extractor": "repository-inventory", "extractorVersion": "1.0.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.0.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.0.0"}]}, {"nodeKind": "symbol", "stableKey": "src/util.py::outer.inner", "name": "inner", "language": "python", "constructs": ["py.nested_function"], @@ -23,11 +25,15 @@ {"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.0.0"}]} ], - "edges": [], "observations": [], "assertions": [], + "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.0.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.0.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.0.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.0.0", - "path": "src/util.py", "span": {"startLine": 7, "endLine": 8}, "subject": "src/util.py::outer#2", "constructs": ["py.duplicate_symbol"]} + "path": "src/util.py", "subject": "src/util.py::outer#2", "constructs": ["py.duplicate_symbol"]} ] } } 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 index 41884d2b..a56518bb 100644 --- a/apps/backend/tests/benchmark/fixtures/minimal/min-ts-class/manifest.json +++ b/apps/backend/tests/benchmark/fixtures/minimal/min-ts-class/manifest.json @@ -15,7 +15,9 @@ {"nodeKind": "repository", "stableKey": "repo:root", "name": "repository", "evidence": [{"path": "src/service.ts", "startLine": 1, "endLine": 1, "granularity": "span", "extractor": "repository-inventory", "extractorVersion": "1.0.0"}]}, {"nodeKind": "file", "stableKey": "file:src/service.ts", "name": "service.ts", "language": "typescript", "constructs": ["ts.module"], - "evidence": [{"path": "src/service.ts", "startLine": 1, "endLine": 10, "granularity": "file", "extractor": "repository-inventory", "extractorVersion": "1.0.0"}]}, + "evidence": [{"path": "src/service.ts", "startLine": 1, "endLine": 10, "granularity": "file", "extractor": "typescript-ast", "extractorVersion": "1.0.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.0.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.0.0"}]}, {"nodeKind": "symbol", "stableKey": "src/service.ts::Cache.get", "name": "get", "language": "typescript", "constructs": ["ts.method"], @@ -23,6 +25,10 @@ {"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.0.0"}]} ], - "edges": [], "observations": [], "assertions": [], "diagnostics": [] + "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.0.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.0.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.0.0"}} + ], "assertions": [], "diagnostics": [] } } 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 index c9c7ad64..f6201663 100644 --- a/apps/backend/tests/benchmark/fixtures/minimal/min-ts-functions/manifest.json +++ b/apps/backend/tests/benchmark/fixtures/minimal/min-ts-functions/manifest.json @@ -15,12 +15,17 @@ {"nodeKind": "repository", "stableKey": "repo:root", "name": "repository", "evidence": [{"path": "src/util.ts", "startLine": 1, "endLine": 1, "granularity": "span", "extractor": "repository-inventory", "extractorVersion": "1.0.0"}]}, {"nodeKind": "file", "stableKey": "file:src/util.ts", "name": "util.ts", "language": "typescript", "constructs": ["ts.module"], - "evidence": [{"path": "src/util.ts", "startLine": 1, "endLine": 8, "granularity": "file", "extractor": "repository-inventory", "extractorVersion": "1.0.0"}]}, + "evidence": [{"path": "src/util.ts", "startLine": 1, "endLine": 8, "granularity": "file", "extractor": "typescript-ast", "extractorVersion": "1.0.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.0.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.0.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.0.0"}]} ], - "edges": [], "observations": [], "assertions": [], "diagnostics": [] + "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.0.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.0.0"}} + ], "assertions": [], "diagnostics": [] } } 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 index f7f4736e..b1f0a359 100644 --- 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 @@ -15,17 +15,19 @@ {"nodeKind": "repository", "stableKey": "repo:root", "name": "repository", "evidence": [{"path": "src/index.ts", "startLine": 1, "endLine": 1, "granularity": "span", "extractor": "repository-inventory", "extractorVersion": "1.0.0"}]}, {"nodeKind": "file", "stableKey": "file:src/index.ts", "name": "index.ts", "language": "typescript", "constructs": ["ts.module"], - "evidence": [{"path": "src/index.ts", "startLine": 1, "endLine": 7, "granularity": "file", "extractor": "repository-inventory", "extractorVersion": "1.0.0"}]} + "evidence": [{"path": "src/index.ts", "startLine": 1, "endLine": 7, "granularity": "file", "extractor": "typescript-ast", "extractorVersion": "1.0.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.0.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.0.0"}]} ], "edges": [], "observations": [ - {"observedKind": "imports", "subjectKind": "file", "subjectKey": "file:src/index.ts", "referentText": "fs.readFile", "constructs": ["ts.import"], + {"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.0.0"}}, - {"observedKind": "imports", "subjectKind": "file", "subjectKey": "file:src/index.ts", "referentText": "path.join", "constructs": ["ts.import_alias"], + {"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.0.0"}}, - {"observedKind": "exports", "subjectKind": "file", "subjectKey": "file:src/index.ts", "referentText": "VERSION", "constructs": ["ts.export"], + {"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.0.0"}}, - {"observedKind": "exports", "subjectKind": "file", "subjectKey": "file:src/index.ts", "referentText": "fs.readFile", "constructs": ["ts.reexport"], + {"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.0.0"}} ], "assertions": [], "diagnostics": [] 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 index e0e2a75a..b043a636 100644 --- a/apps/backend/tests/benchmark/fixtures/minimal/min-ts-route/manifest.json +++ b/apps/backend/tests/benchmark/fixtures/minimal/min-ts-route/manifest.json @@ -3,24 +3,30 @@ "fixtureId": "min-ts-route", "fixtureClass": "minimal", "language": "typescript", - "title": "Minimal TypeScript — route declaration", - "description": "An Express-style route registration observed as a route occurrence. Exercises ts.route.", + "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.0.0", "typescript-ast@1.0.0"], - "constructsCovered": ["ts.module", "ts.route"], - "deterministic": false, + "constructsCovered": ["src.repository", "ts.module", "ts.directory_module", "ts.const", "ts.route"], + "deterministic": true, "expected": { "nodes": [ - {"nodeKind": "repository", "stableKey": "repo:root", "name": "repository", - "evidence": [{"path": "src/router.ts", "startLine": 1, "endLine": 1, "granularity": "span", "extractor": "repository-inventory", "extractorVersion": "1.0.0"}]}, + {"nodeKind": "repository", "stableKey": "repo:root", "name": "repository", "constructs": ["src.repository"], + "evidence": [{"path": "src/router.ts", "startLine": 1, "endLine": 1, "extractor": "repository-inventory", "extractorVersion": "1.0.0"}]}, {"nodeKind": "file", "stableKey": "file:src/router.ts", "name": "router.ts", "language": "typescript", "constructs": ["ts.module"], - "evidence": [{"path": "src/router.ts", "startLine": 1, "endLine": 8, "granularity": "file", "extractor": "repository-inventory", "extractorVersion": "1.0.0"}]} + "evidence": [{"path": "src/router.ts", "startLine": 1, "endLine": 4, "granularity": "file", "extractor": "typescript-ast", "extractorVersion": "1.0.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.0.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.0.0"}]} ], "edges": [], "observations": [ - {"observedKind": "routes", "subjectKind": "file", "subjectKey": "file:src/router.ts", "referentText": "GET /status", "constructs": ["ts.route"], - "evidence": {"path": "src/router.ts", "startLine": 5, "endLine": 5, "granularity": "span", "extractor": "typescript-ast", "extractorVersion": "1.0.0"}} + {"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.0.0"}}, + {"observedKind": "route", "subjectKind": "file", "subjectKey": "file:src/router.ts", "referentText": "/status", "ordinal": 1, "constructs": ["ts.route"], + "evidence": {"path": "src/router.ts", "startLine": 2, "endLine": 2, "extractor": "typescript-ast", "extractorVersion": "1.0.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 index a5b1aad1..14864991 100644 --- 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 @@ -1,7 +1,3 @@ -import { Router } from "express"; - -const router = Router(); - -router.get("/status", (req, res) => { - res.send("ok"); -}); +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..277fe54d --- /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.0.0", "typescript-ast@1.0.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.0.0"}]}, + {"nodeKind":"file","stableKey":"file:src/types.ts","name":"types.ts","language":"typescript","constructs":["ts.module"],"evidence":[{"path":"src/types.ts","startLine":1,"endLine":5,"granularity":"file","extractor":"typescript-ast","extractorVersion":"1.0.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.0.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.0.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.0.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.0.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.0.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.0.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.0.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.0.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.0.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-py-fastapi/manifest.json b/apps/backend/tests/benchmark/fixtures/realistic/real-py-fastapi/manifest.json index 19d185f4..837e0524 100644 --- a/apps/backend/tests/benchmark/fixtures/realistic/real-py-fastapi/manifest.json +++ b/apps/backend/tests/benchmark/fixtures/realistic/real-py-fastapi/manifest.json @@ -14,16 +14,18 @@ "nodes": [ {"nodeKind": "repository", "stableKey": "repo:root", "name": "repository", "evidence": [{"path": "src/service.py", "startLine": 1, "endLine": 1, "granularity": "span", "extractor": "repository-inventory", "extractorVersion": "1.0.0"}]}, - {"nodeKind": "file", "stableKey": "file:src/service.py", "name": "service.py", "language": "python", "constructs": ["py.module"], + {"nodeKind": "file", "stableKey": "file:src/service.py", "name": "service.py", "language": "python", "constructs": ["src.file"], "evidence": [{"path": "src/service.py", "startLine": 1, "endLine": 22, "granularity": "file", "extractor": "repository-inventory", "extractorVersion": "1.0.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.0.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.0.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.0.0"}]}, - {"nodeKind": "symbol", "stableKey": "src/service.py::read_user", "name": "read_user", "language": "python", "constructs": ["py.function.def", "py.decorator"], - "evidence": [{"path": "src/service.py", "startLine": 14, "endLine": 16, "granularity": "span", "extractor": "python-ast", "extractorVersion": "1.0.0"}]}, - {"nodeKind": "symbol", "stableKey": "src/service.py::create_user", "name": "create_user", "language": "python", "constructs": ["py.function.def", "py.decorator"], - "evidence": [{"path": "src/service.py", "startLine": 19, "endLine": 21, "granularity": "span", "extractor": "python-ast", "extractorVersion": "1.0.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.0.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.0.0"}]} ], "edges": [ {"subjectKind": "repository", "subjectKey": "repo:root", "predicate": "contains", "objectKind": "file", "objectKey": "file:src/service.py", @@ -31,11 +33,17 @@ "evidence": [{"path": "src/service.py", "startLine": 1, "endLine": 22, "granularity": "file", "extractor": "relationship-resolver", "extractorVersion": "1.0.0"}]} ], "observations": [ - {"observedKind": "imports", "subjectKind": "file", "subjectKey": "file:src/service.py", "referentText": "fastapi.FastAPI", "constructs": ["py.from_import"], + {"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.0.0"}}, - {"observedKind": "routes", "subjectKind": "file", "subjectKey": "file:src/service.py", "referentText": "GET /users/{user_id}", "constructs": ["py.fastapi_route"], + {"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.0.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.0.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.0.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.0.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.0.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.0.0"}}, + {"observedKind": "route", "subjectKind": "symbol", "subjectKey": "src/service.py::read_user", "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.0.0"}}, - {"observedKind": "routes", "subjectKind": "file", "subjectKey": "file:src/service.py", "referentText": "POST /users", "constructs": ["py.fastapi_route"], + {"observedKind": "route", "subjectKind": "symbol", "subjectKey": "src/service.py::create_user", "referentText": "/users", "ordinal": 1, "constructs": ["py.fastapi_route"], "evidence": {"path": "src/service.py", "startLine": 19, "endLine": 19, "granularity": "span", "extractor": "python-ast", "extractorVersion": "1.0.0"}} ], "assertions": [ 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 index 51c08361..a15b6335 100644 --- a/apps/backend/tests/benchmark/fixtures/realistic/real-ts-service/manifest.json +++ b/apps/backend/tests/benchmark/fixtures/realistic/real-ts-service/manifest.json @@ -15,18 +15,18 @@ {"nodeKind": "repository", "stableKey": "repo:root", "name": "repository", "evidence": [{"path": "src/server.ts", "startLine": 1, "endLine": 1, "granularity": "span", "extractor": "repository-inventory", "extractorVersion": "1.0.0"}]}, {"nodeKind": "file", "stableKey": "file:src/server.ts", "name": "server.ts", "language": "typescript", "constructs": ["ts.module"], - "evidence": [{"path": "src/server.ts", "startLine": 1, "endLine": 8, "granularity": "file", "extractor": "repository-inventory", "extractorVersion": "1.0.0"}]}, - {"nodeKind": "symbol", "stableKey": "src/server.ts::start", "name": "start", "language": "typescript", "constructs": ["ts.function"], + "evidence": [{"path": "src/server.ts", "startLine": 1, "endLine": 8, "granularity": "file", "extractor": "typescript-ast", "extractorVersion": "1.0.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.0.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.0.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.0.0"}]} ], "edges": [], "observations": [ - {"observedKind": "imports", "subjectKind": "file", "subjectKey": "file:src/server.ts", "referentText": "http.createServer", "constructs": ["ts.import"], + {"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.0.0"}}, - {"observedKind": "exports", "subjectKind": "file", "subjectKey": "file:src/server.ts", "referentText": "start", "constructs": ["ts.export"], - "evidence": {"path": "src/server.ts", "startLine": 3, "endLine": 3, "granularity": "span", "extractor": "typescript-ast", "extractorVersion": "1.0.0"}}, - {"observedKind": "exports", "subjectKind": "file", "subjectKey": "file:src/server.ts", "referentText": "server", "constructs": ["ts.export"], - "evidence": {"path": "src/server.ts", "startLine": 7, "endLine": 7, "granularity": "span", "extractor": "typescript-ast", "extractorVersion": "1.0.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.0.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.0.0"}} ], "assertions": [], "diagnostics": [] } diff --git a/apps/backend/tests/benchmark/loader.py b/apps/backend/tests/benchmark/loader.py index 59a0754a..5ff07962 100644 --- a/apps/backend/tests/benchmark/loader.py +++ b/apps/backend/tests/benchmark/loader.py @@ -23,6 +23,7 @@ from pathlib import Path from typing import Any +from app.extraction.support_matrix import SUPPORT_MATRIX as PRODUCTION_SUPPORT_MATRIX from app.intelligence import canonical from benchmark import schema @@ -52,6 +53,8 @@ class ConstructSpec: supported: bool description: str expected_diagnostic: str | None + matrix_language: str + matrix_construct: str @dataclass(frozen=True) @@ -93,6 +96,12 @@ def load_support_matrix(path: Path) -> SupportMatrix: 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" + ) + covered_production: set[tuple[str, str]] = set() for construct_id, spec in sorted(raw.items()): language = spec.get("language") if language not in schema.LANGUAGES: @@ -107,12 +116,45 @@ def load_support_matrix(path: Path) -> SupportMatrix: ) if expected_diagnostic is not None and expected_diagnostic not in schema.DIAGNOSTIC_CODES: raise ManifestError(f"{path}: construct {construct_id!r} has unknown diagnostic {expected_diagnostic!r}") + mapping = mappings[construct_id] + matrix_language = str(mapping.get("language", "")) + matrix_construct = str(mapping.get("construct", "")) + if matrix_language not in PRODUCTION_SUPPORT_MATRIX: + raise ManifestError( + f"{path}: construct {construct_id!r} maps to unknown production matrix {matrix_language!r}" + ) + production = PRODUCTION_SUPPORT_MATRIX[matrix_language] + production_supported = matrix_construct in production.supported + production_unsupported = matrix_construct in production.unsupported + if not (production_supported or production_unsupported): + raise ManifestError( + f"{path}: construct {construct_id!r} maps to unknown production construct " + f"{matrix_language}.{matrix_construct}" + ) + if supported != production_supported: + raise ManifestError( + f"{path}: construct {construct_id!r} support status disagrees with " + f"{matrix_language}.{matrix_construct}" + ) + covered_production.add((matrix_language, matrix_construct)) constructs[construct_id] = ConstructSpec( construct_id=construct_id, language=language, supported=supported, description=str(spec.get("description", "")), expected_diagnostic=expected_diagnostic, + matrix_language=matrix_language, + matrix_construct=matrix_construct, + ) + missing_production = sorted( + (language, construct) + for language, matrix in PRODUCTION_SUPPORT_MATRIX.items() + for construct in (*matrix.supported, *matrix.unsupported) + if (language, construct) not in covered_production + ) + if missing_production: + raise ManifestError( + f"{path}: production support constructs have no benchmark mapping: {missing_production}" ) return SupportMatrix(constructs=constructs, note=str(data.get("note", ""))) @@ -165,6 +207,7 @@ class LoadedFixture: constructs_covered: tuple[str, ...] deterministic: bool expected: tuple[ExpectedFact, ...] + max_source_bytes: int = 512 * 1024 def source_files(self) -> dict[str, bytes]: """Every stored byte of the synthetic repository (everything but the manifest).""" @@ -232,6 +275,7 @@ def _build_fact(group: str, raw: dict[str, Any], *, where: str, producers: set[s 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": @@ -261,6 +305,7 @@ def _build_fact(group: str, raw: dict[str, Any], *, where: str, producers: set[s subject=subject, predicate=observed_kind, referent=str(raw.get("referentText", "")), + ordinal=int(raw.get("ordinal", 1)), evidence=(span,), ) if group == "assertions": @@ -301,7 +346,11 @@ def _build_fact(group: str, raw: dict[str, Any], *, where: str, producers: set[s f"{where}: diagnostic span must be one-based and inclusive", ) location = canonical_value( - {"path": normalized_path, "span": {"startLine": span["startLine"], "endLine": span["endLine"]} if span else None} + { + "details": raw.get("details"), + "path": normalized_path, + "span": {"startLine": span["startLine"], "endLine": span["endLine"]} if span else None, + } ) return Fact( fact_type="diagnostic", @@ -309,6 +358,8 @@ def _build_fact(group: str, raw: dict[str, Any], *, where: str, producers: set[s 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, ) @@ -375,6 +426,11 @@ def load_fixture(directory: Path, support_matrix: SupportMatrix) -> LoadedFixtur producers = set(producer_list) 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 support-matrix construct {construct_id!r}") _require( @@ -417,6 +473,7 @@ def load_fixture(directory: Path, support_matrix: SupportMatrix) -> LoadedFixtur constructs_covered=constructs_covered, deterministic=bool(data.get("deterministic", False)), expected=tuple(expected), + max_source_bytes=max_source_bytes, ) diff --git a/apps/backend/tests/benchmark/provenance.py b/apps/backend/tests/benchmark/provenance.py index 9226f30f..41d96b9d 100644 --- a/apps/backend/tests/benchmark/provenance.py +++ b/apps/backend/tests/benchmark/provenance.py @@ -2,8 +2,8 @@ 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. That makes it (a) a real gate on the golden corpus now and (b) the exact -validator that runs over a live extractor's citations once #89/#90 merge. +model. The same validator gates both the independently authored golden corpus and +every citation emitted by the real extractors. A citation is valid iff: diff --git a/apps/backend/tests/benchmark/report.py b/apps/backend/tests/benchmark/report.py index 26bb29fd..63592ed2 100644 --- a/apps/backend/tests/benchmark/report.py +++ b/apps/backend/tests/benchmark/report.py @@ -12,7 +12,7 @@ from pathlib import Path from benchmark.runner import BenchmarkReport -from benchmark.scorer import Counts +from benchmark.scorer import Counts, LabeledFact def _ratio(value: Fraction) -> str: @@ -29,22 +29,57 @@ def _counts_dict(counts: Counts) -> dict[str, object]: } +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 report.scoring.deferred: + if not report.scoring.available or report.scoring.report is None: scoring = { - "status": "deferred", - "reason": "No extractor available; precision/recall scoring lands with #89/#90.", + "status": "unavailable", + "reason": "The real extraction adapter did not produce measurements.", } else: - assert report.scoring.report is not None + score_report = report.scoring.report scoring = { "status": "scored", - "overall": _counts_dict(report.scoring.report.overall), - "byLanguage": {k: _counts_dict(v) for k, v in sorted(report.scoring.report.by_language.items())}, - "byClass": {k: _counts_dict(v) for k, v in sorted(report.scoring.report.by_class.items())}, - "actualProvenanceValidity": ( - _ratio(report.scoring.actual_provenance.validity) if report.scoring.actual_provenance else None + "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 { @@ -75,16 +110,7 @@ def to_json_dict(report: BenchmarkReport) -> dict[str, object]: } for fixture in report.fixtures ], - "provenance": { - "total": report.provenance.total, - "valid": report.provenance.valid_count, - "validity": _ratio(report.provenance.validity), - "invalid": [ - {"fixtureId": c.fixture_id, "subject": c.subject, "path": c.path, - "startLine": c.start_line, "endLine": c.end_line, "reason": c.reason} - for c in report.provenance.invalid - ], - }, + "goldenFixtureProvenance": _citation_dict(report.provenance), "determinism": [ { "fixtureId": r.fixture_id, @@ -119,6 +145,12 @@ def _table(header: list[str], rows: list[list[str]]) -> list[str]: 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] = [ @@ -134,14 +166,14 @@ def to_markdown(report: BenchmarkReport) -> str: *_table( ["Metric", "Threshold", "Enforced now?"], [ - ["precision", _ratio(report.thresholds.precision), "when extractor available (#89/#90)"], - ["recall", _ratio(report.thresholds.recall), "when extractor available (#89/#90)"], + ["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"], ], ), "", - "## Provenance / citation validity", + "## Golden fixture citation validity", "", f"{report.provenance.valid_count} / {report.provenance.total} citations valid " f"(validity **{_ratio(report.provenance.validity)}**).", @@ -158,13 +190,18 @@ def to_markdown(report: BenchmarkReport) -> str: ) ) - lines += ["", "## Snapshot determinism", ""] + lines += ["", "## Real-extraction determinism", ""] if report.determinism: lines.extend( _table( - ["Fixture", "Deterministic", "Canonical graph hash"], + ["Fixture", "Deterministic", "Sealed hash A", "Sealed hash B"], [ - [r.fixture_id, "yes" if r.deterministic else "**NO**", f"`{r.sealed_hash_a}`"] + [ + r.fixture_id, + "yes" if r.deterministic else "**NO**", + f"`{r.sealed_hash_a}`", + f"`{r.sealed_hash_b}`", + ] for r in report.determinism ], ) @@ -181,15 +218,11 @@ def to_markdown(report: BenchmarkReport) -> str: ) lines += ["", "## Extraction quality (precision / recall)", ""] - if report.scoring.deferred: - lines.append( - "> **Deferred.** No Repository Intelligence extractor is merged yet. Precision/recall " - "scoring plugs into the adapter boundary once **#89/#90** merge their extractors and " - "support matrices; it is intentionally **not** scored against the golden facts themselves." - ) + if not report.scoring.available or report.scoring.report is None: + lines.append("**Unavailable:** the real extraction adapter did not produce measurements.") else: - assert report.scoring.report is not None - overall = report.scoring.report.overall + score_report = report.scoring.report + overall = score_report.overall lines.extend( _table( ["Scope", "TP", "FP", "FN", "Precision", "Recall"], @@ -198,10 +231,45 @@ def to_markdown(report: BenchmarkReport) -> str: + [ [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(report.scoring.report.by_language.items()) + 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", ""] @@ -215,13 +283,25 @@ def to_markdown(report: BenchmarkReport) -> str: def to_step_summary(report: BenchmarkReport) -> str: status = "PASS ✅" if report.passed else "FAIL ❌" - scoring = "deferred (#89/#90)" if report.scoring.deferred else "scored" + 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"- Provenance validity: {_ratio(report.provenance.validity)} " + f"- Golden fixture citation validity: {_ratio(report.provenance.validity)} " f"({report.provenance.valid_count}/{report.provenance.total})\n" - f"- Determinism: {sum(1 for r in report.determinism if r.deterministic)}/{len(report.determinism)} stable\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" ) @@ -231,13 +311,25 @@ 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" - scoring = "deferred (#89/#90)" if report.scoring.deferred else "scored" + 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"- Provenance validity: {_ratio(report.provenance.validity)} " + f"- Golden fixture citation validity: {_ratio(report.provenance.validity)} " f"({report.provenance.valid_count}/{report.provenance.total})\n" - f"- Determinism: {sum(1 for r in report.determinism if r.deterministic)}/{len(report.determinism)} stable\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" ) diff --git a/apps/backend/tests/benchmark/runner.py b/apps/backend/tests/benchmark/runner.py index ff4c67a2..d7e5aa38 100644 --- a/apps/backend/tests/benchmark/runner.py +++ b/apps/backend/tests/benchmark/runner.py @@ -9,11 +9,9 @@ - determinism fails; - the fixture/support-matrix parity check fails; - an expected (required) diagnostic is missing for a declared blind spot; -- precision or recall is below threshold **when an extractor is available**. - -Precision/recall is *deferred* (reported, never green-washed, and not counted as -a pass) while the #89/#90 extractors are unmerged and the adapter reports -``available = False``. +- 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 @@ -74,11 +72,6 @@ class ScoringOutcome: report: ScoreReport | None = None actual_provenance: ProvenanceResult | None = None - @property - def deferred(self) -> bool: - return not self.available - - @dataclass class BenchmarkReport: data_version: str @@ -103,14 +96,14 @@ def determinism_passed(self) -> bool: @property def scoring_gate_passed(self) -> bool: - """Scoring gates the build only when a real extractor is available.""" + """Require real measurements and enforce every extraction-quality gate.""" - if self.scoring.deferred or self.scoring.report is None: - return True + 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 None - or self.scoring.actual_provenance.validity >= self.thresholds.provenance_validity + self.scoring.actual_provenance is not None + and self.scoring.actual_provenance.validity >= self.thresholds.provenance_validity ) return ( overall.precision >= self.thresholds.precision @@ -153,12 +146,28 @@ def failures(self) -> list[str]: 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.scoring_gate_passed and self.scoring.report is not None: + 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 - reasons.append( - f"scoring: precision {float(overall.precision):.4f} / recall {float(overall.recall):.4f} " - f"below thresholds {float(self.thresholds.precision):.4f}/{float(self.thresholds.recall):.4f}" - ) + if overall.precision < self.thresholds.precision: + reasons.append( + f"scoring: precision {float(overall.precision):.4f} is below " + f"{float(self.thresholds.precision):.4f}" + ) + if overall.recall < self.thresholds.recall: + reasons.append( + f"scoring: recall {float(overall.recall):.4f} is below " + f"{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} " + f"{check.path} — {check.reason}" + ) return reasons @@ -227,8 +236,11 @@ def _run_scoring(fixtures: list[LoadedFixture], adapter: ExtractionAdapter) -> S 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: @@ -248,16 +260,16 @@ def run( """Run the full benchmark and return a gated :class:`BenchmarkReport`.""" adapter = adapter or default_adapter() - support_matrix = load_support_matrix(support_matrix_path) 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.note, + support_matrix_note="Support-matrix or corpus validation failed.", corpus=CorpusSummary(0, {}, {}), fixtures=[], provenance=ProvenanceResult(), diff --git a/apps/backend/tests/benchmark/test_adapter.py b/apps/backend/tests/benchmark/test_adapter.py new file mode 100644 index 00000000..1f5e5a3b --- /dev/null +++ b/apps/backend/tests/benchmark/test_adapter.py @@ -0,0 +1,131 @@ +"""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.0.0", + "repository-inventory@1.0.0", + "typescript-ast@1.0.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.0.0") in diagnostics + assert ("RI-SRC-MALFORMED", "typescript-ast@1.0.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.0.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.0.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.0.0", "span"),), + ) + + merged = RealExtractionAdapter._merge_compatible_nodes([fact, fact]) + + assert merged == [fact, fact] diff --git a/apps/backend/tests/benchmark/test_regression_failpath.py b/apps/backend/tests/benchmark/test_regression_failpath.py index c40f64e3..af3066db 100644 --- a/apps/backend/tests/benchmark/test_regression_failpath.py +++ b/apps/backend/tests/benchmark/test_regression_failpath.py @@ -79,8 +79,10 @@ def extract(self, fixture: LoadedFixture) -> list[Fact]: 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, truth_class=fact.truth_class, - value=fact.value, severity=fact.severity, producer=fact.producer, evidence=(broken,), + 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 @@ -88,7 +90,7 @@ def extract(self, fixture: LoadedFixture) -> list[Fact]: def test_a_perfect_extractor_passes_scoring(): result = runner.run(adapter=PerfectAdapter()) - assert not result.scoring.deferred + 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 @@ -129,6 +131,21 @@ def test_a_broken_manifest_fails_the_build(tmp_path: Path): 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"]["construct"] = "not-a-real-construct" + 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 construct" 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, diff --git a/apps/backend/tests/benchmark/test_runner.py b/apps/backend/tests/benchmark/test_runner.py index 0057f73e..0d0e5ba3 100644 --- a/apps/backend/tests/benchmark/test_runner.py +++ b/apps/backend/tests/benchmark/test_runner.py @@ -22,17 +22,24 @@ def test_full_benchmark_passes_and_reports_all_real_signals(): 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 # 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_deferred_not_greenwashed(): +def test_precision_recall_is_measured_by_the_real_extractors(): result = runner.run() - # With no extractor merged, scoring is deferred — never a fabricated 1.0. - assert result.scoring.deferred - assert result.scoring.report is None - # A deferred stage must not, by itself, fail the build. + 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 @@ -42,9 +49,19 @@ def test_reports_are_deterministic_and_serialisable(): assert first == second, "JSON report must be byte-stable across runs" payload = json.loads(first) assert payload["result"] == "pass" - assert payload["provenance"]["validity"] == "1.0000" + 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 "deferred" not in markdown.lower() + assert "unavailable" not in markdown.lower() def test_write_reports_emits_both_files(tmp_path: Path): diff --git a/apps/backend/tests/extraction/test_pipeline.py b/apps/backend/tests/extraction/test_pipeline.py new file mode 100644 index 00000000..0a75bd0f --- /dev/null +++ b/apps/backend/tests/extraction/test_pipeline.py @@ -0,0 +1,33 @@ +from app.extraction.pipeline import ExtractionPipeline +from app.extraction.python import PythonExtractor +from app.extraction.typescript import TypeScriptExtractor + + +def _pipeline(*, max_source_bytes: int = 512 * 1024) -> ExtractionPipeline: + return ExtractionPipeline( + (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"}) + 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} + + +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.0.0", "python-ast@1.0.0"] + nodes = [node for run in runs for node in run.result.nodes] + assert any(node.stable_key == "file:README.md" for node in nodes) + assert any(node.stable_key == "src/a.py::f" for node in nodes) diff --git a/apps/backend/tests/extraction/test_typescript_diagnostics.py b/apps/backend/tests/extraction/test_typescript_diagnostics.py index 51082514..7a95be82 100644 --- a/apps/backend/tests/extraction/test_typescript_diagnostics.py +++ b/apps/backend/tests/extraction/test_typescript_diagnostics.py @@ -21,4 +21,8 @@ def test_commonjs_require_flagged(): def test_parse_error_is_malformed(): - assert "RI-SRC-MALFORMED" in _codes("class {{{ broken\n") + 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/docs/README.md b/docs/README.md index ac4ec768..8ba9507a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -15,7 +15,7 @@ Every document listed here is maintained and describes the system as it currentl | [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, planned producer identity, 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. Acceptance records the governing contract; it does not make unimplemented downstream functionality current product behaviour. The #87 revision identity and #88 immutable snapshot-persistence boundary are implemented against this accepted contract; syntax-aware producers, queries, durable jobs, benchmarks, and consumer migration remain #89–#95. §17 tracks implementation status. Governs downstream issues #87–#95. | -| [Repository Intelligence golden benchmark](../apps/backend/tests/benchmark/README.md) | Contributors on the intelligence track | The versioned golden fixture corpus, independently-authored expected facts, the precision/recall scorer, provenance-validity and canonical-hash determinism checks, and the CI regression report (Issue [#94](https://github.com/Second-Origin/PARTHA/issues/94)). The harness, corpus, provenance, and determinism gates are implemented against the merged #86/#88 contracts; **live precision/recall scoring is deferred until the #89/#90 extractors merge** and is reported as such, never green-washed. | +| [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. | diff --git a/docs/architecture/REPOSITORY_INTELLIGENCE.md b/docs/architecture/REPOSITORY_INTELLIGENCE.md index 25049098..496a84a4 100644 --- a/docs/architecture/REPOSITORY_INTELLIGENCE.md +++ b/docs/architecture/REPOSITORY_INTELLIGENCE.md @@ -22,7 +22,7 @@ If your feature needs a repository fact that does not exist yet, the answer is a The production extraction path is still one Pydantic model — `RepositoryIntelligence` ([`app/intelligence/models.py`](../../apps/backend/app/intelligence/models.py)) — built by one engine — `RepositoryIntelligenceEngine` ([`app/intelligence/engine.py`](../../apps/backend/app/intelligence/engine.py)) — and serialized onto the repository row. That blob is retained as explicitly legacy/unverified compatibility data. -The `ri.v1` persistence boundary now also exists: first-class repository revision columns, normalized snapshot/fact/provenance tables, deterministic canonical hashing, and a lifecycle store that validates and seals immutable snapshots. It does not run the legacy regex output through those tables. Syntax-aware producers, resolution, query APIs, durable jobs, determinism scoring, and consumer migration remain later work (#89–#95). +The `ri.v1` persistence boundary now also exists: 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, their support matrices, and the repository-level source-policy pipeline also exist under `app/extraction`; the Issue #94 benchmark executes and validates that real pipeline. Product ingestion still does not run either the legacy regex output or the new extractors through the normalized snapshot tables. Resolution, query APIs, durable job orchestration, and consumer migration remain separate work (#91–#93 and #95). ```mermaid flowchart LR @@ -37,7 +37,7 @@ flowchart LR Root --> Parser --> Engine --> Model --> Store Root --> Revision - Revision -. future conforming producers .-> Snapshot + Revision -. future product orchestration .-> Snapshot Store -->|"from_record()"| Consumers ``` @@ -45,18 +45,26 @@ flowchart LR ## Where parsing happens -Exactly two places in the backend read repository source from disk: +The legacy product path reads repository source from disk in exactly two places: 1. **`RepositoryParser`** walks the extracted tree and produces `FileTreeNode[]` plus `RepositoryMeta` (languages, framework guess, entry point, counts, README/license presence). 2. **`RepositoryIntelligenceEngine`** reads individual file contents during `build()` — capped at 512 KB per file — to extract imports, exports, routes, symbols, and technology hints, and reads dependency manifests from the repository root. 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. +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. -### `TreeSitterParser` is a placeholder +### Syntax-aware extractors are a separate, real boundary -`app/parsers/tree_sitter_parser.py` is **not functional**. It maps a file extension to a language name and always returns an empty symbol list. `tree-sitter` is a declared dependency but is not wired into parsing. All symbol extraction today is regex-based, inside `RepositoryIntelligenceEngine`. Do not read the class name as a promise of syntax-aware parsing. +`PythonExtractor` uses Python's AST and `TypeScriptExtractor` uses tree-sitter. +Both emit normalized nodes, observations, diagnostics, and line evidence through +the `ExtractionResult` contract. Their declared support and blind spots live in +`app/extraction/support_matrix.py`. They supersede the legacy regex engine for a +future normalized-snapshot build, but product ingestion has not been switched to +that build yet. --- @@ -162,13 +170,13 @@ Two terms with distinct meanings. PARTHA uses them precisely, and supports neith **Evidence: partial.** Graph relationships and engineering-review findings carry the **file paths** they were derived from. That is real evidence, and it is enough to point a reader at the right file. -**Production provenance: incomplete.** The new persistence schema can store complete `ri.v1` provenance, but the current regex engine cannot produce it. Specifically: +**Product-consumed provenance: incomplete.** The new persistence schema can store complete `ri.v1` provenance and the standalone extractors can produce it, but the current product path still consumes the legacy regex engine. Specifically: - **No line spans.** `SourceSymbol` has `id`, `name`, `kind`, `file_path`, and `exported`. It has **no start or end line**. Nothing in the model records where in a file a fact was found. - **No extraction method on the fact.** A consumer cannot tell whether a given fact was matched deterministically or inferred heuristically. That distinction lives in this document, not in the data. - **Revision identity is now exact at the repository boundary.** GitHub imports store a 40-character commit plus resolved ref; uploads store a `sha256:` archive identity. Legacy JSON facts still are not individually revision-addressed, while conforming snapshot rows are. -The honest summary: **the persistence layer can retain exact revisions, spans, producer versions, and derivations, but today's production regex output still tells consumers only which file a legacy fact came from.** No line-cited product claim exists until #89–#95 populate and consume conforming snapshots. +The honest summary: **the persistence layer can retain exact revisions, spans, producer versions, and derivations, and the extractor boundary emits conforming spans; today's product consumers still receive only the legacy file-level facts.** No line-cited product claim exists until the durable snapshot workflow populates and serves conforming snapshots. 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). @@ -179,8 +187,8 @@ Do not describe PARTHA as having evidence-backed or grounded output until line s ## Current limitations - **Symbols:** regex-derived, Python and TS/JS only, no line spans, no signatures, no nesting, no cross-file resolution. Matches inside comments and strings are not excluded. -- **Line spans:** not extracted anywhere in the system. -- **Graph production and consumption:** normalized immutable graph tables exist, but no syntax-aware producer or query/consumer path populates and serves them yet. Product surfaces still read the legacy JSON blob. +- **Line spans:** emitted by the Python and TypeScript extractors, but not yet populated and served by the product ingestion/query path. +- **Graph production and consumption:** normalized immutable graph tables and syntax-aware producers exist, but no durable product job or query/consumer path populates and serves them yet. Product surfaces still read the legacy JSON blob. - **Relationships:** four of the eight declared types are never emitted. An import edge resolves to a declared dependency when the name matches and otherwise creates an `external:` node — there is no real module resolution. - **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, no lockfiles, 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. @@ -192,10 +200,12 @@ Do not describe PARTHA as having evidence-backed or grounded output until line s ## Contributing to the engine -1. Add the fact to the model in `app/intelligence/models.py`. -2. Extract it in `app/intelligence/engine.py`. -3. Cover it with a test in `apps/backend/tests/test_repository_intelligence.py`. -4. Consume it in the feature that needed it. +1. Add observed syntax facts to the shared extraction contract and the relevant + extractor; keep heuristic legacy fields in `app/intelligence/models.py`. +2. Update the published support matrix 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_V1_RFC.md b/docs/architecture/REPOSITORY_INTELLIGENCE_V1_RFC.md index 7cd59294..93a35f61 100644 --- a/docs/architecture/REPOSITORY_INTELLIGENCE_V1_RFC.md +++ b/docs/architecture/REPOSITORY_INTELLIGENCE_V1_RFC.md @@ -1702,8 +1702,9 @@ update records that approval in the contract: 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 permits downstream work; - it does not make that work current product behavior. At the time of this update, PR #102 remains - open, so its #87/#88 implementation is not described here as merged or current behavior. + it does not make that work current product behavior. PR #102 implemented #87/#88, and PR #103 + implemented the standalone #89/#90 extractors. Product orchestration and consumer migration + remain separate downstream work. 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). @@ -1719,13 +1720,13 @@ rules), the three columns are explicit: | --- | --- | --- | --- | | Storage | Legacy regex consumers still read the mutable JSON blob; normalized snapshot tables and the sealing store now exist | Immutable sealed snapshots with nodes, edges, assertions, observations, evidence, and diagnostics (§11) | **Persistence implemented** (#88); production producers/queries remain #89–#92 | | Pipeline identity | `SnapshotStore` fixes and normalizes the planned set before a build; no production job planner invokes it yet | Precomputed `producer_version_set` covers every enabled extractor/resolver/classifier (§3.3) | **Persistence implemented** (#88); enqueue/job coordination remains #93 | -| Repository graph key | The store validates exactly one deterministic `repo:root` before sealing; no production extractor emits it yet | Deterministic snapshot-scoped `repo:root`; database `repository_id` excluded from graph keys (§4.3) | **Persistence implemented** (#88); production emission remains #89/#90 | -| Symbol spans | None on `SourceSymbol` ([`models.py:55`](../../apps/backend/app/intelligence/models.py#L55)) | Required line spans (§6) | **Unimplemented** (#89/#90) | -| Extraction | Regex in `engine.py`; `TreeSitterParser` returns `[]` | Syntax-aware extractors with support matrices | **Unimplemented** (#89/#90) | +| Repository graph key | `ExtractionPipeline` emits deterministic `repo:root`; `SnapshotStore` validates exactly one before sealing; no product job invokes that pipeline yet | Deterministic snapshot-scoped `repo:root`; database `repository_id` excluded from graph keys (§4.3) | **Producer and persistence implemented** (#88–#90); job integration remains #93 | +| Symbol spans | Python/TypeScript extractors emit required spans; legacy `SourceSymbol` remains spanless | Required line spans (§6) | **Producer implemented** (#89/#90); product consumption remains #92/#93 | +| Extraction | Legacy product ingestion still uses regex; standalone AST/tree-sitter extractors and support matrices are implemented and benchmarked | Syntax-aware extractors with support matrices | **Producer implemented** (#89/#90); durable product integration remains #93 | | Revision identity | Indexed `revision_kind`/`revision_value`/`revision_ref`; `commitSha` is API compatibility only | Indexed immutable columns (§3) | **Implemented** (#87) | | Relationships | 4 of 8 declared types emitted; imports as text | Resolved edges + diagnostics (§5) | **Unimplemented** (#91) | | Inferred entity properties | Legacy heuristic module roles remain in the compatibility blob; the snapshot store supports separate validated assertions | Separate inferred property assertions; observed nodes remain unique (§5.6) | **Persistence implemented** (#88); production inference/querying remains #91/#92 | -| Provenance | Legacy production output has file paths only; the normalized store validates path + span + producer/version | Path + span + extractor/version (§6) | **Persistence implemented** (#88); syntax-aware production remains #89/#90 | +| Provenance | Extractors emit path + span + producer/version and the normalized store validates them; legacy product consumers still receive file paths only | Path + span + extractor/version (§6) | **Producer and persistence implemented** (#88–#90); product orchestration/querying remains #92/#93 | | Query API | Consumers read the whole blob | Versioned owner-scoped read API (§9.5) | **Unimplemented** (#92) | | Evidence-backed output | AI emits empty citation lists | Every claim cites a valid span (§7.4) | **Unimplemented** (#95) | @@ -1734,9 +1735,10 @@ 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. #89–#95 producers, -queries, jobs, benchmarks, and consumer migration remain downstream work and are not current -behavior. No existing documentation is rewritten by this RFC to imply otherwise. +implemented against that accepted contract, as the status column records. The #89/#90 producers +and #94 benchmark are implemented; resolution, queries, jobs, and consumer migration remain +downstream work and are not current product behavior. No existing documentation is rewritten by +this RFC to imply otherwise. --- diff --git a/docs/architecture/SYSTEM_OVERVIEW.md b/docs/architecture/SYSTEM_OVERVIEW.md index 0d5a05cc..98606336 100644 --- a/docs/architecture/SYSTEM_OVERVIEW.md +++ b/docs/architecture/SYSTEM_OVERVIEW.md @@ -116,7 +116,7 @@ This runs **synchronously inside the HTTP request**. A large repository blocks a | Relational DB | `users`, `refresh_tokens`, `repositories`, `ai_provider_configs`, and normalized `ri_*` snapshot tables | SQLite by default for local development; PostgreSQL under Docker Compose. The current migration head adds revision identity plus immutable snapshot persistence. | | `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) | Parser metadata and the **legacy/unverified** serialized Repository Intelligence under the `intelligence` key. | New imports no longer stash `commitSha` here. Existing legacy facts are retained for compatibility and are not copied into `ri.v1` observed facts. | -| `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. | The persistence boundary and sealing rules are implemented. Syntax-aware producers, query APIs, durable jobs, benchmarks, and consumer migration remain #89–#95, so current product consumers do not read these tables yet. | +| `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. | The persistence boundary, sealing rules, syntax-aware producers, and golden benchmark are implemented. Query APIs, durable product jobs, and consumer migration remain separate work, so current product consumers do not read these tables yet. | | `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 and never returned to the client. | | Filesystem (`STORAGE_PATH`) | Extracted archives and cloned repositories; uploaded archives (deleted after extraction). | Repository source is read from here on demand for file preview. | From 25738d4b47e8f877b58d8e5858691b9a1696eff4 Mon Sep 17 00:00:00 2001 From: parthrohit22 Date: Fri, 17 Jul 2026 11:39:02 +0100 Subject: [PATCH 101/347] fix(benchmark): preserve root and portable escape fixture --- apps/backend/app/extraction/pipeline.py | 25 +++---- apps/backend/tests/benchmark/README.md | 5 ++ .../adv-py-malformed/manifest.json | 9 ++- .../adv-source-edgecases/..\\escape.py" | 2 - .../adv-source-edgecases/manifest.json | 8 ++- .../adv-ts-malformed/manifest.json | 9 ++- apps/backend/tests/benchmark/loader.py | 70 +++++++++++++++---- apps/backend/tests/benchmark/schema.py | 6 ++ apps/backend/tests/benchmark/test_loader.py | 34 +++++++++ .../backend/tests/extraction/test_pipeline.py | 16 +++++ .../tests/test_snapshot_persistence.py | 55 +++++++++++++++ 11 files changed, 203 insertions(+), 36 deletions(-) delete mode 100644 "apps/backend/tests/benchmark/fixtures/adversarial/adv-source-edgecases/..\\escape.py" diff --git a/apps/backend/app/extraction/pipeline.py b/apps/backend/app/extraction/pipeline.py index a1c532ef..7c7ccd68 100644 --- a/apps/backend/app/extraction/pipeline.py +++ b/apps/backend/app/extraction/pipeline.py @@ -4,8 +4,9 @@ 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, path normalization, and the output-affecting file-size -budget required by RFC-0001 sections 6.2, 8.2, and 12.7. +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 @@ -80,6 +81,16 @@ def run(self, sources: Mapping[str, bytes]) -> tuple[ProducedExtraction, ...]: ) 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) + file_key = canonical.normalize_stable_key("file", f"file:{path}") if len(source) > self.max_source_bytes: inventory_diagnostics.append( @@ -120,15 +131,6 @@ def run(self, sources: Mapping[str, bytes]) -> tuple[ProducedExtraction, ...]: inventory_nodes.append( self._file_node(path, evidence, self._language(path)) ) - repository_evidence = repository_evidence or self._repo_evidence( - evidence - ) - elif result.nodes: - evidence = self._whole_file_evidence(path, source) - if evidence is not None: - repository_evidence = repository_evidence or self._repo_evidence( - evidence - ) continue text, diagnostic = decode_source( @@ -159,7 +161,6 @@ def run(self, sources: Mapping[str, bytes]) -> tuple[ProducedExtraction, ...]: granularity="file", ) inventory_nodes.append(self._file_node(path, evidence, self._language(path))) - repository_evidence = repository_evidence or self._repo_evidence(evidence) if repository_evidence is not None: inventory_nodes.insert( diff --git a/apps/backend/tests/benchmark/README.md b/apps/backend/tests/benchmark/README.md index 4206b686..98ce542f 100644 --- a/apps/backend/tests/benchmark/README.md +++ b/apps/backend/tests/benchmark/README.md @@ -54,6 +54,11 @@ the fixture bytes — never a fabricated Git SHA), `producerVersionSet`, 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 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 index 28ade692..36c0b89b 100644 --- a/apps/backend/tests/benchmark/fixtures/adversarial/adv-py-malformed/manifest.json +++ b/apps/backend/tests/benchmark/fixtures/adversarial/adv-py-malformed/manifest.json @@ -4,14 +4,17 @@ "fixtureClass": "adversarial", "language": "python", "title": "Adversarial Python — syntax error", - "description": "A file that fails to parse. It must emit an RI-SRC-MALFORMED diagnostic and no line-addressed facts (RFC-0001 §6.2, §8). Exercises py.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.0.0"], + "producerVersionSet": ["python-ast@1.0.0", "repository-inventory@1.0.0"], "constructsCovered": ["py.syntax_error", "src.malformed_source"], "deterministic": false, "expected": { - "nodes": [], "edges": [], "observations": [], "assertions": [], + "nodes": [ + {"nodeKind": "repository", "stableKey": "repo:root", "name": "repository", + "evidence": [{"path": "src/broken.py", "startLine": 1, "endLine": 1, "extractor": "repository-inventory", "extractorVersion": "1.0.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.0.0", diff --git "a/apps/backend/tests/benchmark/fixtures/adversarial/adv-source-edgecases/..\\escape.py" "b/apps/backend/tests/benchmark/fixtures/adversarial/adv-source-edgecases/..\\escape.py" deleted file mode 100644 index ab93399c..00000000 --- "a/apps/backend/tests/benchmark/fixtures/adversarial/adv-source-edgecases/..\\escape.py" +++ /dev/null @@ -1,2 +0,0 @@ -def must_not_extract(): - pass 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 index 7c529fa1..bfeb1133 100644 --- a/apps/backend/tests/benchmark/fixtures/adversarial/adv-source-edgecases/manifest.json +++ b/apps/backend/tests/benchmark/fixtures/adversarial/adv-source-edgecases/manifest.json @@ -4,15 +4,19 @@ "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 safe POSIX filename containing a backslash that normalizes to an escaping path.", + "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.0.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": [], "edges": [], "observations": [], "assertions": [], + "nodes": [ + {"nodeKind": "repository", "stableKey": "repo:root", "name": "repository", + "evidence": [{"path": "src/large.py", "startLine": 1, "endLine": 1, "extractor": "repository-inventory", "extractorVersion": "1.0.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.0.0", 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 index 86e5b897..c484fb57 100644 --- a/apps/backend/tests/benchmark/fixtures/adversarial/adv-ts-malformed/manifest.json +++ b/apps/backend/tests/benchmark/fixtures/adversarial/adv-ts-malformed/manifest.json @@ -4,14 +4,17 @@ "fixtureClass": "adversarial", "language": "typescript", "title": "Adversarial TypeScript — syntax error", - "description": "A file that fails to parse. It must emit an RI-SRC-MALFORMED diagnostic and no line-addressed facts. Exercises ts.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.0.0"], + "producerVersionSet": ["typescript-ast@1.0.0", "repository-inventory@1.0.0"], "constructsCovered": ["ts.syntax_error"], "deterministic": false, "expected": { - "nodes": [], "edges": [], "observations": [], "assertions": [], + "nodes": [ + {"nodeKind": "repository", "stableKey": "repo:root", "name": "repository", + "evidence": [{"path": "src/broken.ts", "startLine": 1, "endLine": 1, "extractor": "repository-inventory", "extractorVersion": "1.0.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.0.0", diff --git a/apps/backend/tests/benchmark/loader.py b/apps/backend/tests/benchmark/loader.py index 5ff07962..c1d9aaf0 100644 --- a/apps/backend/tests/benchmark/loader.py +++ b/apps/backend/tests/benchmark/loader.py @@ -21,7 +21,7 @@ from dataclasses import dataclass, field from fractions import Fraction from pathlib import Path -from typing import Any +from typing import Any, Mapping from app.extraction.support_matrix import SUPPORT_MATRIX as PRODUCTION_SUPPORT_MATRIX from app.intelligence import canonical @@ -33,7 +33,6 @@ decode_strict_utf8, is_binary, logical_line_count, - read_bytes, ) @@ -193,6 +192,23 @@ class ExpectedFact: 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 @@ -208,17 +224,12 @@ class LoadedFixture: 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).""" - files: dict[str, bytes] = {} - for path in sorted(self.directory.rglob("*")): - if not path.is_file() or path.name == "manifest.json": - continue - relative = path.relative_to(self.directory).as_posix() - files[relative] = path.read_bytes() - return files + return _fixture_source_files(self.directory, self.synthetic_files) def revision_value(self) -> str: """A real, reproducible ``sha256:`` upload identity over the stored bytes. @@ -367,13 +378,12 @@ def _build_fact(group: str, raw: dict[str, Any], *, where: str, producers: set[s def _validate_evidence_against_source( - fixture_dir: Path, span: EvidenceSpan, *, where: str + 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.""" - source_path = fixture_dir / span.path - _require(source_path.is_file(), f"{where}: evidence cites missing source file {span.path!r}") - data = read_bytes(source_path) + 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) @@ -393,6 +403,35 @@ def _validate_evidence_against_source( ) +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) @@ -424,6 +463,8 @@ def load_fixture(directory: Path, support_matrix: SupportMatrix) -> LoadedFixtur 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) @@ -454,7 +495,7 @@ def load_fixture(directory: Path, support_matrix: SupportMatrix) -> LoadedFixtur _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(directory, span, where=where) + _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. @@ -474,6 +515,7 @@ def load_fixture(directory: Path, support_matrix: SupportMatrix) -> LoadedFixtur deterministic=bool(data.get("deterministic", False)), expected=tuple(expected), max_source_bytes=max_source_bytes, + synthetic_files=synthetic_files, ) diff --git a/apps/backend/tests/benchmark/schema.py b/apps/backend/tests/benchmark/schema.py index f21c08b0..442ac066 100644 --- a/apps/backend/tests/benchmark/schema.py +++ b/apps/backend/tests/benchmark/schema.py @@ -22,6 +22,12 @@ # ``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( { diff --git a/apps/backend/tests/benchmark/test_loader.py b/apps/backend/tests/benchmark/test_loader.py index 0691c137..48b5090a 100644 --- a/apps/backend/tests/benchmark/test_loader.py +++ b/apps/backend/tests/benchmark/test_loader.py @@ -107,6 +107,40 @@ def test_sanity_base_manifest_loads(tmp_path: Path): assert symbol.language == "python" +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", [ diff --git a/apps/backend/tests/extraction/test_pipeline.py b/apps/backend/tests/extraction/test_pipeline.py index 0a75bd0f..fa1a8f41 100644 --- a/apps/backend/tests/extraction/test_pipeline.py +++ b/apps/backend/tests/extraction/test_pipeline.py @@ -19,9 +19,25 @@ def test_pipeline_rejects_escaping_paths_before_dispatch(): 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_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(): diff --git a/apps/backend/tests/test_snapshot_persistence.py b/apps/backend/tests/test_snapshot_persistence.py index cf742db7..a7f56486 100644 --- a/apps/backend/tests/test_snapshot_persistence.py +++ b/apps/backend/tests/test_snapshot_persistence.py @@ -9,6 +9,8 @@ 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, @@ -451,6 +453,59 @@ def test_fatal_diagnostic_fails_snapshot_but_nonfatal_diagnostic_can_seal(db): 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)) From 6cd94d9b821de079e43e878ca3942bd0d0339bb0 Mon Sep 17 00:00:00 2001 From: hardikuppal04 <262667963+hardikuppal04@users.noreply.github.com> Date: Fri, 17 Jul 2026 12:01:09 +0530 Subject: [PATCH 102/347] chore(api): complete OpenAPI accuracy pass (#83) --- apps/backend/app/api/openapi.py | 127 ++++++++++++++++ apps/backend/app/api/routes/ai.py | 127 ++++++++++++++-- apps/backend/app/api/routes/analysis.py | 107 ++++++++++++- apps/backend/app/api/routes/auth.py | 90 ++++++++++- apps/backend/app/api/routes/documentation.py | 36 ++++- apps/backend/app/api/routes/reports.py | 37 ++++- apps/backend/app/api/routes/repositories.py | 143 +++++++++++++++++- apps/backend/app/main.py | 63 +++++++- apps/backend/tests/test_openapi_contract.py | 151 +++++++++++++++++++ 9 files changed, 841 insertions(+), 40 deletions(-) create mode 100644 apps/backend/app/api/openapi.py create mode 100644 apps/backend/tests/test_openapi_contract.py diff --git a/apps/backend/app/api/openapi.py b/apps/backend/app/api/openapi.py new file mode 100644 index 00000000..a761eed5 --- /dev/null +++ b/apps/backend/app/api/openapi.py @@ -0,0 +1,127 @@ +"""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", + }, +} + + +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, 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 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, dict[str, Any]]: + """Combine one success example with route-specific standard errors.""" + responses = { + 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/routes/ai.py b/apps/backend/app/api/routes/ai.py index bc4e393a..5ff9658a 100644 --- a/apps/backend/app/api/routes/ai.py +++ b/apps/backend/app/api/routes/ai.py @@ -1,9 +1,11 @@ import json +from typing import Annotated -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Body, Depends from fastapi.responses import StreamingResponse from app.api.deps import get_ai_service, get_current_user +from app.api.openapi import documented_responses, error_responses, response_example from app.schemas.ai import AiProviderConfig, AiProviderPublicConfig, AiProviderTestRequest, AiProviderTestResponse, AiQueryRequest, AiQueryResponse from app.services.ai_service import AiService @@ -12,29 +14,134 @@ # 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"}, +} +_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": ["Show the authentication routes"], +} -@router.get("/config", response_model=AiProviderPublicConfig) + +@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: +@router.post( + "/stream", + response_class=StreamingResponse, + responses={ + 200: response_example( + "Server-sent events containing a repository-aware AI response.", + 'data: {"type":"content","content":"Authentication "}\\n\\n' + 'data: {"type":"done"}\\n\\n', + media_type="text/event-stream", + schema={"type": "string"}, + ), + **error_responses(401, 404, 422, 429, 502, 500), + }, +) +async def stream_ai( + request: Annotated[AiQueryRequest, Body(openapi_examples={"repository-question": _QUERY_REQUEST_EXAMPLE})], + service: AiService = Depends(get_ai_service), +) -> StreamingResponse: # Resolve the answer BEFORE returning StreamingResponse. service.query runs # ownership scoping and provider-config validation, so a cross-owner request # (404) or a missing provider key (422) must surface as a normal error diff --git a/apps/backend/app/api/routes/analysis.py b/apps/backend/app/api/routes/analysis.py index ad4cc267..0cf13e39 100644 --- a/apps/backend/app/api/routes/analysis.py +++ b/apps/backend/app/api/routes/analysis.py @@ -1,6 +1,7 @@ from fastapi import APIRouter, Depends from app.api.deps import get_analysis_service, get_current_user +from app.api.openapi import documented_responses from app.schemas.analysis import AnalysisStartResponse, AnalysisStatusResponse from app.schemas.architecture import ArchitectureResponse from app.schemas.dependencies import DependencyGraphResponse @@ -10,8 +11,20 @@ # Every analysis route requires auth; records are owner-scoped in AnalysisService. router = APIRouter(prefix="/analysis", tags=["analysis"], dependencies=[Depends(get_current_user)]) +_REPOSITORY_ID = "11111111-1111-1111-1111-111111111111" +_COMMON_ERRORS = (401, 404, 422, 429, 500) -@router.post("/{repository_id}/start", response_model=AnalysisStartResponse) + +@router.post( + "/{repository_id}/start", + response_model=AnalysisStartResponse, + responses=documented_responses( + 200, + "Repository analysis completed synchronously.", + {"repositoryId": _REPOSITORY_ID, "status": "completed"}, + *_COMMON_ERRORS, + ), +) def start_analysis( repository_id: str, service: AnalysisService = Depends(get_analysis_service), @@ -19,7 +32,24 @@ def start_analysis( return service.start(repository_id) -@router.get("/{repository_id}/status", response_model=AnalysisStatusResponse) +@router.get( + "/{repository_id}/status", + response_model=AnalysisStatusResponse, + responses=documented_responses( + 200, + "Current repository-analysis status.", + { + "repositoryId": _REPOSITORY_ID, + "status": "completed", + "stage": "completed", + "progress": 100, + "startedAt": "2026-07-17T00:00:00Z", + "completedAt": "2026-07-17T00:00:02Z", + "error": None, + }, + *_COMMON_ERRORS, + ), +) def get_analysis_status( repository_id: str, service: AnalysisService = Depends(get_analysis_service), @@ -27,7 +57,33 @@ def get_analysis_status( return service.status(repository_id) -@router.get("/{repository_id}/architecture", response_model=ArchitectureResponse) +@router.get( + "/{repository_id}/architecture", + response_model=ArchitectureResponse, + responses=documented_responses( + 200, + "Architecture model derived from repository intelligence.", + { + "repositoryId": _REPOSITORY_ID, + "repositoryName": "example-service", + "architectureType": "Layered application", + "detectedLayers": [], + "nodes": [], + "edges": [], + "modules": [], + "requestFlow": [], + "summary": { + "language": "Python", + "framework": "FastAPI", + "totalModules": 0, + "totalNodes": 0, + "entryPoint": "/app/main.py", + "architecturePattern": "Layered application", + }, + }, + *_COMMON_ERRORS, + ), +) def get_architecture( repository_id: str, service: AnalysisService = Depends(get_analysis_service), @@ -35,7 +91,23 @@ def get_architecture( return service.architecture_model(repository_id) -@router.get("/{repository_id}/dependencies", response_model=DependencyGraphResponse) +@router.get( + "/{repository_id}/dependencies", + response_model=DependencyGraphResponse, + responses=documented_responses( + 200, + "Dependency inventory and declared relationships.", + { + "repositoryId": _REPOSITORY_ID, + "nodes": [], + "edges": [], + "totalDependencies": 0, + "vulnerabilityAssessment": {"status": "not_computed"}, + "outdatedAssessment": {"status": "not_computed"}, + }, + *_COMMON_ERRORS, + ), +) def get_dependencies( repository_id: str, service: AnalysisService = Depends(get_analysis_service), @@ -43,7 +115,32 @@ 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, + "Engineering-review findings and improvement roadmap.", + { + "repositoryId": _REPOSITORY_ID, + "repositoryName": "example-service", + "generatedAt": "2026-07-17T00:00:00Z", + "summary": { + "overallScore": 100, + "overallTrend": "stable", + "criticalCount": 0, + "highCount": 0, + "mediumCount": 0, + "lowCount": 0, + "totalFindings": 0, + }, + "scores": [], + "findings": [], + "roadmap": [], + }, + *_COMMON_ERRORS, + ), +) def get_review( repository_id: str, service: AnalysisService = Depends(get_analysis_service), diff --git a/apps/backend/app/api/routes/auth.py b/apps/backend/app/api/routes/auth.py index 38651474..7b165899 100644 --- a/apps/backend/app/api/routes/auth.py +++ b/apps/backend/app/api/routes/auth.py @@ -1,6 +1,9 @@ -from fastapi import APIRouter, Cookie, Depends, Response, status +from typing import Annotated + +from fastapi import APIRouter, Body, Cookie, Depends, Response, status from app.api.deps import get_auth_service, get_current_user +from app.api.openapi import documented_responses, error_responses from app.auth.service import AuthService from app.core.config import Settings, get_settings from app.core.exceptions import UnauthorizedError @@ -11,6 +14,25 @@ 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( @@ -25,9 +47,22 @@ def _set_refresh_cookie(response: Response, raw_token: str, settings: Settings) ) -@router.post("/register", response_model=AuthResponse, status_code=status.HTTP_201_CREATED) +@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: RegisterRequest, + request: Annotated[RegisterRequest, Body(openapi_examples={"registration": _REGISTER_EXAMPLE})], response: Response, service: AuthService = Depends(get_auth_service), settings: Settings = Depends(get_settings), @@ -37,9 +72,21 @@ def register( return AuthResponse(access_token=access_token, user=UserResponse.model_validate(user)) -@router.post("/login", response_model=AuthResponse) +@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: LoginRequest, + request: Annotated[LoginRequest, Body(openapi_examples={"login": _LOGIN_EXAMPLE})], response: Response, service: AuthService = Depends(get_auth_service), settings: Settings = Depends(get_settings), @@ -49,7 +96,19 @@ def login( return AuthResponse(access_token=access_token, user=UserResponse.model_validate(user)) -@router.post("/refresh", response_model=AuthResponse) +@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), @@ -63,7 +122,11 @@ def refresh( return AuthResponse(access_token=access_token, user=UserResponse.model_validate(user)) -@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT) +@router.post( + "/logout", + status_code=status.HTTP_204_NO_CONTENT, + responses=error_responses(422, 429, 500), +) def logout( refresh_token: str | None = Cookie(default=None, alias=REFRESH_COOKIE), service: AuthService = Depends(get_auth_service), @@ -74,6 +137,17 @@ def logout( return response -@router.get("/me", response_model=UserResponse) +@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) diff --git a/apps/backend/app/api/routes/documentation.py b/apps/backend/app/api/routes/documentation.py index 37d4173a..d4e35ff2 100644 --- a/apps/backend/app/api/routes/documentation.py +++ b/apps/backend/app/api/routes/documentation.py @@ -1,16 +1,46 @@ -from fastapi import APIRouter, Depends +from typing import Annotated + +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 # 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", +} + -@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/reports.py b/apps/backend/app/api/routes/reports.py index c5df306b..948163bc 100644 --- a/apps/backend/app/api/routes/reports.py +++ b/apps/backend/app/api/routes/reports.py @@ -1,6 +1,9 @@ -from fastapi import APIRouter, Depends +from typing import Annotated + +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 @@ -8,10 +11,38 @@ # 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.", + _RESPONSE_EXAMPLE, + 401, + 404, + 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 1fd914a3..d6e1a7de 100644 --- a/apps/backend/app/api/routes/repositories.py +++ b/apps/backend/app/api/routes/repositories.py @@ -1,6 +1,9 @@ -from fastapi import APIRouter, Depends, Query, Response, UploadFile, status +from typing import Annotated + +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 from app.schemas.repository import ( GitHubImportRequest, RepositoryFileResponse, @@ -14,8 +17,67 @@ # 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", + "dataSource": "real", + "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": [], +} +_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), @@ -23,22 +85,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, + 422, + 429, + 500, + ), +) def get_repository( repository_id: str, service: RepositoryService = Depends(get_repository_service), @@ -46,7 +148,28 @@ def get_repository( return service.get_repository(repository_id) -@router.get("/{repository_id}/file", response_model=RepositoryFileResponse) +@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."), @@ -55,7 +178,11 @@ 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, 422, 429, 500), +) def delete_repository( repository_id: str, service: RepositoryService = Depends(get_repository_service), diff --git a/apps/backend/app/main.py b/apps/backend/app/main.py index 18c924aa..834568e3 100644 --- a/apps/backend/app/main.py +++ b/apps/backend/app/main.py @@ -11,6 +11,7 @@ from sqlalchemy import text from app.api.router import api_router +from app.api.openapi import documented_responses, response_example from app.core import database from app.core.config import get_settings from app.core.exceptions import ErrorResponse, register_exception_handlers @@ -22,6 +23,41 @@ 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: @@ -131,11 +167,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" @@ -151,7 +196,19 @@ 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"], + response_class=PlainTextResponse, + responses=documented_responses( + status.HTTP_200_OK, + "Prometheus-compatible runtime metrics.", + "partha_http_requests_total 1\\n", + status.HTTP_500_INTERNAL_SERVER_ERROR, + media_type="text/plain", + schema={"type": "string"}, + ), + ) def metrics() -> str: return runtime_metrics.render_prometheus() diff --git a/apps/backend/tests/test_openapi_contract.py b/apps/backend/tests/test_openapi_contract.py new file mode 100644 index 00000000..248d0f40 --- /dev/null +++ b/apps/backend/tests/test_openapi_contract.py @@ -0,0 +1,151 @@ +"""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. +""" + +from collections.abc import Mapping + + +PUBLIC_OPERATIONS = { + ("POST", "/auth/register"), + ("POST", "/auth/login"), + ("POST", "/auth/refresh"), + ("POST", "/auth/logout"), + ("GET", "/health"), + ("GET", "/ready"), + ("GET", "/metrics"), +} + +EXPECTED_RESPONSES = { + ("POST", "/auth/register"): {201, 409, 422, 429, 500}, + ("POST", "/auth/login"): {200, 401, 422, 429, 500}, + ("POST", "/auth/refresh"): {200, 401, 422, 429, 500}, + ("POST", "/auth/logout"): {204, 422, 429, 500}, + ("GET", "/auth/me"): {200, 401, 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, 422, 429, 500}, + ("GET", "/repositories/{repository_id}/file"): {200, 401, 404, 422, 429, 500}, + ("DELETE", "/repositories/{repository_id}"): {204, 401, 404, 422, 429, 500}, + ("POST", "/analysis/{repository_id}/start"): {200, 401, 404, 422, 429, 500}, + ("GET", "/analysis/{repository_id}/status"): {200, 401, 404, 422, 429, 500}, + ("GET", "/analysis/{repository_id}/architecture"): {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", "/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}, + ("POST", "/ai/stream"): {200, 401, 404, 422, 429, 502, 500}, + ("POST", "/documentation/generate"): {200, 401, 404, 422, 429, 500}, + ("POST", "/export"): {200, 401, 404, 422, 429, 500}, + ("GET", "/health"): {200, 500}, + ("GET", "/ready"): {200, 503, 500}, + ("GET", "/metrics"): {200, 500}, +} + +BODY_MEDIA_TYPES = { + ("POST", "/auth/register"): "application/json", + ("POST", "/auth/login"): "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", "/ai/stream"): "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 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_authentication_for_every_protected_operation(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: + assert not operation.get("security"), f"public operation unexpectedly requires auth: {key}" + else: + assert operation.get("security") == [{"HTTPBearer": []}], f"missing auth requirement: {key}" + + +def test_openapi_declares_exact_runtime_response_statuses(client): + document = client.get("/openapi.json").json() + + for key, expected_statuses in EXPECTED_RESPONSES.items(): + 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[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(): + success_status = 201 if 201 in EXPECTED_RESPONSES[key] else 204 if 204 in EXPECTED_RESPONSES[key] 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/event-stream" if key == ("POST", "/ai/stream") else "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" From 0d635c0ee9465155341636d8b9ecb589375fad8c Mon Sep 17 00:00:00 2001 From: hardikuppal04 <262667963+hardikuppal04@users.noreply.github.com> Date: Fri, 17 Jul 2026 21:40:18 +0530 Subject: [PATCH 103/347] fix(api): tighten OpenAPI auth and error contracts --- apps/backend/app/api/openapi.py | 23 +++++++ apps/backend/app/api/routes/analysis.py | 9 ++- apps/backend/app/api/routes/auth.py | 5 +- apps/backend/app/api/routes/repositories.py | 7 +- apps/backend/app/main.py | 17 ++++- apps/backend/tests/test_openapi_contract.py | 75 ++++++++++++++++----- 6 files changed, 111 insertions(+), 25 deletions(-) diff --git a/apps/backend/app/api/openapi.py b/apps/backend/app/api/openapi.py index a761eed5..497c38d0 100644 --- a/apps/backend/app/api/openapi.py +++ b/apps/backend/app/api/openapi.py @@ -73,6 +73,8 @@ }, } +_SUPPRESS_AUTOMATIC_422_MARKER = "x-partha-suppress-automatic-422" + def response_example( description: str, @@ -106,6 +108,27 @@ def error_responses(*status_codes: int) -> dict[int, dict[str, Any]]: } +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, diff --git a/apps/backend/app/api/routes/analysis.py b/apps/backend/app/api/routes/analysis.py index 0cf13e39..94cadb98 100644 --- a/apps/backend/app/api/routes/analysis.py +++ b/apps/backend/app/api/routes/analysis.py @@ -1,7 +1,7 @@ from fastapi import APIRouter, Depends from app.api.deps import get_analysis_service, get_current_user -from app.api.openapi import documented_responses +from app.api.openapi import documented_responses, suppress_automatic_validation_error from app.schemas.analysis import AnalysisStartResponse, AnalysisStatusResponse from app.schemas.architecture import ArchitectureResponse from app.schemas.dependencies import DependencyGraphResponse @@ -12,7 +12,7 @@ router = APIRouter(prefix="/analysis", tags=["analysis"], dependencies=[Depends(get_current_user)]) _REPOSITORY_ID = "11111111-1111-1111-1111-111111111111" -_COMMON_ERRORS = (401, 404, 422, 429, 500) +_COMMON_ERRORS = (401, 404, 429, 500) @router.post( @@ -24,6 +24,7 @@ {"repositoryId": _REPOSITORY_ID, "status": "completed"}, *_COMMON_ERRORS, ), + openapi_extra=suppress_automatic_validation_error(), ) def start_analysis( repository_id: str, @@ -49,6 +50,7 @@ def start_analysis( }, *_COMMON_ERRORS, ), + openapi_extra=suppress_automatic_validation_error(), ) def get_analysis_status( repository_id: str, @@ -83,6 +85,7 @@ def get_analysis_status( }, *_COMMON_ERRORS, ), + openapi_extra=suppress_automatic_validation_error(), ) def get_architecture( repository_id: str, @@ -107,6 +110,7 @@ def get_architecture( }, *_COMMON_ERRORS, ), + openapi_extra=suppress_automatic_validation_error(), ) def get_dependencies( repository_id: str, @@ -140,6 +144,7 @@ def get_dependencies( }, *_COMMON_ERRORS, ), + openapi_extra=suppress_automatic_validation_error(), ) def get_review( repository_id: str, diff --git a/apps/backend/app/api/routes/auth.py b/apps/backend/app/api/routes/auth.py index 7b165899..df838d36 100644 --- a/apps/backend/app/api/routes/auth.py +++ b/apps/backend/app/api/routes/auth.py @@ -3,7 +3,7 @@ from fastapi import APIRouter, Body, Cookie, Depends, Response, status from app.api.deps import get_auth_service, get_current_user -from app.api.openapi import documented_responses, error_responses +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 @@ -125,7 +125,8 @@ def refresh( @router.post( "/logout", status_code=status.HTTP_204_NO_CONTENT, - responses=error_responses(422, 429, 500), + responses=error_responses(429, 500), + openapi_extra=suppress_automatic_validation_error(), ) def logout( refresh_token: str | None = Cookie(default=None, alias=REFRESH_COOKIE), diff --git a/apps/backend/app/api/routes/repositories.py b/apps/backend/app/api/routes/repositories.py index d6e1a7de..5d247551 100644 --- a/apps/backend/app/api/routes/repositories.py +++ b/apps/backend/app/api/routes/repositories.py @@ -3,7 +3,7 @@ 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 +from app.api.openapi import documented_responses, error_responses, suppress_automatic_validation_error from app.schemas.repository import ( GitHubImportRequest, RepositoryFileResponse, @@ -136,10 +136,10 @@ def list_repositories( _REPOSITORY_EXAMPLE, 401, 404, - 422, 429, 500, ), + openapi_extra=suppress_automatic_validation_error(), ) def get_repository( repository_id: str, @@ -181,7 +181,8 @@ def get_repository_file( @router.delete( "/{repository_id}", status_code=status.HTTP_204_NO_CONTENT, - responses=error_responses(401, 404, 422, 429, 500), + responses=error_responses(401, 404, 429, 500), + openapi_extra=suppress_automatic_validation_error(), ) def delete_repository( repository_id: str, diff --git a/apps/backend/app/main.py b/apps/backend/app/main.py index 834568e3..4dd7f2fb 100644 --- a/apps/backend/app/main.py +++ b/apps/backend/app/main.py @@ -3,7 +3,7 @@ from os import getpid import logging from time import perf_counter -from typing import Literal +from typing import Any, Literal from fastapi import FastAPI, Request, status from fastapi.middleware.cors import CORSMiddleware @@ -11,7 +11,11 @@ from sqlalchemy import text from app.api.router import api_router -from app.api.openapi import documented_responses, response_example +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 @@ -105,6 +109,15 @@ 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 diff --git a/apps/backend/tests/test_openapi_contract.py b/apps/backend/tests/test_openapi_contract.py index 248d0f40..5277d781 100644 --- a/apps/backend/tests/test_openapi_contract.py +++ b/apps/backend/tests/test_openapi_contract.py @@ -5,36 +5,43 @@ 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"), - ("POST", "/auth/refresh"), + # Logout is deliberately idempotent when its refresh cookie is absent. ("POST", "/auth/logout"), ("GET", "/health"), ("GET", "/ready"), ("GET", "/metrics"), } +# 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", "/auth/login"): {200, 401, 422, 429, 500}, ("POST", "/auth/refresh"): {200, 401, 422, 429, 500}, - ("POST", "/auth/logout"): {204, 422, 429, 500}, + ("POST", "/auth/logout"): {204, 429, 500}, ("GET", "/auth/me"): {200, 401, 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, 422, 429, 500}, + ("GET", "/repositories/{repository_id}"): {200, 401, 404, 429, 500}, ("GET", "/repositories/{repository_id}/file"): {200, 401, 404, 422, 429, 500}, - ("DELETE", "/repositories/{repository_id}"): {204, 401, 404, 422, 429, 500}, - ("POST", "/analysis/{repository_id}/start"): {200, 401, 404, 422, 429, 500}, - ("GET", "/analysis/{repository_id}/status"): {200, 401, 404, 422, 429, 500}, - ("GET", "/analysis/{repository_id}/architecture"): {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}, + ("DELETE", "/repositories/{repository_id}"): {204, 401, 404, 429, 500}, + ("POST", "/analysis/{repository_id}/start"): {200, 401, 404, 429, 500}, + ("GET", "/analysis/{repository_id}/status"): {200, 401, 404, 429, 500}, + ("GET", "/analysis/{repository_id}/architecture"): {200, 401, 404, 429, 500}, + ("GET", "/analysis/{repository_id}/dependencies"): {200, 401, 404, 429, 500}, + ("GET", "/analysis/{repository_id}/review"): {200, 401, 404, 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}, @@ -76,28 +83,63 @@ def _response_media(operation: dict, status_code: int, media_type: str = "applic 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_authentication_for_every_protected_operation(client): +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: - assert not operation.get("security"), f"public operation unexpectedly requires auth: {key}" + 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_openapi_declares_exact_runtime_response_statuses(client): +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, expected_statuses in EXPECTED_RESPONSES.items(): + 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}" @@ -108,7 +150,7 @@ def test_standard_error_responses_use_the_error_envelope_and_an_example(client): assert "ErrorResponse" in components for key, operation in _operations(document.json()).items(): - for status_code in EXPECTED_RESPONSES[key] & STANDARD_ERROR_STATUSES: + 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" @@ -129,7 +171,8 @@ 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(): - success_status = 201 if 201 in EXPECTED_RESPONSES[key] else 204 if 204 in EXPECTED_RESPONSES[key] else 200 + 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" From 338dffcfd3e9c30bf74f9c68bf11b4ad60acfbb9 Mon Sep 17 00:00:00 2001 From: Shaurya K Sharma Date: Fri, 17 Jul 2026 20:55:58 +0100 Subject: [PATCH 104/347] feat(intelligence): resolve repository relationships --- apps/backend/.gitattributes | 1 + apps/backend/app/extraction/__init__.py | 2 + apps/backend/app/extraction/manifests.py | 176 ++++++ apps/backend/app/extraction/python.py | 95 +++- apps/backend/app/extraction/typescript.py | 238 +++++++- apps/backend/app/intelligence/__init__.py | 9 +- apps/backend/app/intelligence/resolution.py | 522 ++++++++++++++++++ .../min-py-decorator-route/manifest.json | 9 +- .../minimal/min-py-imports/manifest.json | 2 + .../min-ts-imports-exports/manifest.json | 4 + .../minimal/min-ts-route/manifest.json | 8 +- .../realistic/real-py-fastapi/manifest.json | 18 +- .../realistic/real-ts-service/manifest.json | 4 + .../extraction/test_dependency_manifests.py | 37 ++ .../backend/tests/extraction/test_pipeline.py | 3 +- .../tests/extraction/test_python_extractor.py | 20 + .../tests/extraction/test_python_routes.py | 6 +- .../tests/extraction/test_support_matrix.py | 16 + .../extraction/test_typescript_imports.py | 10 + .../extraction/test_typescript_routes.py | 15 + .../extraction/test_typescript_symbols.py | 10 + .../resolution/ambiguous-call/src/first.ts | 3 + .../resolution/ambiguous-call/src/second.ts | 3 + .../resolution/ambiguous-call/src/source.ts | 3 + .../tests/intelligence/test_resolution.py | 285 ++++++++++ docs/architecture/REPOSITORY_INTELLIGENCE.md | 2 +- .../REPOSITORY_INTELLIGENCE_RESOLUTION.md | 96 ++++ .../REPOSITORY_INTELLIGENCE_V1_RFC.md | 4 +- 28 files changed, 1577 insertions(+), 24 deletions(-) create mode 100644 apps/backend/.gitattributes create mode 100644 apps/backend/app/extraction/manifests.py create mode 100644 apps/backend/app/intelligence/resolution.py create mode 100644 apps/backend/tests/extraction/test_dependency_manifests.py create mode 100644 apps/backend/tests/intelligence/fixtures/resolution/ambiguous-call/src/first.ts create mode 100644 apps/backend/tests/intelligence/fixtures/resolution/ambiguous-call/src/second.ts create mode 100644 apps/backend/tests/intelligence/fixtures/resolution/ambiguous-call/src/source.ts create mode 100644 apps/backend/tests/intelligence/test_resolution.py create mode 100644 docs/architecture/REPOSITORY_INTELLIGENCE_RESOLUTION.md 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/app/extraction/__init__.py b/apps/backend/app/extraction/__init__.py index 1ffa0a3c..16be255d 100644 --- a/apps/backend/app/extraction/__init__.py +++ b/apps/backend/app/extraction/__init__.py @@ -11,6 +11,7 @@ ExtractionPipeline, ProducedExtraction, ) +from app.extraction.manifests import DependencyManifestExtractor __all__ = [ "ExtractedDiagnostic", @@ -20,6 +21,7 @@ "ExtractionResult", "Extractor", "DEFAULT_MAX_SOURCE_BYTES", + "DependencyManifestExtractor", "ExtractionPipeline", "ProducedExtraction", ] diff --git a/apps/backend/app/extraction/manifests.py b/apps/backend/app/extraction/manifests.py new file mode 100644 index 00000000..e8627d4a --- /dev/null +++ b/apps/backend/app/extraction/manifests.py @@ -0,0 +1,176 @@ +"""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 + +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.intelligence import canonical + + +_NPM_SECTIONS = ("dependencies", "devDependencies", "peerDependencies", "optionalDependencies") + + +class DependencyManifestExtractor: + """Extract direct npm/PyPI declarations as observed dependency facts.""" + + name = "dependency-manifest" + 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 {"package.json", "pyproject.toml", "requirements.txt"} + + 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): + return ExtractionResult( + diagnostics=( + ExtractedDiagnostic( + code=RI_SRC_MALFORMED, + category="malformed source", + severity="error", + message="dependency manifest could not be parsed", + path=normalized_path, + ), + ) + ) + + nodes: list[ExtractedNode] = [] + observations: list[ExtractedObservation] = [] + diagnostics: list[ExtractedDiagnostic] = [] + for ecosystem, name, line in declarations: + stable_key = self._dependency_key(ecosystem, name) + evidence, diagnostic = build_evidence( + normalized_path, line, 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=name, + language=None, + evidence=(evidence,), + ) + ) + observations.append( + ExtractedObservation( + observed_kind="dependency", + subject_kind="dependency", + subject_key=stable_key, + referent_text=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: + if ecosystem == "pypi": + name = re.sub(r"[-_.]+", "-", name).lower() + return canonical.normalize_stable_key("dependency", f"dep:{ecosystem}:{name}") + + @staticmethod + def _npm_declarations(text: str) -> list[tuple[str, str, int]]: + parsed = json.loads(text) + declarations: list[tuple[str, str, int]] = [] + for section in _NPM_SECTIONS: + dependencies = parsed.get(section, {}) + if not isinstance(dependencies, dict): + continue + for name in sorted(dependencies): + declarations.append(("npm", str(name), DependencyManifestExtractor._find_line(text, str(name)))) + return declarations + + @staticmethod + def _pyproject_declarations(text: str) -> list[tuple[str, str, int]]: + parsed = tomllib.loads(text) + values = parsed.get("project", {}).get("dependencies", []) + if not isinstance(values, list): + return [] + declarations: list[tuple[str, str, int]] = [] + for value in values: + name = DependencyManifestExtractor._python_requirement_name(str(value)) + if name: + declarations.append(("pypi", name, DependencyManifestExtractor._find_line(text, str(value)))) + return declarations + + @staticmethod + def _requirements_declarations(text: str) -> list[tuple[str, str, int]]: + declarations: list[tuple[str, str, int]] = [] + for line_number, raw in enumerate(text.splitlines(), start=1): + value = raw.split("#", 1)[0].strip() + if not value or value.startswith(("-", ".")): + continue + name = DependencyManifestExtractor._python_requirement_name(value) + if name: + declarations.append(("pypi", name, 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 + + @staticmethod + def _find_line(text: str, needle: str) -> int: + for line_number, line in enumerate(text.splitlines(), start=1): + if needle in line: + return line_number + return 1 diff --git a/apps/backend/app/extraction/python.py b/apps/backend/app/extraction/python.py index 0141a6a8..cef1b846 100644 --- a/apps/backend/app/extraction/python.py +++ b/apps/backend/app/extraction/python.py @@ -210,6 +210,7 @@ def extract(self, path: str, source: bytes) -> ExtractionResult: self._collect_symbols( tree, path, line_count, nodes, observations, diagnostics ) + self._collect_calls(tree, path, line_count, module_key, observations, diagnostics) self._collect_blind_spots(tree, path, line_count, diagnostics) return ExtractionResult( @@ -223,16 +224,23 @@ def _collect_imports( ) -> None: 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 != "*" ] + 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: @@ -254,6 +262,25 @@ def _collect_imports( 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) @@ -261,8 +288,10 @@ 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 @@ -335,16 +364,45 @@ def visit(scope: list[str], body) -> 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=canonical.normalize_stable_key("symbol", final_key), + 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( @@ -363,6 +421,41 @@ def visit(scope: list[str], body) -> None: 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. + """ + + for node in ast.walk(tree): + if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Name): + continue + # 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"}: + continue + 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) + continue + observations.append( + ExtractedObservation( + observed_kind="call", + subject_kind="module", + subject_key=module_key, + referent_text=node.func.id, + ordinal=_UNASSIGNED_ORDINAL, + evidence=evidence, + ) + ) + def _attribute_root(self, node): """Resolve ``a.b.c`` to its root ``Name``, or None if not name-rooted.""" diff --git a/apps/backend/app/extraction/typescript.py b/apps/backend/app/extraction/typescript.py index 1ad0afce..26d7306d 100644 --- a/apps/backend/app/extraction/typescript.py +++ b/apps/backend/app/extraction/typescript.py @@ -140,8 +140,10 @@ def extract(self, path: str, source: bytes) -> ExtractionResult: 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, observations) + self._collect_routes(tree.root_node, path, line_count, file_key, nodes, observations) + self._collect_calls(tree.root_node, path, line_count, file_key, observations) self._collect_blind_spots(tree.root_node, path, line_count, diagnostics) return ExtractionResult( nodes=tuple(nodes), @@ -169,12 +171,112 @@ def walk(node): 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_routes(self, root, path, line_count, file_key, observations) -> None: + 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 == "class_declaration": + name = node.child_by_field_name("name") + if name is not None: + class_key = canonical.normalize_stable_key( + "symbol", symbol_stable_key(path, [], self._node_text(name, source)) + ) + if class_key in symbols: + 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: + 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(target, 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) -> None: """Emit a ``route`` observation per confirmed react-router path literal. Only two contexts count: a ``path`` key inside an argument to a router @@ -186,8 +288,10 @@ def _collect_routes(self, root, path, line_count, file_key, observations) -> Non source = root.text seen: set[int] = set() + route_ordinal = 0 - def emit(node, literal): + def emit(node, literal, handler_referent=None, handler_node=None): + nonlocal route_ordinal if node.id in seen: return seen.add(node.id) @@ -196,13 +300,47 @@ def emit(node, literal): 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="file", - subject_key=file_key, referent_text=literal, + 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") @@ -222,15 +360,38 @@ def collect_route_entry(node): 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 - path_value = pair_value(pair, "path") - if path_value is not None and path_value.type == "string": - emit(pair, self._node_text(path_value, source).strip("'\"`")) + candidate_path = pair_value(pair, "path") + if candidate_path is not None and candidate_path.type == "string": + path_pair, path_value = pair, 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: - collect_route_table(children_value) + 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 @@ -256,13 +417,70 @@ def walk(node): 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"): - emit(child, self._node_text(parts[1], source).strip("'\"{}`")) + path_attribute = child + path_literal = self._node_text(parts[1], source).strip("'\"{}`") + 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 _collect_calls(self, root, path, line_count, file_key, observations) -> None: + """Record direct identifier calls; target selection is resolver work.""" + + source = root.text + + def walk(node): + if node.type == "call_expression": + 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. + if function_name != "require": + 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, + ) + ) for child in node.named_children: walk(child) diff --git a/apps/backend/app/intelligence/__init__.py b/apps/backend/app/intelligence/__init__.py index 6322a878..3884ad55 100644 --- a/apps/backend/app/intelligence/__init__.py +++ b/apps/backend/app/intelligence/__init__.py @@ -1,5 +1,12 @@ 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", "SnapshotStore"] +__all__ = [ + "RelationshipResolver", + "RepositoryIntelligenceEngine", + "RepositoryIntelligence", + "ResolutionResult", + "SnapshotStore", +] diff --git a/apps/backend/app/intelligence/resolution.py b/apps/backend/app/intelligence/resolution.py new file mode 100644 index 00000000..a7ed090c --- /dev/null +++ b/apps/backend/app/intelligence/resolution.py @@ -0,0 +1,522 @@ +"""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 dataclasses import dataclass +import posixpath +import re + +from sqlalchemy import select + +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`` / ``implements`` + A direct reference. The resolver accepts a full stable key or a simple + symbol name; names with more than one candidate stay ambiguous. + ``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``. + + New extractors can add observation kinds without making this class guess: + unsupported kinds are simply not relationship inputs. + """ + + name = "relationship-resolver" + version = "1.0.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) -> 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. + """ + + 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( + self.store.db.scalars( + select(RiNode).where(RiNode.snapshot_id == snapshot.snapshot_id) + ) + ) + nodes_by_key = {node.stable_key: node for node in nodes} + observations = sorted( + self.store.db.scalars( + select(RiObservation).where(RiObservation.snapshot_id == snapshot.snapshot_id) + ), + key=lambda item: item.observation_id, + ) + 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) + ): + 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 = [ + _ObservedInput(observation, sorted(evidence_by_observation.get(observation.id, ()), key=self._evidence_key)[0]) + for observation in observations + if evidence_by_observation.get(observation.id) + ] + inputs_by_kind: dict[str, list[_ObservedInput]] = defaultdict(list) + for input_ in inputs: + 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"]: + 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) + + edges_added = 0 + diagnostics_added = 0 + + for input_ in inputs_by_kind["definition"]: + added, diagnosed = self._resolve_definition(input_, nodes_by_key) + edges_added += added + diagnostics_added += diagnosed + + for input_ in inputs_by_kind["import"]: + added, diagnosed = self._resolve_import(input_, nodes_by_key) + edges_added += added + diagnostics_added += diagnosed + + for input_ in inputs_by_kind["call"]: + 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"]: + 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"]: + added, diagnosed = self._resolve_dependency(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"]: + route_handlers[input_.observation.subject_key].append(input_) + for input_ in inputs_by_kind["route"]: + 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) + + 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] + ) -> 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) + 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 = nodes_by_key.get(input_.observation.subject_key) + if subject is None or subject.node_kind != "symbol": + subject = self._containing_symbol(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_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 if candidate.stable_key != subject.stable_key} + 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], + ) -> 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)) + 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 = self._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"} + + @staticmethod + def _package_root(specifier: str, source_path: str) -> str: + if source_path.endswith(".py"): + return specifier.split(".", 1)[0] + if specifier.startswith("@"): + return "/".join(specifier.split("/")[:2]) + return specifier.split("/", 1)[0] + + 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]: + 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] + bound_candidates: list[RiNode] = [] + source = nodes_by_key.get(f"file:{source_path}") + if source is not None: + 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:") + 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}::") + ) + if bound_candidates: + return bound_candidates + return [ + node for node in nodes_by_key.values() + if node.node_kind == "symbol" and node.name == referent + ] + + @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, + ) 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 index 6d98a99f..762be129 100644 --- 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 @@ -21,16 +21,21 @@ {"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.0.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.0.0"}]} + "evidence": [{"path": "src/api.py", "startLine": 12, "endLine": 13, "granularity": "span", "extractor": "python-ast", "extractorVersion": "1.0.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.0.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.0.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.0.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.0.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.0.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.0.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.0.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.0.0"}}, - {"observedKind":"route","subjectKind":"symbol","subjectKey":"src/api.py::health","referentText":"/health","ordinal":1,"constructs":["py.fastapi_route"],"evidence":{"path":"src/api.py","startLine":11,"endLine":11,"extractor":"python-ast","extractorVersion":"1.0.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.0.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.0.0"}} ], "assertions": [], "diagnostics": [] } 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 index 50984982..af10d67e 100644 --- a/apps/backend/tests/benchmark/fixtures/minimal/min-py-imports/manifest.json +++ b/apps/backend/tests/benchmark/fixtures/minimal/min-py-imports/manifest.json @@ -26,6 +26,8 @@ {"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.0.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.0.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.0.0"}} ], "assertions": [], "diagnostics": [] 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 index b1f0a359..342288cb 100644 --- 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 @@ -23,8 +23,12 @@ "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.0.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.0.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.0.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.0.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.0.0"}}, {"observedKind": "import", "subjectKind": "file", "subjectKey": "file:src/index.ts", "referentText": "fs", "ordinal": 1, "constructs": ["ts.reexport"], 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 index b043a636..851e8e4d 100644 --- a/apps/backend/tests/benchmark/fixtures/minimal/min-ts-route/manifest.json +++ b/apps/backend/tests/benchmark/fixtures/minimal/min-ts-route/manifest.json @@ -19,13 +19,17 @@ {"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.0.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.0.0"}]} + "evidence": [{"path": "src/router.ts", "startLine": 1, "endLine": 3, "extractor": "typescript-ast", "extractorVersion": "1.0.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.0.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.0.0"}}, - {"observedKind": "route", "subjectKind": "file", "subjectKey": "file:src/router.ts", "referentText": "/status", "ordinal": 1, "constructs": ["ts.route"], + {"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.0.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.0.0"}} ], "assertions": [], "diagnostics": [] 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 index 837e0524..b34957e1 100644 --- a/apps/backend/tests/benchmark/fixtures/realistic/real-py-fastapi/manifest.json +++ b/apps/backend/tests/benchmark/fixtures/realistic/real-py-fastapi/manifest.json @@ -25,7 +25,11 @@ {"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.0.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.0.0"}]} + "evidence": [{"path": "src/service.py", "startLine": 20, "endLine": 21, "granularity": "span", "extractor": "python-ast", "extractorVersion": "1.0.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.0.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.0.0"}]} ], "edges": [ {"subjectKind": "repository", "subjectKey": "repo:root", "predicate": "contains", "objectKind": "file", "objectKey": "file:src/service.py", @@ -35,15 +39,23 @@ "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.0.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.0.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.0.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.0.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.0.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.0.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.0.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.0.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.0.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.0.0"}}, - {"observedKind": "route", "subjectKind": "symbol", "subjectKey": "src/service.py::read_user", "referentText": "/users/{user_id}", "ordinal": 1, "constructs": ["py.fastapi_route"], + {"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.0.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.0.0"}}, - {"observedKind": "route", "subjectKind": "symbol", "subjectKey": "src/service.py::create_user", "referentText": "/users", "ordinal": 1, "constructs": ["py.fastapi_route"], + {"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.0.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.0.0"}} ], "assertions": [ 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 index a15b6335..e4139228 100644 --- a/apps/backend/tests/benchmark/fixtures/realistic/real-ts-service/manifest.json +++ b/apps/backend/tests/benchmark/fixtures/realistic/real-ts-service/manifest.json @@ -25,6 +25,10 @@ "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.0.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.0.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.0.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.0.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.0.0"}} ], 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..dc1e1579 --- /dev/null +++ b/apps/backend/tests/extraction/test_dependency_manifests.py @@ -0,0 +1,37 @@ +from app.extraction.manifests import DependencyManifestExtractor + + +EXTRACTOR = DependencyManifestExtractor() + + +def _extract(path: str, source: str): + return EXTRACTOR.extract(path, source.encode("utf-8")) + + +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"] diff --git a/apps/backend/tests/extraction/test_pipeline.py b/apps/backend/tests/extraction/test_pipeline.py index fa1a8f41..176d2aed 100644 --- a/apps/backend/tests/extraction/test_pipeline.py +++ b/apps/backend/tests/extraction/test_pipeline.py @@ -1,11 +1,12 @@ 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( - (PythonExtractor(), TypeScriptExtractor()), + (DependencyManifestExtractor(), PythonExtractor(), TypeScriptExtractor()), max_source_bytes=max_source_bytes, ) diff --git a/apps/backend/tests/extraction/test_python_extractor.py b/apps/backend/tests/extraction/test_python_extractor.py index ef9b94ca..8ea52bec 100644 --- a/apps/backend/tests/extraction/test_python_extractor.py +++ b/apps/backend/tests/extraction/test_python_extractor.py @@ -49,3 +49,23 @@ def test_relative_imports_preserve_level_in_referent_text(): 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")] diff --git a/apps/backend/tests/extraction/test_python_routes.py b/apps/backend/tests/extraction/test_python_routes.py index 434d4cc4..9ad5fc26 100644 --- a/apps/backend/tests/extraction/test_python_routes.py +++ b/apps/backend/tests/extraction/test_python_routes.py @@ -63,4 +63,8 @@ def test_fastapi_route_decorator_yields_literal_path_observation(): 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::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_support_matrix.py b/apps/backend/tests/extraction/test_support_matrix.py index d55f7490..29a0cb69 100644 --- a/apps/backend/tests/extraction/test_support_matrix.py +++ b/apps/backend/tests/extraction/test_support_matrix.py @@ -73,6 +73,22 @@ def test_python_blind_spot_emits_a_diagnostic(construct): ) +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] diff --git a/apps/backend/tests/extraction/test_typescript_imports.py b/apps/backend/tests/extraction/test_typescript_imports.py index ff23e27d..69e9426d 100644 --- a/apps/backend/tests/extraction/test_typescript_imports.py +++ b/apps/backend/tests/extraction/test_typescript_imports.py @@ -16,3 +16,13 @@ def test_imports_become_observations(): 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 index a0a1d80e..ebfc3050 100644 --- a/apps/backend/tests/extraction/test_typescript_routes.py +++ b/apps/backend/tests/extraction/test_typescript_routes.py @@ -23,6 +23,21 @@ def test_jsx_route_path_becomes_route_observation(): assert paths == ["/settings"] +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"] diff --git a/apps/backend/tests/extraction/test_typescript_symbols.py b/apps/backend/tests/extraction/test_typescript_symbols.py index 3c2b38d4..97ff17db 100644 --- a/apps/backend/tests/extraction/test_typescript_symbols.py +++ b/apps/backend/tests/extraction/test_typescript_symbols.py @@ -65,3 +65,13 @@ def test_exported_function_carries_exported_property(): 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_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")] 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..cd471456 --- /dev/null +++ b/apps/backend/tests/intelligence/test_resolution.py @@ -0,0 +1,285 @@ +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.0.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): + 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/a.ts::shared", name="shared", path="src/a.ts") + _node(store, snapshot, "symbol", "src/b.ts::shared", name="shared", path="src/b.ts") + _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/a.ts::shared", "src/b.ts::shared"], + } + assert _edge_triples(session, snapshot) == set() + 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): + 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="1.0.0", + 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="1.0.0", + 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.0.0", RESOLVER_PRODUCER]) + store.add_node(snapshot, node_kind="repository", stable_key="repo:root", evidence=[ + Evidence("src/source.ts", 1, 1, "typescript-ast", "1.0.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.0.0", RESOLVER_PRODUCER]) + store.add_node(snapshot, node_kind="repository", stable_key="repo:root", evidence=[ + Evidence("app/routes.py", 1, 1, "python-ast", "1.0.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.0.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): + fixture = FIXTURE_ROOT / "ambiguous-call" + store, snapshot = _store(session, ["typescript-ast@1.0.0", RESOLVER_PRODUCER]) + store.add_node(snapshot, node_kind="repository", stable_key="repo:root", evidence=[ + Evidence("src/source.ts", 1, 1, "typescript-ast", "1.0.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, diagnostic.details["candidates"]) for diagnostic in diagnostics] == [ + ("RI-RES-AMBIGUOUS", ["src/first.ts::shared", "src/second.ts::shared"]) + ] + assert not [edge for edge in _edge_triples(session, snapshot) if edge[1] == "calls"] + assert store.seal(snapshot).state == "completed" diff --git a/docs/architecture/REPOSITORY_INTELLIGENCE.md b/docs/architecture/REPOSITORY_INTELLIGENCE.md index 496a84a4..55bcbd52 100644 --- a/docs/architecture/REPOSITORY_INTELLIGENCE.md +++ b/docs/architecture/REPOSITORY_INTELLIGENCE.md @@ -22,7 +22,7 @@ If your feature needs a repository fact that does not exist yet, the answer is a The production extraction path is still one Pydantic model — `RepositoryIntelligence` ([`app/intelligence/models.py`](../../apps/backend/app/intelligence/models.py)) — built by one engine — `RepositoryIntelligenceEngine` ([`app/intelligence/engine.py`](../../apps/backend/app/intelligence/engine.py)) — and serialized onto the repository row. That blob is retained as explicitly legacy/unverified compatibility data. -The `ri.v1` persistence boundary now also exists: 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, their support matrices, and the repository-level source-policy pipeline also exist under `app/extraction`; the Issue #94 benchmark executes and validates that real pipeline. Product ingestion still does not run either the legacy regex output or the new extractors through the normalized snapshot tables. Resolution, query APIs, durable job orchestration, and consumer migration remain separate work (#91–#93 and #95). +The `ri.v1` persistence boundary now also exists: 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, their support matrices, the repository-level source-policy pipeline, and deterministic [relationship resolution](REPOSITORY_INTELLIGENCE_RESOLUTION.md) over stored observations exist under `app/extraction` and `app/intelligence`; the Issue #94 benchmark executes and validates the extraction pipeline. Product ingestion still does not run either the legacy regex output or the new extractors through the normalized snapshot tables. Query APIs, durable job orchestration, and consumer migration remain separate work (#92, #93, and #95). ```mermaid flowchart LR diff --git a/docs/architecture/REPOSITORY_INTELLIGENCE_RESOLUTION.md b/docs/architecture/REPOSITORY_INTELLIGENCE_RESOLUTION.md new file mode 100644 index 00000000..71c5164e --- /dev/null +++ b/docs/architecture/REPOSITORY_INTELLIGENCE_RESOLUTION.md @@ -0,0 +1,96 @@ +# 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. + +## 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 `calls`, `implements`, `routes_to` lookup | +| `call` | Direct named call | `calls` | +| `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` | + +`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. + +## 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. Python dotted imports also consider +each stored module prefix, which preserves the existing extractor representation +for `from .pkg import symbol`. Match candidates only against stored file nodes. + +For a bare specifier, 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`. + +### References and implementations + +A direct stable-key referent wins. Otherwise, the resolver checks a same-file +top-level symbol, then the explicit `import_binding` records for the source +file, and finally all snapshot symbols with exactly that name. Multiple matches +are ambiguous. TypeScript extraction emits `implements` only for direct +`class ... implements ...` syntax; `extends` is intentionally not repurposed as +an `implements` fact. + +### 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. + +## Truth classes and provenance + +The resolver emits only `resolved` edges through `SnapshotStore.add_edge`. Each +edge has `relationship-resolver@1.0.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, ambiguous references, actual +TypeScript alias resolution, and FastAPI decorator routes. Both warning paths +are sealed successfully to prove that honest partial knowledge remains usable. diff --git a/docs/architecture/REPOSITORY_INTELLIGENCE_V1_RFC.md b/docs/architecture/REPOSITORY_INTELLIGENCE_V1_RFC.md index 93a35f61..c782f853 100644 --- a/docs/architecture/REPOSITORY_INTELLIGENCE_V1_RFC.md +++ b/docs/architecture/REPOSITORY_INTELLIGENCE_V1_RFC.md @@ -1724,7 +1724,7 @@ rules), the three columns are explicit: | Symbol spans | Python/TypeScript extractors emit required spans; legacy `SourceSymbol` remains spanless | Required line spans (§6) | **Producer implemented** (#89/#90); product consumption remains #92/#93 | | Extraction | Legacy product ingestion still uses regex; standalone AST/tree-sitter extractors and support matrices are implemented and benchmarked | Syntax-aware extractors with support matrices | **Producer implemented** (#89/#90); durable product integration remains #93 | | Revision identity | Indexed `revision_kind`/`revision_value`/`revision_ref`; `commitSha` is API compatibility only | Indexed immutable columns (§3) | **Implemented** (#87) | -| Relationships | 4 of 8 declared types emitted; imports as text | Resolved edges + diagnostics (§5) | **Unimplemented** (#91) | +| Relationships | Deterministic resolver over stored observations, with resolved edges and explicit unresolved/ambiguous diagnostics | Resolved edges + diagnostics (§5) | **Implemented** (#91); product job wiring remains #93 | | Inferred entity properties | Legacy heuristic module roles remain in the compatibility blob; the snapshot store supports separate validated assertions | Separate inferred property assertions; observed nodes remain unique (§5.6) | **Persistence implemented** (#88); production inference/querying remains #91/#92 | | Provenance | Extractors emit path + span + producer/version and the normalized store validates them; legacy product consumers still receive file paths only | Path + span + extractor/version (§6) | **Producer and persistence implemented** (#88–#90); product orchestration/querying remains #92/#93 | | Query API | Consumers read the whole blob | Versioned owner-scoped read API (§9.5) | **Unimplemented** (#92) | @@ -1736,7 +1736,7 @@ behavior.** RFC-0001 is **Accepted** — independently ratified by ([§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 -and #94 benchmark are implemented; resolution, queries, jobs, and consumer migration remain +and #94 benchmark plus #91 deterministic resolution are implemented; queries, jobs, and consumer migration remain downstream work and are not current product behavior. No existing documentation is rewritten by this RFC to imply otherwise. From 4395e8e1383d56297803302d161feb1d437e9961 Mon Sep 17 00:00:00 2001 From: hardikuppal04 <262667963+hardikuppal04@users.noreply.github.com> Date: Sat, 18 Jul 2026 02:21:26 +0530 Subject: [PATCH 105/347] test(migrations): validate PostgreSQL round trips (#85) --- .github/workflows/ci.yml | 11 ++-- apps/backend/README.md | 2 +- apps/backend/tests/test_migrations.py | 78 +++++++++++++++++++++------ 3 files changed, 68 insertions(+), 23 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aa13afe0..e160bcf3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -72,12 +72,11 @@ jobs: needs: repository-hygiene runs-on: ubuntu-latest - # Postgres so the gated refresh-token concurrency test runs against a real - # database (SQLite serializes writes and cannot exercise the row-lock race), - # and Redis so the gated rate-limit backend test exercises the real atomic - # script and TTL behaviour. Every other test uses per-test SQLite and the - # in-memory rate-limit store, so neither service is required for the rest - # of the suite to run. + # 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 diff --git a/apps/backend/README.md b/apps/backend/README.md index 37ebfe9f..45d27116 100644 --- a/apps/backend/README.md +++ b/apps/backend/README.md @@ -33,7 +33,7 @@ python -m pytest # from apps/backend npm run test:backend # from the repository root ``` -Tests run against per-test SQLite and the in-memory rate limiter. Three tests are gated on real services — refresh-token concurrency (PostgreSQL) and the Redis rate-limit backend — and skip unless `PARTHA_TEST_PG_URL` and `PARTHA_TEST_REDIS_URL` are set. CI provides both. +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. ## Migrations diff --git a/apps/backend/tests/test_migrations.py b/apps/backend/tests/test_migrations.py index 96dcdef9..c93b80c1 100644 --- a/apps/backend/tests/test_migrations.py +++ b/apps/backend/tests/test_migrations.py @@ -1,36 +1,82 @@ +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. - Alembic's env.py reads the URL from settings, so point it at a throwaway - SQLite file. Running up -> down -> up proves both directions and that the - down does not leave state that blocks a re-apply. + 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. """ - database_path = tmp_path / "migration-roundtrip.db" - monkeypatch.setenv("DATABASE_URL", f"sqlite:///{database_path}") - monkeypatch.setenv("CORS_ORIGINS", "http://testserver") - - from app.core import config + with _migration_database_url(tmp_path) as database_url: + monkeypatch.setenv("DATABASE_URL", database_url) + monkeypatch.setenv("CORS_ORIGINS", "http://testserver") - config.get_settings.cache_clear() - try: - cfg = Config(str(BACKEND_ROOT / "alembic.ini")) - cfg.set_main_option("script_location", str(BACKEND_ROOT / "alembic")) + from app.core import config - command.upgrade(cfg, "head") - command.downgrade(cfg, "base") - command.upgrade(cfg, "head") - finally: 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() + + 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_revision_backfill_classifies_exact_legacy_values_and_downgrade_preserves_metadata( From 5eb4e35ee99372166d8fd020d3394777286a7ec4 Mon Sep 17 00:00:00 2001 From: Shaurya K Sharma Date: Sat, 18 Jul 2026 00:36:37 +0100 Subject: [PATCH 106/347] fix(intelligence): resolve edges only from proven syntax facts (#91) Address review findings on evidence-backed resolution and manifest provenance: - resolution: drop the repository-wide same-name fallback in reference resolution. calls/implements/routes_to resolve only via a full stable key, a same-file definition, or an explicit import binding. A broken binding or a lone same-named symbol elsewhere stays unresolved, and multiple binding targets stay ambiguous. - resolution: resolve absolute `from a.b import c` using the stored binding module specifier so the import edge can reach the module file `a/b.py`, preferring any local module over a same-root dependency. - manifests: locate dependency declaration lines structurally (JSON section keys, ordered TOML array elements) instead of a first-substring match, so descriptions, scripts, or a matching package name no longer steal provenance; each npm section is handled independently. - manifests: validate manifest structure and fail closed as RI-SRC-MALFORMED for array/scalar roots, non-object sections, a non-table project, and non-list or non-string dependencies, rather than raising AttributeError/TypeError or silently emitting an empty list. - docs: describe the actual evidence-backed algorithm and drop the promise of a repository-wide name fallback. --- apps/backend/app/extraction/manifests.py | 276 +++++++++++++++++- apps/backend/app/intelligence/resolution.py | 87 ++++-- .../extraction/test_dependency_manifests.py | 96 ++++++ .../tests/intelligence/test_resolution.py | 189 +++++++++++- .../REPOSITORY_INTELLIGENCE_RESOLUTION.md | 58 ++-- 5 files changed, 639 insertions(+), 67 deletions(-) diff --git a/apps/backend/app/extraction/manifests.py b/apps/backend/app/extraction/manifests.py index e8627d4a..dc5a23d5 100644 --- a/apps/backend/app/extraction/manifests.py +++ b/apps/backend/app/extraction/manifests.py @@ -30,6 +30,33 @@ _NPM_SECTIONS = ("dependencies", "devDependencies", "peerDependencies", "optionalDependencies") +class _ManifestStructureError(Exception): + """A manifest decoded cleanly but is not a supported dependency structure. + + Raised by the section readers when a top-level root, a dependency section, + or an individual entry has an unexpected type, or when an exact declaration + line cannot be located. It is caught alongside the decoder errors so every + such case fails closed as ``RI-SRC-MALFORMED`` rather than escaping as an + ``AttributeError``/``TypeError`` 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 + + class DependencyManifestExtractor: """Extract direct npm/PyPI declarations as observed dependency facts.""" @@ -70,14 +97,14 @@ def extract(self, path: str, source: bytes) -> ExtractionResult: declarations = self._pyproject_declarations(text) else: declarations = self._requirements_declarations(text) - except (json.JSONDecodeError, tomllib.TOMLDecodeError): + 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", + message="dependency manifest could not be parsed or has an unsupported structure", path=normalized_path, ), ) @@ -129,26 +156,53 @@ def _dependency_key(ecosystem: str, name: str) -> str: @staticmethod def _npm_declarations(text: str) -> list[tuple[str, str, int]]: parsed = json.loads(text) + if not isinstance(parsed, dict): + raise _ManifestStructureError("package.json root is not an object") + member_lines = DependencyManifestExtractor._json_object_member_lines(text) declarations: list[tuple[str, str, int]] = [] for section in _NPM_SECTIONS: - dependencies = parsed.get(section, {}) - if not isinstance(dependencies, dict): + 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): - declarations.append(("npm", str(name), DependencyManifestExtractor._find_line(text, str(name)))) + 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(("npm", str(name), line)) return declarations @staticmethod def _pyproject_declarations(text: str) -> list[tuple[str, str, int]]: parsed = tomllib.loads(text) - values = parsed.get("project", {}).get("dependencies", []) + 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 [] - declarations: list[tuple[str, str, int]] = [] for value in values: - name = DependencyManifestExtractor._python_requirement_name(str(value)) + if not isinstance(value, str): + raise _ManifestStructureError("pyproject dependency entry is not a string") + element_lines = DependencyManifestExtractor._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[tuple[str, str, int]] = [] + for value, line in zip(values, element_lines): + name = DependencyManifestExtractor._python_requirement_name(value) if name: - declarations.append(("pypi", name, DependencyManifestExtractor._find_line(text, str(value)))) + declarations.append(("pypi", name, line)) return declarations @staticmethod @@ -168,9 +222,203 @@ 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. + @staticmethod - def _find_line(text: str, needle: str) -> int: - for line_number, line in enumerate(text.splitlines(), start=1): - if needle in line: - return line_number - return 1 + 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 — + exactly the npm dependency sections. ``text`` has already parsed as JSON + (``json.loads`` gates malformed input), so the scan is string-aware and + does not re-validate. Braces and colons inside string literals never + affect nesting. + """ + + tokens = DependencyManifestExtractor._json_tokens(text) + root, _ = DependencyManifestExtractor._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 + + @staticmethod + 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 + + @staticmethod + 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 = DependencyManifestExtractor._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 = DependencyManifestExtractor._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 + + @staticmethod + 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 = DependencyManifestExtractor._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 = DependencyManifestExtractor._toml_skip_string(text, i, line) + elif c in "\"'": + i, line = DependencyManifestExtractor._toml_skip_string(text, i, line) + else: + i += 1 + if not entered: + return None + return element_lines + + @staticmethod + 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 + + @staticmethod + 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 diff --git a/apps/backend/app/intelligence/resolution.py b/apps/backend/app/intelligence/resolution.py index a7ed090c..9e39569e 100644 --- a/apps/backend/app/intelligence/resolution.py +++ b/apps/backend/app/intelligence/resolution.py @@ -49,8 +49,10 @@ class RelationshipResolver: A TypeScript/Python module specifier. It resolves a local file or an already-observed dependency node. ``call`` / ``implements`` - A direct reference. The resolver accepts a full stable key or a simple - symbol name; names with more than one candidate stay ambiguous. + 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. 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. @@ -134,7 +136,7 @@ def resolve(self, snapshot: RiSnapshot) -> ResolutionResult: diagnostics_added += diagnosed for input_ in inputs_by_kind["import"]: - added, diagnosed = self._resolve_import(input_, nodes_by_key) + added, diagnosed = self._resolve_import(input_, nodes_by_key, bindings_by_file) edges_added += added diagnostics_added += diagnosed @@ -192,7 +194,10 @@ def _resolve_definition( return 2, 0 def _resolve_import( - self, input_: _ObservedInput, nodes_by_key: dict[str, RiNode] + 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: @@ -200,7 +205,13 @@ def _resolve_import( 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) + 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( @@ -294,12 +305,22 @@ def _import_candidates( 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" @@ -371,38 +392,46 @@ def _reference_candidates( 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] - bound_candidates: list[RiNode] = [] source = nodes_by_key.get(f"file:{source_path}") - if source is not None: - for specifier, imported, local in bindings: - if local != referent: + 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 - 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:") - 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}::") - ) - if bound_candidates: - return bound_candidates - return [ - node for node in nodes_by_key.values() - if node.node_kind == "symbol" and node.name == referent - ] + path = target_file.stable_key.removeprefix("file:") + 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: diff --git a/apps/backend/tests/extraction/test_dependency_manifests.py b/apps/backend/tests/extraction/test_dependency_manifests.py index dc1e1579..47f16ae8 100644 --- a/apps/backend/tests/extraction/test_dependency_manifests.py +++ b/apps/backend/tests/extraction/test_dependency_manifests.py @@ -1,3 +1,5 @@ +import pytest + from app.extraction.manifests import DependencyManifestExtractor @@ -8,6 +10,10 @@ 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", @@ -35,3 +41,93 @@ 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)] + + +# --- 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/intelligence/test_resolution.py b/apps/backend/tests/intelligence/test_resolution.py index cd471456..76ecace4 100644 --- a/apps/backend/tests/intelligence/test_resolution.py +++ b/apps/backend/tests/intelligence/test_resolution.py @@ -130,12 +130,19 @@ def test_resolver_persists_all_supported_relationship_kinds(session): 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/a.ts::shared", name="shared", path="src/a.ts") - _node(store, snapshot, "symbol", "src/b.ts::shared", name="shared", path="src/b.ts") + _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) @@ -145,12 +152,93 @@ def test_ambiguous_reference_is_a_warning_without_a_guessed_edge(session): assert diagnostic.code == "RI-RES-AMBIGUOUS" assert diagnostic.details == { "observation_id": diagnostic.details["observation_id"], - "candidates": ["src/a.ts::shared", "src/b.ts::shared"], + "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 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 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") @@ -265,6 +353,10 @@ def test_python_extractor_route_inputs_resolve_to_the_decorated_handler(session) 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.0.0", RESOLVER_PRODUCER]) store.add_node(snapshot, node_kind="repository", stable_key="repo:root", evidence=[ @@ -278,8 +370,93 @@ def test_golden_ambiguous_call_fixture_emits_a_diagnostic_not_an_edge(session): 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, diagnostic.details["candidates"]) for diagnostic in diagnostics] == [ - ("RI-RES-AMBIGUOUS", ["src/first.ts::shared", "src/second.ts::shared"]) - ] + 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.0.0", RESOLVER_PRODUCER]) + store.add_node(snapshot, node_kind="repository", stable_key="repo:root", evidence=[ + Evidence("app/main.py", 1, 1, "python-ast", "1.0.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.0.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.0.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 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 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_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" diff --git a/docs/architecture/REPOSITORY_INTELLIGENCE_RESOLUTION.md b/docs/architecture/REPOSITORY_INTELLIGENCE_RESOLUTION.md index 71c5164e..052d1821 100644 --- a/docs/architecture/REPOSITORY_INTELLIGENCE_RESOLUTION.md +++ b/docs/architecture/REPOSITORY_INTELLIGENCE_RESOLUTION.md @@ -28,7 +28,7 @@ edge. | --- | --- | --- | | `definition` | Symbol definition | `contains`, `defines` | | `import` | Module specifier | `imports` | -| `import_binding` | `specifier|imported|local` exact binding representation | supports `calls`, `implements`, `routes_to` lookup | +| `import_binding` | `specifier|imported|local` exact binding representation | supports `imports`, `calls`, `implements`, `routes_to` lookup | | `call` | Direct named call | `calls` | | `implements` | TypeScript `implements` clause | `implements` | | `route` + `route_handler` | Route declaration and one handler reference | `routes_to` | @@ -52,23 +52,39 @@ single parent to the definition symbol. 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. Python dotted imports also consider -each stored module prefix, which preserves the existing extractor representation -for `from .pkg import symbol`. Match candidates only against stored file nodes. - -For a bare specifier, 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`. +`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 direct stable-key referent wins. Otherwise, the resolver checks a same-file -top-level symbol, then the explicit `import_binding` records for the source -file, and finally all snapshot symbols with exactly that name. Multiple matches -are ambiguous. TypeScript extraction emits `implements` only for direct -`class ... implements ...` syntax; `extends` is intentionally not repurposed as -an `implements` fact. +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. 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. This applies identically +to `calls`, `implements`, and `routes_to`. TypeScript extraction emits +`implements` only for direct `class ... implements ...` syntax; `extends` is +intentionally not repurposed as an `implements` fact. ### Routes @@ -91,6 +107,12 @@ 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, ambiguous references, actual -TypeScript alias resolution, and FastAPI decorator routes. Both warning paths -are sealed successfully to prove that honest partial knowledge remains usable. +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. All warning paths are sealed successfully to prove that +honest partial knowledge remains usable. From 8ce8b8eb50bfe9b592a5b64e4660471aca3c397e Mon Sep 17 00:00:00 2001 From: Shaurya K Sharma Date: Sat, 18 Jul 2026 00:41:48 +0100 Subject: [PATCH 107/347] test(intelligence): assert no edge on unresolved/ambiguous resolver cases (#91) Use the resolver result in the four no-edge regression tests instead of leaving it bound but unused, asserting edges_added == 0 alongside the existing edge-absence and diagnostic checks. --- apps/backend/tests/intelligence/test_resolution.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/backend/tests/intelligence/test_resolution.py b/apps/backend/tests/intelligence/test_resolution.py index 76ecace4..75de8d7c 100644 --- a/apps/backend/tests/intelligence/test_resolution.py +++ b/apps/backend/tests/intelligence/test_resolution.py @@ -213,6 +213,7 @@ def test_imported_route_handler_without_resolvable_binding_stays_unresolved(sess _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 @@ -233,6 +234,7 @@ def test_implemented_interface_without_evidence_stays_unresolved(session): _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" @@ -431,6 +433,7 @@ def test_python_absolute_from_import_without_module_or_dependency_is_unresolved( 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" @@ -445,6 +448,7 @@ def test_python_absolute_from_import_with_two_module_candidates_is_ambiguous(ses 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" From b7beb769c2e424ed36895e24b0d294888f3f2296 Mon Sep 17 00:00:00 2001 From: parthrohit22 Date: Sat, 18 Jul 2026 02:08:15 +0100 Subject: [PATCH 108/347] fix(intelligence): preserve evidence-safe relationship resolution (#91) --- apps/backend/app/extraction/python.py | 114 +++++++++++- apps/backend/app/extraction/typescript.py | 173 ++++++++++++++++-- apps/backend/app/intelligence/resolution.py | 54 +++++- .../tests/extraction/test_python_extractor.py | 33 ++++ .../extraction/test_typescript_routes.py | 20 ++ .../extraction/test_typescript_symbols.py | 44 +++++ .../tests/intelligence/test_resolution.py | 158 ++++++++++++++++ .../REPOSITORY_INTELLIGENCE_RESOLUTION.md | 37 +++- 8 files changed, 592 insertions(+), 41 deletions(-) diff --git a/apps/backend/app/extraction/python.py b/apps/backend/app/extraction/python.py index cef1b846..31c6f2b3 100644 --- a/apps/backend/app/extraction/python.py +++ b/apps/backend/app/extraction/python.py @@ -222,6 +222,14 @@ def extract(self, path: str, source: bytes) -> ExtractionResult: 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]] = [] @@ -236,11 +244,12 @@ def _collect_imports( for alias in node.names if alias.name != "*" ] - bindings = [ - (module_specifier, alias.name, alias.asname or alias.name) - for alias in node.names - if alias.name != "*" and module_specifier - ] + 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: @@ -429,14 +438,24 @@ def _collect_calls(self, tree, path, line_count, module_key, observations, diagn local, imported, or ambiguous) is deliberately deferred to #91. """ - for node in ast.walk(tree): - if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Name): - continue + 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"}: - continue + return evidence, diagnostic = build_evidence( path, node.lineno, node.end_lineno or node.lineno, line_count, producer=self.producer, @@ -444,7 +463,7 @@ def _collect_calls(self, tree, path, line_count, module_key, observations, diagn if evidence is None: if diagnostic is not None: diagnostics.append(diagnostic) - continue + return observations.append( ExtractedObservation( observed_kind="call", @@ -455,6 +474,81 @@ def _collect_calls(self, tree, path, line_count, module_key, observations, diagn 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, + ) + ) + + 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() + 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.""" diff --git a/apps/backend/app/extraction/typescript.py b/apps/backend/app/extraction/typescript.py index 26d7306d..5dccc1a6 100644 --- a/apps/backend/app/extraction/typescript.py +++ b/apps/backend/app/extraction/typescript.py @@ -142,7 +142,9 @@ def extract(self, path: str, source: bytes) -> ExtractionResult: ) 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) + self._collect_routes( + tree.root_node, path, line_count, file_key, nodes, observations, diagnostics + ) self._collect_calls(tree.root_node, path, line_count, file_key, observations) self._collect_blind_spots(tree.root_node, path, line_count, diagnostics) return ExtractionResult( @@ -192,13 +194,24 @@ def _collect_implements(self, root, path, line_count, nodes, observations) -> No symbols = {node.stable_key: node for node in nodes if node.node_kind == "symbol"} def walk(node): - if node.type == "class_declaration": + if node.type in ("class_declaration", "abstract_class_declaration"): name = node.child_by_field_name("name") if name is not None: - class_key = canonical.normalize_stable_key( - "symbol", symbol_stable_key(path, [], self._node_text(name, source)) - ) - if class_key in symbols: + 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, @@ -208,6 +221,7 @@ def walk(node): 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, @@ -221,7 +235,7 @@ def walk(node): observed_kind="implements", subject_kind="symbol", subject_key=class_key, - referent_text=self._node_text(target, source), + referent_text=self._node_text(reference, source), ordinal=_UNASSIGNED_ORDINAL, evidence=evidence, ) @@ -276,7 +290,9 @@ def emit(imported, local, node): binding, ) - def _collect_routes(self, root, path, line_count, file_key, nodes, observations) -> None: + 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 @@ -290,6 +306,28 @@ def _collect_routes(self, root, path, line_count, file_key, nodes, observations) 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: @@ -369,8 +407,11 @@ def collect_route_entry(node): if pair.type != "pair": continue candidate_path = pair_value(pair, "path") - if candidate_path is not None and candidate_path.type == "string": - path_pair, path_value = pair, candidate_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": @@ -426,8 +467,11 @@ def walk(node): parts = child.named_children if (len(parts) > 1 and self._node_text(parts[0], source) == "path"): - path_attribute = child - path_literal = self._node_text(parts[1], source).strip("'\"{}`") + 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) @@ -458,7 +502,56 @@ def _collect_calls(self, root, path, line_count, file_key, observations) -> None source = root.text - def walk(node): + function_scope_types = { + "function_declaration", + "function_expression", + "generator_function_declaration", + "generator_function", + "arrow_function", + "method_definition", + } + + def binding_names(pattern) -> 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(binding_names(child)) + return names + + def scope_bindings(function) -> set[str]: + names: set[str] = set() + parameters = function.child_by_field_name("parameters") + if parameters is not None: + for parameter in parameters.named_children: + names.update(binding_names(parameter.child_by_field_name("pattern"))) + parameter = function.child_by_field_name("parameter") + if parameter is not None: + names.update(binding_names(parameter)) + + 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(binding_names(node.child_by_field_name("name"))) + return + if node.type in ("class_declaration", "abstract_class_declaration"): + names.update(binding_names(node.child_by_field_name("name"))) + return + if node.type == "variable_declarator": + names.update(binding_names(node.child_by_field_name("name"))) + for child in node.named_children: + collect(child) + + if body is not None: + collect(body) + return names + + 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": function = node.child_by_field_name("function") if function is not None and function.type == "identifier": @@ -481,8 +574,19 @@ def walk(node): 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) + walk(child, shadowed) walk(root) @@ -530,6 +634,38 @@ def _node_text(self, node, source: bytes) -> str: 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: @@ -547,6 +683,7 @@ def _collect_symbols( ) -> 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.""" @@ -563,11 +700,17 @@ def emit(name_node, decl_node, scope, exported): 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={"exported": True} if exported else None, + properties=properties, ) ) observations.append( diff --git a/apps/backend/app/intelligence/resolution.py b/apps/backend/app/intelligence/resolution.py index 9e39569e..4307c135 100644 --- a/apps/backend/app/intelligence/resolution.py +++ b/apps/backend/app/intelligence/resolution.py @@ -48,11 +48,14 @@ class RelationshipResolver: ``import`` A TypeScript/Python module specifier. It resolves a local file or an already-observed dependency node. - ``call`` / ``implements`` + ``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. Multiple binding - targets stay ambiguous; missing evidence stays unresolved. + 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. @@ -126,6 +129,10 @@ def resolve(self, snapshot: RiSnapshot) -> ResolutionResult: 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 = { + self._reference_input_key(input_) + for input_ in inputs_by_kind["call_shadowed"] + } edges_added = 0 diagnostics_added = 0 @@ -141,9 +148,14 @@ def resolve(self, snapshot: RiSnapshot) -> ResolutionResult: diagnostics_added += diagnosed for input_ in inputs_by_kind["call"]: - added, diagnosed = self._resolve_reference( - input_, nodes_by_key, evidence_by_node, bindings_by_file, predicate="calls" - ) + 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 @@ -223,9 +235,15 @@ def _resolve_reference( *, predicate: str, ) -> tuple[int, int]: - subject = nodes_by_key.get(input_.observation.subject_key) + observed_subject = nodes_by_key.get(input_.observation.subject_key) + subject = observed_subject if subject is None or subject.node_kind != "symbol": 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", + }: + subject = observed_subject if subject is None: return self._unresolved(input_, f"{predicate} source symbol is absent from the snapshot") referent = input_.observation.referent_text @@ -290,7 +308,7 @@ def _resolve_candidates( predicate: str, candidates: list[RiNode], ) -> tuple[int, int]: - unique = {candidate.stable_key: candidate for candidate in candidates if candidate.stable_key != subject.stable_key} + 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") @@ -421,6 +439,15 @@ def _reference_candidates( 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) @@ -549,3 +576,14 @@ def _evidence_key(evidence: RiEvidence) -> tuple[str, int, int, str, str, str]: 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/tests/extraction/test_python_extractor.py b/apps/backend/tests/extraction/test_python_extractor.py index 8ea52bec..1542b1f3 100644 --- a/apps/backend/tests/extraction/test_python_extractor.py +++ b/apps/backend/tests/extraction/test_python_extractor.py @@ -69,3 +69,36 @@ def test_direct_named_calls_become_resolver_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\n" + "def 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_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_typescript_routes.py b/apps/backend/tests/extraction/test_typescript_routes.py index ebfc3050..054601d7 100644 --- a/apps/backend/tests/extraction/test_typescript_routes.py +++ b/apps/backend/tests/extraction/test_typescript_routes.py @@ -23,6 +23,26 @@ def test_jsx_route_path_becomes_route_observation(): assert paths == ["/settings"] +def test_dynamic_jsx_route_path_is_diagnostic_not_a_literal_route(): + source = ( + "const routePath = '/settings';\n" + "const 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" diff --git a/apps/backend/tests/extraction/test_typescript_symbols.py b/apps/backend/tests/extraction/test_typescript_symbols.py index 97ff17db..f28bfc55 100644 --- a/apps/backend/tests/extraction/test_typescript_symbols.py +++ b/apps/backend/tests/extraction/test_typescript_symbols.py @@ -67,6 +67,24 @@ def test_exported_function_carries_exported_property(): 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;\n" + "export 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 = [ @@ -75,3 +93,29 @@ def test_direct_implements_clause_becomes_a_resolver_observation(): 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 {}\n" + "abstract 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; }\n" + "function 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/test_resolution.py b/apps/backend/tests/intelligence/test_resolution.py index 75de8d7c..294c1bab 100644 --- a/apps/backend/tests/intelligence/test_resolution.py +++ b/apps/backend/tests/intelligence/test_resolution.py @@ -456,6 +456,164 @@ def test_python_absolute_from_import_with_two_module_candidates_is_ambiguous(ses 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.0.0", RESOLVER_PRODUCER]) + store.add_node(snapshot, node_kind="repository", stable_key="repo:root", evidence=[ + Evidence("src/main.ts", 1, 1, "typescript-ast", "1.0.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.0.0", RESOLVER_PRODUCER]) + store.add_node(snapshot, node_kind="repository", stable_key="repo:root", evidence=[ + Evidence("app/main.py", 1, 1, "python-ast", "1.0.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.0.0", 4, "file") + ]) + _persist_extraction( + store, + snapshot, + PythonExtractor().extract( + "app/main.py", + b"def target():\n return 1\n" + b"def 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.0.0", RESOLVER_PRODUCER]) + store.add_node(snapshot, node_kind="repository", stable_key="repo:root", evidence=[ + Evidence("src/routes.tsx", 1, 1, "typescript-ast", "1.0.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';\n" + b"const 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.0.0", RESOLVER_PRODUCER]) + store.add_node(snapshot, node_kind="repository", stable_key="repo:root", evidence=[ + Evidence("src/main.ts", 1, 1, "typescript-ast", "1.0.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.0.0", RESOLVER_PRODUCER]) + store.add_node(snapshot, node_kind="repository", stable_key="repo:root", evidence=[ + Evidence("src/worker.ts", 1, 1, "typescript-ast", "1.0.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"] diff --git a/docs/architecture/REPOSITORY_INTELLIGENCE_RESOLUTION.md b/docs/architecture/REPOSITORY_INTELLIGENCE_RESOLUTION.md index 052d1821..a1f2fc01 100644 --- a/docs/architecture/REPOSITORY_INTELLIGENCE_RESOLUTION.md +++ b/docs/architecture/REPOSITORY_INTELLIGENCE_RESOLUTION.md @@ -30,6 +30,7 @@ edge. | `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` | @@ -38,6 +39,12 @@ edge. 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 @@ -76,15 +83,24 @@ diagnostics. 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. There is **no** repository-wide same-name fallback. Without a +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. This applies identically -to `calls`, `implements`, and `routes_to`. TypeScript extraction emits -`implements` only for direct `class ... implements ...` syntax; `extends` is -intentionally not repurposed as an `implements` fact. +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`, and `routes_to`. 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. ### Routes @@ -93,7 +109,9 @@ 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. +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 @@ -114,5 +132,8 @@ 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. All warning paths are sealed successfully to prove that -honest partial knowledge remains usable. +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. From b84cc44c0dffef9bd9e3034e132e20b84a3e6c8c Mon Sep 17 00:00:00 2001 From: parthrohit22 Date: Sat, 18 Jul 2026 11:49:29 +0100 Subject: [PATCH 109/347] fix: add roadmap and confidential directory to .gitignore --- .gitignore | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index c0d193ea..cc08cecf 100644 --- a/.gitignore +++ b/.gitignore @@ -148,4 +148,6 @@ ri-benchmark-report/ .fseventsd .Trashesg -PARTHA_Defensible_Repository_Intelligence_Roadmap_2026_2027.html \ No newline at end of file +PARTHA_Defensible_Repository_Intelligence_Roadmap_2026_2027.html + +/confidential/ From 0cca32680175a19c51ce3fb6ff9330aeb0be55cf Mon Sep 17 00:00:00 2001 From: hardikuppal04 <262667963+hardikuppal04@users.noreply.github.com> Date: Sat, 18 Jul 2026 03:25:38 +0530 Subject: [PATCH 110/347] test(api): cover frontend-required routes (#84) --- apps/backend/tests/api_assertions.py | 14 +++++++ apps/backend/tests/test_ai_api_contract.py | 39 +++++++++++++++++++ apps/backend/tests/test_ai_stream.py | 12 +++--- apps/backend/tests/test_auth.py | 19 +++++---- apps/backend/tests/test_documentation_api.py | 8 ++++ apps/backend/tests/test_export_api.py | 10 +++-- apps/backend/tests/test_ingestion_pipeline.py | 29 +++++++++----- .../tests/test_provider_key_encryption.py | 30 ++++++++++---- apps/backend/tests/test_repositories_api.py | 6 ++- .../backend/tests/test_repository_file_api.py | 7 ++-- .../tests/test_repository_ownership.py | 13 +++++++ .../backend/tests/test_route_authorization.py | 10 +++-- apps/backend/tests/test_system.py | 4 ++ 13 files changed, 156 insertions(+), 45 deletions(-) create mode 100644 apps/backend/tests/api_assertions.py diff --git a/apps/backend/tests/api_assertions.py b/apps/backend/tests/api_assertions.py new file mode 100644 index 00000000..76b6d12a --- /dev/null +++ b/apps/backend/tests/api_assertions.py @@ -0,0 +1,14 @@ +"""Small assertions shared by HTTP integration tests.""" + +from httpx import Response + +from app.core.exceptions import ErrorResponse + + +def assert_error_response(response: Response, status_code: int, code: str) -> ErrorResponse: + """Assert the public error envelope, including request correlation.""" + assert response.status_code == status_code, response.text + error = ErrorResponse.model_validate(response.json()) + assert error.code == code + assert error.request_id == response.headers["X-Request-ID"] + return error diff --git a/apps/backend/tests/test_ai_api_contract.py b/apps/backend/tests/test_ai_api_contract.py index 080fa321..2d659fda 100644 --- a/apps/backend/tests/test_ai_api_contract.py +++ b/apps/backend/tests/test_ai_api_contract.py @@ -4,6 +4,7 @@ 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.api_assertions import assert_error_response def _zip_bytes(files: dict[str, bytes]) -> bytes: @@ -22,6 +23,44 @@ async def complete(self, config: AiProviderConfig, prompt: PromptBundle) -> AiPr return AiProviderResponse(content="Repository summary from test provider.") +class ConnectionTestProvider: + async def complete(self, config: AiProviderConfig, prompt: PromptBundle) -> AiProviderResponse: + assert config.provider == "openai" + 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() diff --git a/apps/backend/tests/test_ai_stream.py b/apps/backend/tests/test_ai_stream.py index 5c3a94df..9de1f22e 100644 --- a/apps/backend/tests/test_ai_stream.py +++ b/apps/backend/tests/test_ai_stream.py @@ -15,6 +15,7 @@ from app.ai.providers.registry import ProviderRegistry from app.ai.types import AiProviderResponse from app.api.deps import get_provider_registry +from tests.api_assertions import assert_error_response from tests.conftest import register_user @@ -98,10 +99,8 @@ def test_stream_without_provider_config_returns_clear_error_not_empty_stream(cli ) # A clear 422, produced before streaming — not a 200 with an empty stream. - assert response.status_code == 422 - body = response.json() - assert body["code"] == "validation_error" - assert "not configured" in body["message"].lower() + error = assert_error_response(response, 422, "validation_error") + assert "not configured" in error.message.lower() assert not _is_event_stream(response) @@ -119,11 +118,10 @@ def test_stream_cross_owner_returns_404_before_streaming(client, make_auth_heade # 404, never 200 or 403: a non-owner's request is indistinguishable from a # missing repository, and it fails before any stream starts (JSON error body, # not an SSE stream). - assert response.status_code == 404 - assert response.json()["code"] == "not_found" + assert_error_response(response, 404, "not_found") assert not _is_event_stream(response) def test_stream_requires_authentication(client): response = client.post("/ai/stream", json={"repositoryId": "any", "query": "hi"}) - assert response.status_code == 401 + assert_error_response(response, 401, "unauthorized") diff --git a/apps/backend/tests/test_auth.py b/apps/backend/tests/test_auth.py index f6291e24..3658e05c 100644 --- a/apps/backend/tests/test_auth.py +++ b/apps/backend/tests/test_auth.py @@ -2,6 +2,8 @@ import pytest +from tests.api_assertions import assert_error_response + REGISTER = {"email": "alice@example.com", "password": "correct-horse-battery"} COOKIE = "partha_refresh" @@ -47,13 +49,14 @@ def test_register_duplicate_email_conflicts(client): assert _register(client).status_code == 201 duplicate = _register(client) - assert duplicate.status_code == 409 - assert duplicate.json()["code"] == "conflict_error" + assert_error_response(duplicate, 409, "conflict_error") def test_register_rejects_short_password(client): response = _register(client, password="short") - assert response.status_code == 422 + error = assert_error_response(response, 422, "request_validation_error") + assert error.details is not None + assert "errors" in error.details def test_register_commit_time_collision_is_reported_as_conflict(client, monkeypatch): @@ -144,9 +147,9 @@ def test_login_wrong_password_and_unknown_email_are_indistinguishable(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"}) - assert wrong_password.status_code == unknown_email.status_code == 401 - assert wrong_password.json() == unknown_email.json() | {"request_id": wrong_password.json()["request_id"]} - assert wrong_password.json()["code"] == "unauthorized" + 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): @@ -172,7 +175,7 @@ def test_user_without_credential_cannot_login(client): def test_me_requires_a_token(client): - assert client.get("/auth/me").status_code == 401 + assert_error_response(client.get("/auth/me"), 401, "unauthorized") def test_me_rejects_garbage_token(client): @@ -222,7 +225,7 @@ def test_refresh_reuse_revokes_the_whole_family(client): def test_refresh_without_cookie_is_unauthorized(client): - assert client.post("/auth/refresh").status_code == 401 + assert_error_response(client.post("/auth/refresh"), 401, "unauthorized") def test_logout_revokes_and_is_idempotent(client): diff --git a/apps/backend/tests/test_documentation_api.py b/apps/backend/tests/test_documentation_api.py index d1f17dd0..e35622d1 100644 --- a/apps/backend/tests/test_documentation_api.py +++ b/apps/backend/tests/test_documentation_api.py @@ -1,6 +1,8 @@ import io import zipfile +from tests.api_assertions import assert_error_response + def _zip_bytes(files: dict[str, str]) -> bytes: buffer = io.BytesIO() @@ -56,3 +58,9 @@ def test_documentation_html_renders_real_elements(auth_client): 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") diff --git a/apps/backend/tests/test_export_api.py b/apps/backend/tests/test_export_api.py index 00b3f509..9c4e1863 100644 --- a/apps/backend/tests/test_export_api.py +++ b/apps/backend/tests/test_export_api.py @@ -3,6 +3,8 @@ import json import zipfile +from tests.api_assertions import assert_error_response + def _zip_bytes(files: dict[str, str]) -> bytes: buffer = io.BytesIO() @@ -155,12 +157,12 @@ def test_export_rejects_invalid_format(auth_client): 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(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_ingestion_pipeline.py b/apps/backend/tests/test_ingestion_pipeline.py index baa2a63d..0526f848 100644 --- a/apps/backend/tests/test_ingestion_pipeline.py +++ b/apps/backend/tests/test_ingestion_pipeline.py @@ -8,6 +8,7 @@ from app.github.client import GitHubClient from app.core.exceptions import TimeoutServiceError +from tests.api_assertions import assert_error_response def _zip_bytes(files: dict[str, str]) -> bytes: @@ -77,6 +78,18 @@ def test_zip_upload_persists_repository_and_analysis_completes(auth_client): assert status["progress"] == 100 assert status["completedAt"] is not None + 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["summary"]["totalFindings"] == len(review["findings"]) + list_response = auth_client.get("/repositories") assert list_response.status_code == 200 repositories = list_response.json()["data"] @@ -179,19 +192,15 @@ def test_tar_gz_upload_is_supported(auth_client): 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(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(auth_client): @@ -251,8 +260,8 @@ def fake_clone(_: GitHubClient, __: str, destination: Path, ___: str | None = No assert new_revision.json()["revision"]["value"] == "b" * 40 assert new_revision.json()["id"] != first.json()["id"] assert shared_commit_other_source.status_code == 201 - assert malformed_branch.status_code == 422 - assert malformed_branch.json()["message"] == "Branch name contains unsupported characters." + error = assert_error_response(malformed_branch, 422, "validation_error") + assert error.message == "Branch name contains unsupported characters." def test_github_clone_timeout_is_reported(monkeypatch: pytest.MonkeyPatch, tmp_path: Path): diff --git a/apps/backend/tests/test_provider_key_encryption.py b/apps/backend/tests/test_provider_key_encryption.py index 8a7e9a2e..7ccc4195 100644 --- a/apps/backend/tests/test_provider_key_encryption.py +++ b/apps/backend/tests/test_provider_key_encryption.py @@ -12,6 +12,7 @@ from sqlalchemy import select +from tests.api_assertions import assert_error_response from tests.conftest import register_user @@ -74,6 +75,21 @@ def _fresh_key() -> str: # --- 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( @@ -87,7 +103,9 @@ def test_saved_key_is_encrypted_at_rest_and_never_returned_in_full(client): assert body["apiKeyLast4"] == "1234" assert "sk-secret-ABCD1234" not in json.dumps(body) - got = client.get("/ai/config", headers=auth["headers"]).json() + 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) @@ -160,10 +178,8 @@ def test_provider_config_is_scoped_per_user(client, make_auth_headers): 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"]) - assert response.status_code == 422 - body = response.json() - assert body["code"] == "validation_error" - assert "API key is required" in body["message"] + 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): @@ -192,5 +208,5 @@ def test_ai_query_without_provider_config_returns_a_clear_error(client): json={"repositoryId": repository_id, "query": "Summarize this repo"}, headers=auth["headers"], ) - assert response.status_code == 422 - assert "not configured" in response.json()["message"].lower() + error = assert_error_response(response, 422, "validation_error") + assert "not configured" in error.message.lower() diff --git a/apps/backend/tests/test_repositories_api.py b/apps/backend/tests/test_repositories_api.py index fe29ae8a..9d7b45cd 100644 --- a/apps/backend/tests/test_repositories_api.py +++ b/apps/backend/tests/test_repositories_api.py @@ -1,3 +1,6 @@ +from tests.api_assertions import assert_error_response + + def test_list_repositories_starts_empty(auth_client): response = auth_client.get("/repositories") @@ -8,5 +11,4 @@ def test_list_repositories_starts_empty(auth_client): 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 eaa90b5b..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: @@ -112,8 +113,7 @@ def test_path_traversal_is_rejected(auth_client): 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(auth_client): @@ -139,8 +139,7 @@ def test_missing_file_returns_not_found(auth_client): 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(auth_client): diff --git a/apps/backend/tests/test_repository_ownership.py b/apps/backend/tests/test_repository_ownership.py index 160d7d83..e535ccd1 100644 --- a/apps/backend/tests/test_repository_ownership.py +++ b/apps/backend/tests/test_repository_ownership.py @@ -10,6 +10,7 @@ from sqlalchemy import select +from tests.api_assertions import assert_error_response from tests.conftest import register_user @@ -86,6 +87,18 @@ def test_delete_returns_404_for_another_users_repository(client, make_auth_heade 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. diff --git a/apps/backend/tests/test_route_authorization.py b/apps/backend/tests/test_route_authorization.py index 19f02f17..8490d32a 100644 --- a/apps/backend/tests/test_route_authorization.py +++ b/apps/backend/tests/test_route_authorization.py @@ -14,6 +14,8 @@ 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") @@ -63,10 +65,12 @@ def test_every_protected_route_requires_authentication(client): # 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"}) - if response.status_code != 401: - failures.append((method, path, response.status_code)) + try: + assert_error_response(response, 401, "unauthorized") + except AssertionError as exc: + failures.append((method, path, str(exc))) - assert not failures, f"routes reachable without authentication: {failures}" + assert not failures, f"routes missing the standard unauthenticated response: {failures}" def _seed_repository(owner_id: str) -> str: diff --git a/apps/backend/tests/test_system.py b/apps/backend/tests/test_system.py index 96b5b8ec..2977ee24 100644 --- a/apps/backend/tests/test_system.py +++ b/apps/backend/tests/test_system.py @@ -127,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): From 02c05bd171137ef458699f5f3876d9dca94e8a27 Mon Sep 17 00:00:00 2001 From: hardikuppal04 <262667963+hardikuppal04@users.noreply.github.com> Date: Sat, 18 Jul 2026 19:42:48 +0530 Subject: [PATCH 111/347] test(api): address frontend route review feedback --- apps/backend/tests/api_assertions.py | 17 +++++++-- apps/backend/tests/test_ai_api_contract.py | 2 + apps/backend/tests/test_api_assertions.py | 44 ++++++++++++++++++++++ apps/backend/tests/test_auth.py | 19 ++++++++++ 4 files changed, 79 insertions(+), 3 deletions(-) create mode 100644 apps/backend/tests/test_api_assertions.py diff --git a/apps/backend/tests/api_assertions.py b/apps/backend/tests/api_assertions.py index 76b6d12a..57076603 100644 --- a/apps/backend/tests/api_assertions.py +++ b/apps/backend/tests/api_assertions.py @@ -4,11 +4,22 @@ 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 public error envelope, including request correlation.""" + """Assert the exact public error envelope, including request correlation.""" assert response.status_code == status_code, response.text - error = ErrorResponse.model_validate(response.json()) + payload = response.json() + assert isinstance(payload, dict), f"error response must be a JSON object: {payload!r}" + assert set(payload) == ERROR_RESPONSE_FIELDS, ( + "error response fields must be exactly " + f"{sorted(ERROR_RESPONSE_FIELDS)}; got {sorted(payload)}" + ) + + error = ErrorResponse.model_validate(payload) assert error.code == code - assert error.request_id == response.headers["X-Request-ID"] + 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/test_ai_api_contract.py b/apps/backend/tests/test_ai_api_contract.py index 2d659fda..52df701d 100644 --- a/apps/backend/tests/test_ai_api_contract.py +++ b/apps/backend/tests/test_ai_api_contract.py @@ -26,6 +26,8 @@ async def complete(self, config: AiProviderConfig, prompt: PromptBundle) -> AiPr 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") diff --git a/apps/backend/tests/test_api_assertions.py b/apps/backend/tests/test_api_assertions.py new file mode 100644 index 00000000..337ce1f5 --- /dev/null +++ b/apps/backend/tests/test_api_assertions.py @@ -0,0 +1,44 @@ +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_auth.py b/apps/backend/tests/test_auth.py index 3658e05c..e34afb87 100644 --- a/apps/backend/tests/test_auth.py +++ b/apps/backend/tests/test_auth.py @@ -238,6 +238,25 @@ def test_logout_revokes_and_is_idempotent(client): 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 ---------------------------------------------------------- From 5333a53e484d824213b197a8bb8d18cf08a4295e Mon Sep 17 00:00:00 2001 From: Shaurya K Sharma Date: Sat, 18 Jul 2026 16:32:07 +0100 Subject: [PATCH 112/347] fix(intelligence): classify env files by evidence not filename (#122) Environment files were flagged as a critical secret exposure whenever a basename started with ".env", so templates such as .env.example, .env.sample, .env.template, and .env.dist were reported as exposed secrets and carried rotation advice with no supporting evidence. Classify each committed environment file by content into one of three evidence classes -- template present, runtime env file present, or secret-like value detected -- and size the review finding accordingly. Only a non-placeholder secret-like value raises a critical finding with rotation advice. Empty values, explicit placeholders, ${VAR} references, and example credentials embedded in URLs (e.g. user:password@host or user:${PW}@host) no longer trigger a critical incident. Findings name their evidence class and sensitive key names only, never values. Cached intelligence built before this change is refreshed so stale records cannot surface a filename-only critical finding. --- apps/backend/app/intelligence/engine.py | 81 +++++++++- apps/backend/app/intelligence/models.py | 12 ++ apps/backend/app/review/review_service.py | 59 ++++++- .../tests/test_environment_file_review.py | 144 ++++++++++++++++++ apps/backend/tests/test_review_evidence.py | 4 +- docs/architecture/REPOSITORY_INTELLIGENCE.md | 2 + 6 files changed, 294 insertions(+), 8 deletions(-) create mode 100644 apps/backend/tests/test_environment_file_review.py diff --git a/apps/backend/app/intelligence/engine.py b/apps/backend/app/intelligence/engine.py index adf87ab7..e8532e4d 100644 --- a/apps/backend/app/intelligence/engine.py +++ b/apps/backend/app/intelligence/engine.py @@ -9,6 +9,7 @@ from typing import Any from app.intelligence.models import ( + EnvironmentFileEvidence, KnowledgeGraph, KnowledgeGraphNode, KnowledgeGraphRelationship, @@ -40,6 +41,15 @@ ".gitignore", "alembic.ini", } +ENVIRONMENT_TEMPLATE_NAMES = {".env.example", ".env.sample", ".env.template", ".env.dist"} +SECRET_KEY_NAME_PATTERN = re.compile( + r"(?:^|_)(?:api_?key|access_?key|auth_?token|client_?secret|credential|password|private_?key|secret|token)(?:$|_)", + flags=re.IGNORECASE, +) +PLACEHOLDER_VALUE_PATTERN = re.compile( + r"^(?:\$\{[^}]+\}|\$[A-Za-z_][A-Za-z0-9_]*|<[^>]+>|\[[^]]+\]|(?:example|sample|placeholder|replace|your)[\s_-].*|(?:change|replace)[\s_-]?me|not[\s_-]?a[\s_-]?real[\s_-]?.*|x+|\*+)$", + flags=re.IGNORECASE, +) DATABASE_TECHNOLOGIES = { "postgres": "PostgreSQL", "postgresql": "PostgreSQL", @@ -74,7 +84,7 @@ class RepositoryIntelligenceEngine: def from_record(self, record: RepositoryRecord) -> RepositoryIntelligence: existing = self.load(record) - if existing: + if existing and (not existing.discovery.environment_files or existing.discovery.environment_file_evidence): return existing return self.build( repository_id=record.id, @@ -112,7 +122,7 @@ def build( 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) + discovery = self._discovery(root, 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( @@ -327,6 +337,7 @@ def _dependency(self, name: str, version: str, dep_type: str, ecosystem: str, so def _discovery( self, + root: Path, metadata: RepositoryMeta, tree: list[FileTreeNode], files: list[SourceFileIntelligence], @@ -341,6 +352,7 @@ def _discovery( 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")] + environment_file_evidence = self._environment_file_evidence(root, env_files) 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) @@ -354,6 +366,7 @@ def _discovery( package_managers=package_managers, configuration_files=config_files, environment_files=env_files, + environment_file_evidence=environment_file_evidence, docker_files=docker_files, ci_files=ci_files, entry_points=[metadata.entry_point] if metadata.entry_point else [], @@ -371,6 +384,70 @@ def _discovery( ), ) + def _environment_file_evidence(self, root: Path, paths: list[str]) -> list[EnvironmentFileEvidence]: + evidence: list[EnvironmentFileEvidence] = [] + for path in paths: + secret_keys = self._secret_like_keys(self._read_text(root, path)) + if secret_keys: + evidence_class = "secret_like_value_detected" + elif Path(path).name.lower() in ENVIRONMENT_TEMPLATE_NAMES: + evidence_class = "template_present" + else: + evidence_class = "runtime_env_file_present" + evidence.append(EnvironmentFileEvidence(path=path, evidence_class=evidence_class, secret_keys=secret_keys)) + return evidence + + def _secret_like_keys(self, text: str) -> list[str]: + keys: set[str] = set() + for line in text.splitlines(): + clean = line.strip() + if not clean or clean.startswith("#"): + continue + if clean.startswith("export "): + clean = clean.removeprefix("export ").lstrip() + if "=" not in clean: + continue + key, value = clean.split("=", 1) + key = key.strip() + value = value.strip().strip("\"'") + if not key or self._is_placeholder_value(value): + continue + if SECRET_KEY_NAME_PATTERN.search(key) or self._has_embedded_credentials(value): + keys.add(key) + return sorted(keys) + + def _is_placeholder_value(self, value: str) -> bool: + normalized = value.strip() + if not normalized or normalized.lower() in { + "none", + "null", + "undefined", + "example", + "sample", + "placeholder", + "replace", + "your", + "user", + "username", + "password", + "pass", + "pwd", + "secret", + "token", + }: + return True + return bool(PLACEHOLDER_VALUE_PATTERN.fullmatch(normalized)) + + def _has_embedded_credentials(self, value: str) -> bool: + if "-----BEGIN" in value and "PRIVATE KEY-----" in value: + return True + # A URL userinfo section only counts as an exposed credential when the + # password is a concrete value, not a placeholder or a ${VAR} reference + # (e.g. postgres://user:password@host and postgres://user:${PW}@host are + # template idioms, not committed secrets). + userinfo = re.search(r"://[^/@\s]+:([^/@\s]+)@", value) + return bool(userinfo) and not self._is_placeholder_value(userinfo.group(1)) + def _count_folders(self, nodes: list[FileTreeNode]) -> int: count = 0 for node in nodes: diff --git a/apps/backend/app/intelligence/models.py b/apps/backend/app/intelligence/models.py index cdac17df..22d41bd1 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 @@ -36,6 +38,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 +54,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] diff --git a/apps/backend/app/review/review_service.py b/apps/backend/app/review/review_service.py index d5438232..7e89dd6a 100644 --- a/apps/backend/app/review/review_service.py +++ b/apps/backend/app/review/review_service.py @@ -1,6 +1,7 @@ from datetime import UTC, datetime from app.intelligence.engine import RepositoryIntelligenceEngine +from app.intelligence.models import EnvironmentFileEvidence from app.models.repository import RepositoryRecord from app.schemas.review import EngineeringReviewResponse, ImprovementStep, ReviewFinding, ReviewScore, ReviewSummary @@ -79,17 +80,65 @@ def _findings(self, intelligence) -> list[ReviewFinding]: affected_modules=["tests"], ) ) - if discovery.environment_files: + environment_evidence: list[EnvironmentFileEvidence] = discovery.environment_file_evidence + if not environment_evidence and discovery.environment_files: + environment_evidence = [ + EnvironmentFileEvidence(path=path, evidence_class="runtime_env_file_present") + for path in discovery.environment_files + ] + template_files = [item.path for item in environment_evidence if item.evidence_class == "template_present"] + runtime_files = [item.path for item in environment_evidence if item.evidence_class == "runtime_env_file_present"] + secret_evidence = [item for item in environment_evidence if item.evidence_class == "secret_like_value_detected"] + secret_files = [item.path for item in secret_evidence] + secret_keys = sorted({key for item in secret_evidence for key in item.secret_keys}) + if secret_files: findings.append( self._finding( - "env-file-present", - "Environment File Present", + "env-secret-like-value-detected", + "Secret-Like Environment Value Detected", "security", "critical", - problem=f"Committed environment file(s) that may contain secrets: {', '.join(discovery.environment_files[:5])}.", + problem=( + "Evidence class: secret-like value detected. Committed environment file(s) contain " + f"non-placeholder values for sensitive key(s): {', '.join(secret_keys[:10])}." + ), 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_files=secret_files, + affected_modules=["configuration"], + ) + ) + if runtime_files: + findings.append( + self._finding( + "env-runtime-file-present", + "Runtime Environment File Present", + "security", + "medium", + problem=( + "Evidence class: runtime env file present. These committed files had no detected secret-like value, " + "so their filenames alone are not evidence of an exposed secret." + ), + impact="A runtime environment file can later accumulate credentials or be copied into deployments without review.", + recommendation="Keep runtime environment files out of version control and review their values. No secret rotation is indicated unless a secret-like value is detected.", + affected_files=runtime_files, + affected_modules=["configuration"], + ) + ) + if template_files: + findings.append( + self._finding( + "env-template-present", + "Environment Template Present", + "configuration", + "low", + problem=( + "Evidence class: template present. These files document environment configuration and contain no detected " + "secret-like value." + ), + impact="Templates are safe to commit when they remain placeholder-only, but they should not be used as a location for live credentials.", + recommendation="Keep templates placeholder-only and document required variables; no secret rotation is indicated by this finding.", + affected_files=template_files, affected_modules=["configuration"], ) ) diff --git a/apps/backend/tests/test_environment_file_review.py b/apps/backend/tests/test_environment_file_review.py new file mode 100644 index 00000000..4b275376 --- /dev/null +++ b/apps/backend/tests/test_environment_file_review.py @@ -0,0 +1,144 @@ +from datetime import UTC, datetime +from pathlib import Path + +import pytest + +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 _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 intelligence, EngineeringReviewBuilder().build(record) + + +@pytest.mark.parametrize( + ("filename", "content", "evidence_class", "finding_id"), + [ + (".env.example", "API_KEY=\nDATABASE_URL=${DATABASE_URL}\n", "template_present", "env-template-present"), + (".env.sample", "TOKEN=example\n", "template_present", "env-template-present"), + (".env.template", "API_SECRET=your-secret-here\n", "template_present", "env-template-present"), + (".env.dist", "ACCESS_TOKEN=placeholder-token\n", "template_present", "env-template-present"), + (".env", "API_KEY=\nTOKEN=${TOKEN}\n", "runtime_env_file_present", "env-runtime-file-present"), + (".env", "API_SECRET=super-secret\n", "secret_like_value_detected", "env-secret-like-value-detected"), + ], +) +def test_environment_files_are_classified_by_name_and_content( + tmp_path: Path, filename: str, content: str, evidence_class: str, finding_id: str +): + (tmp_path / filename).write_text(content, encoding="utf-8") + + intelligence, review = _review(tmp_path) + evidence = intelligence.discovery.environment_file_evidence + findings = {finding.id: finding for finding in review.findings} + + assert [(item.path, item.evidence_class) for item in evidence] == [(filename, evidence_class)] + assert finding_id in findings + assert "Evidence class:" in findings[finding_id].problem + if evidence_class == "secret_like_value_detected": + assert findings[finding_id].severity == "critical" + assert "rotate exposed secrets" in findings[finding_id].recommendation + else: + assert findings[finding_id].severity != "critical" + assert "no secret rotation" in findings[finding_id].recommendation.lower() + + +def test_template_with_a_secret_like_value_is_not_trusted_by_filename(tmp_path: Path): + (tmp_path / ".env.example").write_text("API_SECRET=super-secret\n", encoding="utf-8") + + intelligence, review = _review(tmp_path) + findings = {finding.id: finding for finding in review.findings} + + assert intelligence.discovery.environment_file_evidence[0].evidence_class == "secret_like_value_detected" + assert findings["env-secret-like-value-detected"].severity == "critical" + + +@pytest.mark.parametrize( + "content", + [ + "DATABASE_URL=postgres://user:password@localhost:5432/appdb\n", + "DATABASE_URL=postgres://user:${DB_PASSWORD}@localhost:5432/appdb\n", + "DATABASE_URL=postgres://user:$DB_PASSWORD@localhost:5432/appdb\n", + ], +) +def test_placeholder_url_credentials_are_not_treated_as_secrets(tmp_path: Path, content: str): + (tmp_path / ".env.example").write_text(content, encoding="utf-8") + + intelligence, review = _review(tmp_path) + findings = {finding.id: finding for finding in review.findings} + + assert intelligence.discovery.environment_file_evidence[0].evidence_class == "template_present" + assert "env-secret-like-value-detected" not in findings + assert findings["env-template-present"].severity != "critical" + + +def test_real_url_credential_is_flagged_without_leaking_the_value(tmp_path: Path): + secret_value = "9f2ab7c4d1e0" + (tmp_path / ".env").write_text( + f"DATABASE_URL=postgres://svc:{secret_value}@db.internal:5432/app\n", encoding="utf-8" + ) + + intelligence, review = _review(tmp_path) + evidence = intelligence.discovery.environment_file_evidence[0] + critical = {finding.id: finding for finding in review.findings}["env-secret-like-value-detected"] + + assert evidence.evidence_class == "secret_like_value_detected" + assert evidence.secret_keys == ["DATABASE_URL"] + assert critical.severity == "critical" + assert "DATABASE_URL" in critical.problem + # The sensitive value itself is never surfaced in the finding or the evidence. + assert secret_value not in critical.problem + assert all(secret_value not in key for key in evidence.secret_keys) + + +def test_cached_intelligence_without_environment_evidence_is_refreshed(tmp_path: Path): + (tmp_path / ".env.example").write_text("API_KEY=\n", encoding="utf-8") + tree, meta, total_size = RepositoryParser().parse(tmp_path) + intelligence = RepositoryIntelligenceEngine().build("repo-1", "sample", tmp_path, tree, meta, total_size) + metadata = intelligence.metadata.model_dump(mode="json", by_alias=True) + serialized_intelligence = intelligence.model_dump(mode="json", by_alias=True) + serialized_intelligence["discovery"].pop("environmentFileEvidence") + metadata["intelligence"] = serialized_intelligence + record = RepositoryRecord( + id="repo-1", + name="sample", + source="upload", + local_path=str(tmp_path), + 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=tree, + ) + + review = EngineeringReviewBuilder().build(record) + findings = {finding.id: finding for finding in review.findings} + + assert "env-template-present" in findings + assert "env-runtime-file-present" not in findings diff --git a/apps/backend/tests/test_review_evidence.py b/apps/backend/tests/test_review_evidence.py index 8a893b5a..4e519697 100644 --- a/apps/backend/tests/test_review_evidence.py +++ b/apps/backend/tests/test_review_evidence.py @@ -53,7 +53,9 @@ def test_findings_carry_specific_impact_and_evidence(tmp_path: Path): 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 ".env" in findings["env-secret-like-value-detected"].affected_files + assert findings["env-secret-like-value-detected"].severity == "critical" + assert "API_SECRET" in findings["env-secret-like-value-detected"].problem assert findings["large-files"].affected_files assert any("big.ts" in path for path in findings["large-files"].affected_files) diff --git a/docs/architecture/REPOSITORY_INTELLIGENCE.md b/docs/architecture/REPOSITORY_INTELLIGENCE.md index 55bcbd52..9b412d19 100644 --- a/docs/architecture/REPOSITORY_INTELLIGENCE.md +++ b/docs/architecture/REPOSITORY_INTELLIGENCE.md @@ -170,6 +170,8 @@ Two terms with distinct meanings. PARTHA uses them precisely, and supports neith **Evidence: partial.** Graph relationships and engineering-review findings carry the **file paths** they were derived from. That is real evidence, and it is enough to point a reader at the right file. +Environment-file review findings also name their evidence class: a committed template, a runtime environment file with no detected secret-like value, or a secret-like value. A `.env.example`, `.env.sample`, `.env.template`, or `.env.dist` filename is never treated as proof of an exposed secret. The review reports sensitive key names and file paths, never values; rotation advice appears only when a non-placeholder secret-like value is detected. + **Product-consumed provenance: incomplete.** The new persistence schema can store complete `ri.v1` provenance and the standalone extractors can produce it, but the current product path still consumes the legacy regex engine. Specifically: - **No line spans.** `SourceSymbol` has `id`, `name`, `kind`, `file_path`, and `exported`. It has **no start or end line**. Nothing in the model records where in a file a fact was found. From fb0aa959b576515a5ce6e1b1b4957879b0a8c166 Mon Sep 17 00:00:00 2001 From: parthrohit22 Date: Sat, 18 Jul 2026 16:24:35 +0100 Subject: [PATCH 113/347] feat(intelligence): add owner-scoped snapshot queries --- apps/backend/app/api/deps.py | 8 + apps/backend/app/api/router.py | 3 +- apps/backend/app/api/routes/intelligence.py | 233 ++++++++++++++++++ .../backend/app/intelligence/query_service.py | 196 +++++++++++++++ apps/backend/app/schemas/intelligence.py | 124 ++++++++++ .../tests/test_intelligence_query_api.py | 181 ++++++++++++++ apps/backend/tests/test_openapi_contract.py | 7 + .../frontend/src/shared/services/api/index.ts | 2 +- .../src/shared/services/api/repositories.ts | 38 +++ .../frontend/src/shared/services/api/types.ts | 88 +++++++ docs/architecture/REPOSITORY_INTELLIGENCE.md | 8 +- .../REPOSITORY_INTELLIGENCE_V1_RFC.md | 4 +- 12 files changed, 884 insertions(+), 8 deletions(-) create mode 100644 apps/backend/app/api/routes/intelligence.py create mode 100644 apps/backend/app/intelligence/query_service.py create mode 100644 apps/backend/app/schemas/intelligence.py create mode 100644 apps/backend/tests/test_intelligence_query_api.py diff --git a/apps/backend/app/api/deps.py b/apps/backend/app/api/deps.py index b51ac424..d4bb091e 100644 --- a/apps/backend/app/api/deps.py +++ b/apps/backend/app/api/deps.py @@ -23,6 +23,7 @@ from app.github.client import GitHubClient from app.graph.dependency_graph import DependencyGraphBuilder from app.intelligence.engine import RepositoryIntelligenceEngine +from app.intelligence.query_service import SnapshotQueryService from app.models.user import User from app.parsers.repository_parser import RepositoryParser from app.repositories.repository_repository import RepositoryRepository @@ -84,6 +85,13 @@ 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( repository: RepositoryRepository = Depends(get_repository_repository), storage: LocalStorage = Depends(get_local_storage), diff --git a/apps/backend/app/api/router.py b/apps/backend/app/api/router.py index d0e2b1fb..c1030ae4 100644 --- a/apps/backend/app/api/router.py +++ b/apps/backend/app/api/router.py @@ -1,10 +1,11 @@ from fastapi import APIRouter -from app.api.routes import ai, analysis, auth, documentation, reports, repositories +from app.api.routes import ai, analysis, auth, documentation, intelligence, reports, repositories api_router = APIRouter() api_router.include_router(auth.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) diff --git a/apps/backend/app/api/routes/intelligence.py b/apps/backend/app/api/routes/intelligence.py new file mode 100644 index 00000000..f8b8453d --- /dev/null +++ b/apps/backend/app/api/routes/intelligence.py @@ -0,0 +1,233 @@ +"""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, suppress_automatic_validation_error +from app.intelligence.query_service import SnapshotQueryService +from app.schemas.intelligence import ( + RiAssertionResponse, + RiAssertionsResponse, + RiEdgeResponse, + RiEvidenceResponse, + RiEvidenceResponsePage, + 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.")] + + +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) + + +@router.get( + "/{snapshot_id}", + response_model=RiSnapshotMetadataResponse, + responses=documented_responses(200, "Sealed snapshot metadata.", {"schemaVersion": "ri.v1"}, 401, 404, 429, 500), + openapi_extra=suppress_automatic_validation_error(), +) +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}/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/intelligence/query_service.py b/apps/backend/app/intelligence/query_service.py new file mode 100644 index 00000000..1917dac1 --- /dev/null +++ b/apps/backend/app/intelligence/query_service.py @@ -0,0 +1,196 @@ +"""Read-only, owner-scoped queries over sealed ``ri.v1`` snapshots (#92).""" + +from collections import defaultdict + +from sqlalchemy import func, or_, select +from sqlalchemy.orm import Session + +from app.core.exceptions import NotFoundError +from app.models.snapshot import RiAssertion, RiDerivation, RiEdge, RiEvidence, RiNode, RiSnapshot + + +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 + + def metadata(self, snapshot_id: str) -> RiSnapshot: + return self._snapshot(snapshot_id) + + 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 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.") + 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 _evidence_for(self, snapshot: RiSnapshot, column: str, ids: list[int]) -> dict[int, list[RiEvidence]]: + if not ids: + return {} + field = getattr(RiEvidence, column) + rows = self.db.scalars( + select(RiEvidence) + .where(RiEvidence.snapshot_id == snapshot.snapshot_id, field.in_(ids)) + .order_by( + RiEvidence.path, + RiEvidence.start_line, + RiEvidence.end_line, + RiEvidence.granularity, + RiEvidence.extractor, + RiEvidence.extractor_version, + RiEvidence.id, + ) + ).all() + grouped: dict[int, list[RiEvidence]] = defaultdict(list) + 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/schemas/intelligence.py b/apps/backend/app/schemas/intelligence.py new file mode 100644 index 00000000..c8f24023 --- /dev/null +++ b/apps/backend/app/schemas/intelligence.py @@ -0,0 +1,124 @@ +"""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"] + + +class RiEvidenceResponse(CamelModel): + schema_version: str + 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: str + 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: str + data: list[RiNodeResponse] + pagination: RiPagination + + +class RiNeighboursResponse(CamelModel): + schema_version: str + node_key: str + data: list[RiEdgeResponse] + pagination: RiPagination + + +class RiReferencesResponse(CamelModel): + schema_version: str + data: list[RiEdgeResponse] + pagination: RiPagination + + +class RiAssertionsResponse(CamelModel): + schema_version: str + data: list[RiAssertionResponse] + pagination: RiPagination + + +class RiPathsResponse(CamelModel): + schema_version: str + data: list[RiPathResponse] + pagination: RiPagination + + +class RiEvidenceResponsePage(CamelModel): + schema_version: str + data: list[RiEvidenceResponse] + pagination: RiPagination 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..15454a91 --- /dev/null +++ b/apps/backend/tests/test_intelligence_query_api.py @@ -0,0 +1,181 @@ +"""API coverage for the owner-scoped, stored-snapshot query boundary (#92).""" + +from uuid import uuid4 + +import pytest + +from tests.conftest import register_user + + +def _seed_snapshot(owner_id: str, *, suffix: str = "one") -> 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"], + ) + + 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 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", "/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"]) + + +@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_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 "RiSymbolsResponse" in document["components"]["schemas"] + assert "RiEvidenceResponse" in document["components"]["schemas"] diff --git a/apps/backend/tests/test_openapi_contract.py b/apps/backend/tests/test_openapi_contract.py index 5277d781..60acb1a7 100644 --- a/apps/backend/tests/test_openapi_contract.py +++ b/apps/backend/tests/test_openapi_contract.py @@ -37,6 +37,13 @@ ("GET", "/repositories/{repository_id}"): {200, 401, 404, 429, 500}, ("GET", "/repositories/{repository_id}/file"): {200, 401, 404, 422, 429, 500}, ("DELETE", "/repositories/{repository_id}"): {204, 401, 404, 429, 500}, + ("GET", "/intelligence/v1/snapshots/{snapshot_id}"): {200, 401, 404, 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}/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}, ("GET", "/analysis/{repository_id}/architecture"): {200, 401, 404, 429, 500}, diff --git a/apps/frontend/src/shared/services/api/index.ts b/apps/frontend/src/shared/services/api/index.ts index 2e39f56c..4315514d 100644 --- a/apps/frontend/src/shared/services/api/index.ts +++ b/apps/frontend/src/shared/services/api/index.ts @@ -4,7 +4,7 @@ export type { RequestConfig, ApiClientConfig, HttpMethod } from './client'; export { ApiError, NetworkError, TimeoutError, CancelledError, isApiError, isNetworkError, isTimeoutError, isCancelledError, getErrorMessage } from './errors'; export { authService } from './auth'; -export { repositoryService } from './repositories'; +export { repositoryService, repositoryIntelligenceService } from './repositories'; export { uploadService } from './upload'; export { analysisService } from './analysis'; export { architectureService } from './architecture'; diff --git a/apps/frontend/src/shared/services/api/repositories.ts b/apps/frontend/src/shared/services/api/repositories.ts index c8fa1f0e..3edaac50 100644 --- a/apps/frontend/src/shared/services/api/repositories.ts +++ b/apps/frontend/src/shared/services/api/repositories.ts @@ -5,6 +5,14 @@ import type { RepositoryListResponse, RepositoryFileResponse, ImportGithubRequest, + RiCollectionResponse, + RiAssertion, + RiEdge, + RiEvidence, + RiNeighboursResponse, + RiNode, + RiPath, + RiSnapshotMetadata, } from './types'; export const repositoryService = { @@ -28,3 +36,33 @@ export const repositoryService = { return api.post('/repositories/github', request, config); }, }; + +export const repositoryIntelligenceService = { + getSnapshot(snapshotId: string, config?: RequestConfig): Promise { + return api.get(`/intelligence/v1/snapshots/${encodeURIComponent(snapshotId)}`, config); + }, + + listSymbols(snapshotId: string, offset = 0, limit = 50, config?: RequestConfig): Promise> { + return api.get(`/intelligence/v1/snapshots/${encodeURIComponent(snapshotId)}/symbols?offset=${offset}&limit=${limit}`, config); + }, + + listNeighbours(snapshotId: string, nodeKey: string, offset = 0, limit = 50, config?: RequestConfig): Promise { + return api.get(`/intelligence/v1/snapshots/${encodeURIComponent(snapshotId)}/neighbours?nodeKey=${encodeURIComponent(nodeKey)}&offset=${offset}&limit=${limit}`, config); + }, + + listReferences(snapshotId: string, offset = 0, limit = 50, config?: RequestConfig): Promise> { + return api.get(`/intelligence/v1/snapshots/${encodeURIComponent(snapshotId)}/references?offset=${offset}&limit=${limit}`, config); + }, + + listAssertions(snapshotId: string, offset = 0, limit = 50, config?: RequestConfig): Promise> { + return api.get(`/intelligence/v1/snapshots/${encodeURIComponent(snapshotId)}/assertions?offset=${offset}&limit=${limit}`, config); + }, + + listPaths(snapshotId: string, offset = 0, limit = 50, config?: RequestConfig): Promise> { + return api.get(`/intelligence/v1/snapshots/${encodeURIComponent(snapshotId)}/paths?offset=${offset}&limit=${limit}`, config); + }, + + listEvidence(snapshotId: string, offset = 0, limit = 50, config?: RequestConfig): Promise> { + return api.get(`/intelligence/v1/snapshots/${encodeURIComponent(snapshotId)}/evidence?offset=${offset}&limit=${limit}`, config); + }, +}; diff --git a/apps/frontend/src/shared/services/api/types.ts b/apps/frontend/src/shared/services/api/types.ts index 296ad8d8..498cc067 100644 --- a/apps/frontend/src/shared/services/api/types.ts +++ b/apps/frontend/src/shared/services/api/types.ts @@ -83,6 +83,94 @@ export interface RepositoryFileResponse { mediaType: string | null; } +// Repository Intelligence snapshot queries (`/intelligence/v1`). These are +// read-only views of sealed, owner-scoped normalized facts, not legacy metadata. +export interface RiPagination { + offset: number; + limit: number; + total: number; +} + +export interface RiEvidence { + schemaVersion: string; + factKind: 'node' | 'edge' | 'observation'; + factId: string; + path: string; + startLine: number; + endLine: number; + granularity: 'span' | 'file'; + extractor: string; + extractorVersion: string; +} + +export interface RiNode { + stableKey: string; + nodeKind: string; + name: string | null; + language: string | null; + truthClass: 'observed'; + properties: Record | null; + evidence: RiEvidence[]; +} + +export interface RiEdge { + edgeId: string; + subjectKind: string; + subjectKey: string; + predicate: string; + objectKind: string; + objectKey: string; + truthClass: 'resolved'; + producer: string; + producerVersion: string; + evidence: RiEvidence[]; + derivedFrom: Array<{ kind: string; identity: string }>; +} + +export interface RiAssertion { + assertionId: string; + subjectKind: string; + subjectKey: string; + predicate: string; + value: Record; + truthClass: 'inferred'; + producer: string; + producerVersion: string; + derivedFrom: Array<{ kind: string; identity: string }>; +} + +export interface RiSnapshotMetadata { + schemaVersion: string; + snapshotId: string; + repositoryId: string; + revisionKind: 'git' | 'upload'; + revisionValue: string; + revisionRef: string | null; + state: 'completed'; + producerVersionSet: string[]; + producerSetHash: string; + configHash: string; + canonicalGraphHash: string; + createdAt: string; + updatedAt: string; + sealedAt: string; +} + +export interface RiCollectionResponse { + schemaVersion: string; + data: T[]; + pagination: RiPagination; +} + +export interface RiPath { + path: string; + node: RiNode; +} + +export interface RiNeighboursResponse extends RiCollectionResponse { + nodeKey: string; +} + // Analysis export interface AnalysisStartResponse { repositoryId: string; diff --git a/docs/architecture/REPOSITORY_INTELLIGENCE.md b/docs/architecture/REPOSITORY_INTELLIGENCE.md index 55bcbd52..0f7edeb9 100644 --- a/docs/architecture/REPOSITORY_INTELLIGENCE.md +++ b/docs/architecture/REPOSITORY_INTELLIGENCE.md @@ -22,7 +22,7 @@ If your feature needs a repository fact that does not exist yet, the answer is a The production extraction path is still one Pydantic model — `RepositoryIntelligence` ([`app/intelligence/models.py`](../../apps/backend/app/intelligence/models.py)) — built by one engine — `RepositoryIntelligenceEngine` ([`app/intelligence/engine.py`](../../apps/backend/app/intelligence/engine.py)) — and serialized onto the repository row. That blob is retained as explicitly legacy/unverified compatibility data. -The `ri.v1` persistence boundary now also exists: 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, their support matrices, the repository-level source-policy pipeline, and deterministic [relationship resolution](REPOSITORY_INTELLIGENCE_RESOLUTION.md) over stored observations exist under `app/extraction` and `app/intelligence`; the Issue #94 benchmark executes and validates the extraction pipeline. Product ingestion still does not run either the legacy regex output or the new extractors through the normalized snapshot tables. Query APIs, durable job orchestration, and consumer migration remain separate work (#92, #93, and #95). +The `ri.v1` persistence boundary now also exists: 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, their support matrices, the repository-level source-policy pipeline, and deterministic [relationship resolution](REPOSITORY_INTELLIGENCE_RESOLUTION.md) over stored observations exist under `app/extraction` and `app/intelligence`; the Issue #94 benchmark executes and validates the extraction pipeline. The versioned, owner-scoped `/intelligence/v1/snapshots` API reads sealed normalized snapshots only; it never falls back to legacy metadata or a repository working tree. Product ingestion still does not run either the legacy regex output or the new extractors through the normalized snapshot tables, and durable job orchestration and consumer migration remain separate work (#93 and #95). ```mermaid flowchart LR @@ -119,7 +119,7 @@ The repository API returns `revision: {kind,value,ref}` and retains `commitSha` Consumers still call `RepositoryIntelligenceEngine.from_record(record)`, which returns the legacy model if present and **rebuilds it from disk as a fallback** if it is missing or fails validation. That compatibility path is not an `ri.v1` snapshot producer: its regex facts have no valid spans or versioned provenance and are never promoted to `observed`, `resolved`, or `inferred` rows. -The normalized `ri_*` tables are ready for conforming producers. `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 atomically. Completed snapshots reject mutation. There is intentionally no snapshot query API or consumer cutover in this change. +The normalized `ri_*` tables are ready for conforming producers. `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 atomically. Completed snapshots reject mutation. The query API exposes sealed snapshot metadata, symbols, stored resolved relationships, inferred assertions, file facts, and evidence spans; product consumers have not yet migrated to it. --- @@ -187,8 +187,8 @@ Do not describe PARTHA as having evidence-backed or grounded output until line s ## Current limitations - **Symbols:** regex-derived, Python and TS/JS only, no line spans, no signatures, no nesting, no cross-file resolution. Matches inside comments and strings are not excluded. -- **Line spans:** emitted by the Python and TypeScript extractors, but not yet populated and served by the product ingestion/query path. -- **Graph production and consumption:** normalized immutable graph tables and syntax-aware producers exist, but no durable product job or query/consumer path populates and serves them yet. Product surfaces still read the legacy JSON blob. +- **Line spans:** emitted by the Python and TypeScript extractors and returned unchanged when a sealed snapshot exists, but product ingestion does not yet create those snapshots. +- **Graph production and consumption:** normalized immutable graph tables, syntax-aware producers, and a sealed-snapshot query API exist, but no durable product job populates them and product surfaces still read the legacy JSON blob. - **Relationships:** four of the eight declared types are never emitted. An import edge resolves to a declared dependency when the name matches and otherwise creates an `external:` node — there is no real module resolution. - **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, no lockfiles, 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. diff --git a/docs/architecture/REPOSITORY_INTELLIGENCE_V1_RFC.md b/docs/architecture/REPOSITORY_INTELLIGENCE_V1_RFC.md index c782f853..72996b62 100644 --- a/docs/architecture/REPOSITORY_INTELLIGENCE_V1_RFC.md +++ b/docs/architecture/REPOSITORY_INTELLIGENCE_V1_RFC.md @@ -1727,7 +1727,7 @@ rules), the three columns are explicit: | Relationships | Deterministic resolver over stored observations, with resolved edges and explicit unresolved/ambiguous diagnostics | Resolved edges + diagnostics (§5) | **Implemented** (#91); product job wiring remains #93 | | Inferred entity properties | Legacy heuristic module roles remain in the compatibility blob; the snapshot store supports separate validated assertions | Separate inferred property assertions; observed nodes remain unique (§5.6) | **Persistence implemented** (#88); production inference/querying remains #91/#92 | | Provenance | Extractors emit path + span + producer/version and the normalized store validates them; legacy product consumers still receive file paths only | Path + span + extractor/version (§6) | **Producer and persistence implemented** (#88–#90); product orchestration/querying remains #92/#93 | -| Query API | Consumers read the whole blob | Versioned owner-scoped read API (§9.5) | **Unimplemented** (#92) | +| Query API | Consumers read the whole blob | Versioned owner-scoped read API (§9.5) | **Implemented** (#92); durable population and consumer migration remain separate work | | 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 @@ -1736,7 +1736,7 @@ behavior.** RFC-0001 is **Accepted** — independently ratified by ([§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 -and #94 benchmark plus #91 deterministic resolution are implemented; queries, jobs, and consumer migration remain +and #94 benchmark plus #91 deterministic resolution and #92 sealed-snapshot queries are implemented; durable jobs and consumer migration remain downstream work and are not current product behavior. No existing documentation is rewritten by this RFC to imply otherwise. From 9b46d462e70a7b1194b25d52042979c22780ccb0 Mon Sep 17 00:00:00 2001 From: parthrohit22 Date: Sat, 18 Jul 2026 17:12:34 +0100 Subject: [PATCH 114/347] fix(intelligence): enforce snapshot schema version --- apps/backend/app/api/routes/intelligence.py | 5 ++-- apps/backend/app/core/exceptions.py | 4 ++++ .../backend/app/intelligence/query_service.py | 9 +++++++- apps/backend/app/schemas/intelligence.py | 17 +++++++------- .../tests/test_intelligence_query_api.py | 23 ++++++++++++++++++- apps/backend/tests/test_openapi_contract.py | 2 +- .../frontend/src/shared/services/api/types.ts | 8 ++++--- docs/architecture/REPOSITORY_INTELLIGENCE.md | 2 +- 8 files changed, 52 insertions(+), 18 deletions(-) diff --git a/apps/backend/app/api/routes/intelligence.py b/apps/backend/app/api/routes/intelligence.py index f8b8453d..9cbd2572 100644 --- a/apps/backend/app/api/routes/intelligence.py +++ b/apps/backend/app/api/routes/intelligence.py @@ -5,7 +5,7 @@ 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, suppress_automatic_validation_error +from app.api.openapi import documented_responses from app.intelligence.query_service import SnapshotQueryService from app.schemas.intelligence import ( RiAssertionResponse, @@ -117,8 +117,7 @@ def _pagination(offset: int, limit: int, total: int) -> RiPagination: @router.get( "/{snapshot_id}", response_model=RiSnapshotMetadataResponse, - responses=documented_responses(200, "Sealed snapshot metadata.", {"schemaVersion": "ri.v1"}, 401, 404, 429, 500), - openapi_extra=suppress_automatic_validation_error(), + 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)) diff --git a/apps/backend/app/core/exceptions.py b/apps/backend/app/core/exceptions.py index 418915cb..e6828449 100644 --- a/apps/backend/app/core/exceptions.py +++ b/apps/backend/app/core/exceptions.py @@ -48,6 +48,10 @@ class ValidationServiceError(ServiceError): 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/intelligence/query_service.py b/apps/backend/app/intelligence/query_service.py index 1917dac1..8365fd50 100644 --- a/apps/backend/app/intelligence/query_service.py +++ b/apps/backend/app/intelligence/query_service.py @@ -5,7 +5,7 @@ from sqlalchemy import func, or_, select from sqlalchemy.orm import Session -from app.core.exceptions import NotFoundError +from app.core.exceptions import NotFoundError, UnsupportedSchemaVersionError from app.models.snapshot import RiAssertion, RiDerivation, RiEdge, RiEvidence, RiNode, RiSnapshot @@ -16,6 +16,8 @@ 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) @@ -164,6 +166,11 @@ def _snapshot(self, snapshot_id: str) -> RiSnapshot: ).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 _page(self, model, where: tuple, order_by: tuple, offset: int, limit: int): diff --git a/apps/backend/app/schemas/intelligence.py b/apps/backend/app/schemas/intelligence.py index c8f24023..a6b46ea4 100644 --- a/apps/backend/app/schemas/intelligence.py +++ b/apps/backend/app/schemas/intelligence.py @@ -9,10 +9,11 @@ FactKind = Literal["node", "edge", "observation"] +SchemaVersion = Literal["ri.v1"] class RiEvidenceResponse(CamelModel): - schema_version: str + schema_version: SchemaVersion fact_kind: FactKind fact_id: str path: str @@ -71,7 +72,7 @@ class RiPagination(CamelModel): class RiSnapshotMetadataResponse(CamelModel): - schema_version: str + schema_version: SchemaVersion snapshot_id: str repository_id: str revision_kind: Literal["git", "upload"] @@ -88,37 +89,37 @@ class RiSnapshotMetadataResponse(CamelModel): class RiSymbolsResponse(CamelModel): - schema_version: str + schema_version: SchemaVersion data: list[RiNodeResponse] pagination: RiPagination class RiNeighboursResponse(CamelModel): - schema_version: str + schema_version: SchemaVersion node_key: str data: list[RiEdgeResponse] pagination: RiPagination class RiReferencesResponse(CamelModel): - schema_version: str + schema_version: SchemaVersion data: list[RiEdgeResponse] pagination: RiPagination class RiAssertionsResponse(CamelModel): - schema_version: str + schema_version: SchemaVersion data: list[RiAssertionResponse] pagination: RiPagination class RiPathsResponse(CamelModel): - schema_version: str + schema_version: SchemaVersion data: list[RiPathResponse] pagination: RiPagination class RiEvidenceResponsePage(CamelModel): - schema_version: str + schema_version: SchemaVersion data: list[RiEvidenceResponse] pagination: RiPagination diff --git a/apps/backend/tests/test_intelligence_query_api.py b/apps/backend/tests/test_intelligence_query_api.py index 15454a91..a3fee0fb 100644 --- a/apps/backend/tests/test_intelligence_query_api.py +++ b/apps/backend/tests/test_intelligence_query_api.py @@ -7,7 +7,7 @@ from tests.conftest import register_user -def _seed_snapshot(owner_id: str, *, suffix: str = "one") -> tuple[str, str]: +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 @@ -37,6 +37,7 @@ def _seed_snapshot(owner_id: str, *, suffix: str = "one") -> tuple[str, str]: 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: @@ -165,6 +166,25 @@ def test_snapshot_query_mappings_and_deterministic_pagination(client, make_auth_ 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}" + + rejected = client.get(path, 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") @@ -179,3 +199,4 @@ def test_snapshot_query_openapi_documents_versioned_routes_and_schemas(client): assert "/intelligence/v1/snapshots/{snapshot_id}/symbols" in document["paths"] 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_openapi_contract.py b/apps/backend/tests/test_openapi_contract.py index 60acb1a7..af6aa2ad 100644 --- a/apps/backend/tests/test_openapi_contract.py +++ b/apps/backend/tests/test_openapi_contract.py @@ -37,7 +37,7 @@ ("GET", "/repositories/{repository_id}"): {200, 401, 404, 429, 500}, ("GET", "/repositories/{repository_id}/file"): {200, 401, 404, 422, 429, 500}, ("DELETE", "/repositories/{repository_id}"): {204, 401, 404, 429, 500}, - ("GET", "/intelligence/v1/snapshots/{snapshot_id}"): {200, 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}/references"): {200, 401, 404, 422, 429, 500}, diff --git a/apps/frontend/src/shared/services/api/types.ts b/apps/frontend/src/shared/services/api/types.ts index 498cc067..6f3c43e6 100644 --- a/apps/frontend/src/shared/services/api/types.ts +++ b/apps/frontend/src/shared/services/api/types.ts @@ -85,6 +85,8 @@ export interface RepositoryFileResponse { // Repository Intelligence snapshot queries (`/intelligence/v1`). These are // read-only views of sealed, owner-scoped normalized facts, not legacy metadata. +export type RiSchemaVersion = 'ri.v1'; + export interface RiPagination { offset: number; limit: number; @@ -92,7 +94,7 @@ export interface RiPagination { } export interface RiEvidence { - schemaVersion: string; + schemaVersion: RiSchemaVersion; factKind: 'node' | 'edge' | 'observation'; factId: string; path: string; @@ -140,7 +142,7 @@ export interface RiAssertion { } export interface RiSnapshotMetadata { - schemaVersion: string; + schemaVersion: RiSchemaVersion; snapshotId: string; repositoryId: string; revisionKind: 'git' | 'upload'; @@ -157,7 +159,7 @@ export interface RiSnapshotMetadata { } export interface RiCollectionResponse { - schemaVersion: string; + schemaVersion: RiSchemaVersion; data: T[]; pagination: RiPagination; } diff --git a/docs/architecture/REPOSITORY_INTELLIGENCE.md b/docs/architecture/REPOSITORY_INTELLIGENCE.md index 0f7edeb9..b7a011fe 100644 --- a/docs/architecture/REPOSITORY_INTELLIGENCE.md +++ b/docs/architecture/REPOSITORY_INTELLIGENCE.md @@ -22,7 +22,7 @@ If your feature needs a repository fact that does not exist yet, the answer is a The production extraction path is still one Pydantic model — `RepositoryIntelligence` ([`app/intelligence/models.py`](../../apps/backend/app/intelligence/models.py)) — built by one engine — `RepositoryIntelligenceEngine` ([`app/intelligence/engine.py`](../../apps/backend/app/intelligence/engine.py)) — and serialized onto the repository row. That blob is retained as explicitly legacy/unverified compatibility data. -The `ri.v1` persistence boundary now also exists: 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, their support matrices, the repository-level source-policy pipeline, and deterministic [relationship resolution](REPOSITORY_INTELLIGENCE_RESOLUTION.md) over stored observations exist under `app/extraction` and `app/intelligence`; the Issue #94 benchmark executes and validates the extraction pipeline. The versioned, owner-scoped `/intelligence/v1/snapshots` API reads sealed normalized snapshots only; it never falls back to legacy metadata or a repository working tree. Product ingestion still does not run either the legacy regex output or the new extractors through the normalized snapshot tables, and durable job orchestration and consumer migration remain separate work (#93 and #95). +The `ri.v1` persistence boundary now also exists: 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, their support matrices, the repository-level source-policy pipeline, and deterministic [relationship resolution](REPOSITORY_INTELLIGENCE_RESOLUTION.md) over stored observations exist under `app/extraction` and `app/intelligence`; the Issue #94 benchmark executes and validates the extraction pipeline. The versioned, owner-scoped `/intelligence/v1/snapshots` API reads sealed `ri.v1` normalized snapshots only and explicitly rejects unsupported snapshot schema versions; it never falls back to legacy metadata or a repository working tree. Product ingestion still does not run either the legacy regex output or the new extractors through the normalized snapshot tables, and durable job orchestration and consumer migration remain separate work (#93 and #95). ```mermaid flowchart LR From fc6e8cda268f7b53107fe95c4277e8e046105456 Mon Sep 17 00:00:00 2001 From: parthrohit22 Date: Sat, 18 Jul 2026 17:13:56 +0100 Subject: [PATCH 115/347] test(intelligence): cover unsupported snapshot versions --- apps/backend/tests/test_intelligence_query_api.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/apps/backend/tests/test_intelligence_query_api.py b/apps/backend/tests/test_intelligence_query_api.py index a3fee0fb..e7efbd20 100644 --- a/apps/backend/tests/test_intelligence_query_api.py +++ b/apps/backend/tests/test_intelligence_query_api.py @@ -172,11 +172,12 @@ def test_query_rejects_owner_visible_unsupported_schema_without_cross_owner_disc _, snapshot_id = _seed_snapshot(owner["user"]["id"], schema_version="ri.v2") path = f"/intelligence/v1/snapshots/{snapshot_id}" - rejected = client.get(path, 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"]} + for suffix in ["", "/symbols", "/neighbours?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"]) From 6c27f57fa541a683afe7b26908aaf5e9d8d6a160 Mon Sep 17 00:00:00 2001 From: hardikuppal04 <262667963+hardikuppal04@users.noreply.github.com> Date: Sat, 18 Jul 2026 21:16:27 +0530 Subject: [PATCH 116/347] fix(review): exclude generated oversized files (#121) --- apps/backend/app/review/review_service.py | 120 +++++++++++++++++- apps/backend/app/schemas/review.py | 8 ++ apps/backend/tests/test_review_evidence.py | 66 +++++++++- .../review/components/FindingDetail.test.tsx | 29 +++++ .../review/components/FindingDetail.tsx | 7 +- apps/frontend/src/shared/types/review.ts | 6 + docs/architecture/REPOSITORY_INTELLIGENCE.md | 2 + 7 files changed, 229 insertions(+), 9 deletions(-) create mode 100644 apps/frontend/src/features/review/components/FindingDetail.test.tsx diff --git a/apps/backend/app/review/review_service.py b/apps/backend/app/review/review_service.py index d5438232..e3d18d8f 100644 --- a/apps/backend/app/review/review_service.py +++ b/apps/backend/app/review/review_service.py @@ -1,8 +1,17 @@ from datetime import UTC, datetime +from pathlib import PurePosixPath -from app.intelligence.engine import RepositoryIntelligenceEngine +from app.intelligence.engine import SOURCE_EXTENSIONS, RepositoryIntelligenceEngine +from app.intelligence.models import SourceFileIntelligence from app.models.repository import RepositoryRecord -from app.schemas.review import EngineeringReviewResponse, ImprovementStep, ReviewFinding, ReviewScore, ReviewSummary +from app.schemas.review import ( + EngineeringReviewResponse, + ImprovementStep, + ReviewFileEvidence, + ReviewFinding, + ReviewScore, + ReviewSummary, +) LARGE_FILE_BYTES = 40_000 LARGE_SOURCE_SURFACE = 300 @@ -10,6 +19,88 @@ 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"} +# The oversized-source review is intentionally limited to refactorable authored +# code. Lockfiles, configuration, generated/minified code, vendored code, and +# build outputs are not design evidence, even when they are large. +LOCKFILE_NAMES = frozenset( + { + "bun.lockb", + "cargo.lock", + "composer.lock", + "gemfile.lock", + "go.sum", + "mix.lock", + "npm-shrinkwrap.json", + "package-lock.json", + "pdm.lock", + "pipfile.lock", + "pnpm-lock.yaml", + "poetry.lock", + "uv.lock", + "yarn.lock", + } +) +ARTIFACT_DIRECTORY_NAMES = frozenset( + { + ".next", + ".nuxt", + "__generated__", + "build", + "coverage", + "dist", + "gen", + "generated", + "node_modules", + "out", + "target", + "third-party", + "third_party", + "vendor", + } +) +GENERATED_SOURCE_SUFFIXES = ( + ".designer.cs", + ".g.ts", + ".gen.js", + ".gen.ts", + ".gen.tsx", + ".generated.js", + ".generated.py", + ".generated.ts", + ".generated.tsx", + ".min.cjs", + ".min.js", + ".min.mjs", + "_pb2.py", +) +CONFIGURATION_SOURCE_SUFFIXES = ( + ".conf.js", + ".conf.ts", + ".config.js", + ".config.ts", + ".config.tsx", +) + + +def _is_refactorable_source_file(file: SourceFileIntelligence) -> bool: + """Return whether a file is authored source eligible for the size review. + + The rules are path- and metadata-based so the review does not infer a + maintainability problem from generated or dependency-managed artifacts. + """ + + path = PurePosixPath(file.path.casefold()) + name = path.name + return ( + file.size > LARGE_FILE_BYTES + and file.extension in SOURCE_EXTENSIONS + and file.role not in {"configuration", "documentation"} + and name not in LOCKFILE_NAMES + and not any(part in ARTIFACT_DIRECTORY_NAMES for part in path.parts) + and not name.endswith(GENERATED_SOURCE_SUFFIXES) + and not name.endswith(CONFIGURATION_SOURCE_SUFFIXES) + ) + class EngineeringReviewBuilder: def __init__(self, intelligence: RepositoryIntelligenceEngine | None = None) -> None: @@ -107,21 +198,34 @@ def _findings(self, intelligence) -> list[ReviewFinding]: ) ) large_files = sorted( - (file for file in files if file.role != "documentation" and file.size > LARGE_FILE_BYTES), + (file for file in files if _is_refactorable_source_file(file)), key=lambda file: file.size, reverse=True, ) if large_files: + large_file_evidence = [ + ReviewFileEvidence(path=file.path, size_bytes=file.size) for file in large_files[:10] + ] 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]], + problem=( + f"{len(large_files)} authored source file(s) exceed {LARGE_FILE_BYTES // 1000} KB. " + "Size is a review signal; it does not establish a design problem by itself." + ), + impact=( + "Large authored source files can require more context to review, test, and change safely, " + "so they merit a focused review before any refactoring decision." + ), + recommendation=( + "Review the listed files with their measured sizes as context; refactor only where " + "responsibilities or complexity warrant it." + ), + affected_files=[evidence.path for evidence in large_file_evidence], + affected_file_details=large_file_evidence, ) ) if not discovery.ci_files: @@ -164,6 +268,7 @@ def _finding( recommendation: str, affected_files: list[str], affected_modules: list[str] | None = None, + affected_file_details: list[ReviewFileEvidence] | None = None, ) -> ReviewFinding: return ReviewFinding( id=finding_id, @@ -177,6 +282,7 @@ def _finding( priority=SEVERITY_PRIORITY[severity], estimated_effort="small" if severity in {"low", "medium"} else "medium", affected_files=affected_files, + affected_file_details=affected_file_details or [], affected_modules=affected_modules or ["repository"], tags=[category], ) diff --git a/apps/backend/app/schemas/review.py b/apps/backend/app/schemas/review.py index fe15f34b..237358de 100644 --- a/apps/backend/app/schemas/review.py +++ b/apps/backend/app/schemas/review.py @@ -1,6 +1,8 @@ from datetime import datetime from typing import Literal +from pydantic import Field + from app.schemas.base import CamelModel ReviewSeverity = Literal["critical", "high", "medium", "low"] @@ -19,6 +21,11 @@ ] +class ReviewFileEvidence(CamelModel): + path: str + size_bytes: int + + class ReviewFinding(CamelModel): id: str title: str @@ -31,6 +38,7 @@ class ReviewFinding(CamelModel): priority: int estimated_effort: Literal["trivial", "small", "medium", "large", "major"] affected_files: list[str] + affected_file_details: list[ReviewFileEvidence] = Field(default_factory=list) affected_modules: list[str] tags: list[str] diff --git a/apps/backend/tests/test_review_evidence.py b/apps/backend/tests/test_review_evidence.py index 8a893b5a..cc160232 100644 --- a/apps/backend/tests/test_review_evidence.py +++ b/apps/backend/tests/test_review_evidence.py @@ -1,10 +1,17 @@ from datetime import UTC, datetime from pathlib import Path +import pytest + from app.intelligence.engine import RepositoryIntelligenceEngine +from app.intelligence.models import SourceFileIntelligence from app.models.repository import RepositoryRecord from app.parsers.repository_parser import RepositoryParser -from app.review.review_service import EngineeringReviewBuilder +from app.review.review_service import ( + LARGE_FILE_BYTES, + EngineeringReviewBuilder, + _is_refactorable_source_file, +) _OLD_PLACEHOLDER_IMPACT = "This can reduce maintainability, correctness, or operational confidence." @@ -67,3 +74,60 @@ def test_roadmap_is_derived_from_findings(tmp_path: Path): for step in review.roadmap: assert step.related_findings # each step links back to real findings assert set(step.related_findings) <= finding_ids + + +@pytest.mark.parametrize( + ("path", "extension", "role", "expected"), + [ + ("package-lock.json", "json", "unknown", False), + ("poetry.lock", "lock", "unknown", False), + ("vendor/runtime.ts", "ts", "unknown", False), + ("src/generated/client.ts", "ts", "unknown", False), + ("src/client.generated.ts", "ts", "unknown", False), + ("src/bundle.min.js", "js", "unknown", False), + ("build/application.ts", "ts", "unknown", False), + ("vite.config.ts", "ts", "configuration", False), + ("webpack.config.ts", "ts", "unknown", False), + ("src/application.ts", "ts", "service", True), + ], +) +def test_large_file_rule_only_accepts_refactorable_authored_source( + path: str, + extension: str, + role: str, + expected: bool, +): + file = SourceFileIntelligence( + path=path, + name=path.rsplit("/", 1)[-1], + module_id="module:source", + extension=extension, + size=LARGE_FILE_BYTES + 1, + role=role, # type: ignore[arg-type] + ) + + assert _is_refactorable_source_file(file) is expected + + +def test_large_file_finding_excludes_artifacts_and_includes_measured_source_size(tmp_path: Path): + source_dir = tmp_path / "src" + source_dir.mkdir() + authored_source = "export const value = 1;\n" * 3_000 + lockfile = '{"lockfileVersion": 3}\n' * 3_000 + (source_dir / "application.ts").write_bytes(authored_source.encode("utf-8")) + (tmp_path / "package-lock.json").write_bytes(lockfile.encode("utf-8")) + + review = _review(tmp_path) + finding = next(finding for finding in review.findings if finding.id == "large-files") + + expected_path = next(path for path in finding.affected_files if path.endswith("src/application.ts")) + assert finding.affected_files == [expected_path] + expected_details = [(expected_path, len(authored_source.encode("utf-8")))] + assert [(detail.path, detail.size_bytes) for detail in finding.affected_file_details] == expected_details + assert finding.model_dump(by_alias=True)["affectedFileDetails"] == [ + {"path": path, "sizeBytes": size} for path, size in expected_details + ] + copy = " ".join((finding.problem, finding.impact, finding.recommendation)).lower() + assert "review signal" in copy + assert "low cohesion" not in copy + assert "god-file" not in copy diff --git a/apps/frontend/src/features/review/components/FindingDetail.test.tsx b/apps/frontend/src/features/review/components/FindingDetail.test.tsx new file mode 100644 index 00000000..d4b5b396 --- /dev/null +++ b/apps/frontend/src/features/review/components/FindingDetail.test.tsx @@ -0,0 +1,29 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import type { ReviewFinding } from '@/shared/types/review'; +import { FindingDetail } from './FindingDetail'; + +const largeFileFinding: ReviewFinding = { + id: 'large-files', + title: 'Oversized Source Files', + category: 'maintainability', + severity: 'medium', + status: 'open', + problem: 'One authored source file exceeds 40 KB. Size is a review signal.', + impact: 'Review the file with its measured size as context.', + recommendation: 'Refactor only where responsibilities or complexity warrant it.', + priority: 3, + estimatedEffort: 'small', + affectedFiles: ['/src/application.ts'], + affectedFileDetails: [{ path: '/src/application.ts', sizeBytes: 72_000 }], + affectedModules: ['src'], + tags: ['maintainability'], +}; + +describe('FindingDetail', () => { + it('shows the measured size for files with review evidence', () => { + render(); + + expect(screen.getByText('/src/application.ts (72000 bytes)')).toBeInTheDocument(); + }); +}); diff --git a/apps/frontend/src/features/review/components/FindingDetail.tsx b/apps/frontend/src/features/review/components/FindingDetail.tsx index 13cc7e0b..9e22f054 100644 --- a/apps/frontend/src/features/review/components/FindingDetail.tsx +++ b/apps/frontend/src/features/review/components/FindingDetail.tsx @@ -11,6 +11,9 @@ interface FindingDetailProps { } export function FindingDetail({ finding, onClose }: FindingDetailProps) { + const fileSizes = new Map( + (finding.affectedFileDetails ?? []).map((file) => [file.path, file.sizeBytes]), + ); const severityColor = { critical: 'text-red-400', high: 'text-orange-400', @@ -68,7 +71,9 @@ export function FindingDetail({ finding, onClose }: FindingDetailProps) {
    {finding.affectedFiles.map((file) => ( -
  • {file}
  • +
  • + {file}{fileSizes.has(file) ? ` (${fileSizes.get(file)} bytes)` : ''} +
  • ))}
diff --git a/apps/frontend/src/shared/types/review.ts b/apps/frontend/src/shared/types/review.ts index 97285df8..50ebc006 100644 --- a/apps/frontend/src/shared/types/review.ts +++ b/apps/frontend/src/shared/types/review.ts @@ -13,6 +13,11 @@ export type ReviewCategory = | 'dependency-health' | 'configuration'; +export interface ReviewFileEvidence { + path: string; + sizeBytes: number; +} + export interface ReviewFinding { id: string; title: string; @@ -25,6 +30,7 @@ export interface ReviewFinding { priority: number; estimatedEffort: 'trivial' | 'small' | 'medium' | 'large' | 'major'; affectedFiles: string[]; + affectedFileDetails: ReviewFileEvidence[]; affectedModules: string[]; tags: string[]; } diff --git a/docs/architecture/REPOSITORY_INTELLIGENCE.md b/docs/architecture/REPOSITORY_INTELLIGENCE.md index b7a011fe..9bfa09fc 100644 --- a/docs/architecture/REPOSITORY_INTELLIGENCE.md +++ b/docs/architecture/REPOSITORY_INTELLIGENCE.md @@ -170,6 +170,8 @@ Two terms with distinct meanings. PARTHA uses them precisely, and supports neith **Evidence: partial.** Graph relationships and engineering-review findings carry the **file paths** they were derived from. That is real evidence, and it is enough to point a reader at the right file. +For the `Oversized Source Files` review signal, PARTHA evaluates only authored source-code extensions above the configured threshold. It excludes documentation and configuration files, common dependency lockfiles, generated or minified filenames, and files under vendor, generated, dependency, or build-output directories. The finding includes each retained file's measured byte size; size is a review signal, not a diagnosis of a design issue. + **Product-consumed provenance: incomplete.** The new persistence schema can store complete `ri.v1` provenance and the standalone extractors can produce it, but the current product path still consumes the legacy regex engine. Specifically: - **No line spans.** `SourceSymbol` has `id`, `name`, `kind`, `file_path`, and `exported`. It has **no start or end line**. Nothing in the model records where in a file a fact was found. From e0f93436e8611c32a9f23dc4e18341bc2ac084da Mon Sep 17 00:00:00 2001 From: hardikuppal04 <262667963+hardikuppal04@users.noreply.github.com> Date: Sat, 18 Jul 2026 22:10:40 +0530 Subject: [PATCH 117/347] fix(review): exclude additional generated artifacts --- apps/backend/app/review/review_service.py | 5 +++++ apps/backend/tests/test_review_evidence.py | 6 ++++++ 2 files changed, 11 insertions(+) diff --git a/apps/backend/app/review/review_service.py b/apps/backend/app/review/review_service.py index e3d18d8f..f891e31b 100644 --- a/apps/backend/app/review/review_service.py +++ b/apps/backend/app/review/review_service.py @@ -45,12 +45,14 @@ ".next", ".nuxt", "__generated__", + "bin", "build", "coverage", "dist", "gen", "generated", "node_modules", + "obj", "out", "target", "third-party", @@ -60,6 +62,7 @@ ) GENERATED_SOURCE_SUFFIXES = ( ".designer.cs", + ".g.cs", ".g.ts", ".gen.js", ".gen.ts", @@ -71,7 +74,9 @@ ".min.cjs", ".min.js", ".min.mjs", + ".pb.go", "_pb2.py", + "_pb2_grpc.py", ) CONFIGURATION_SOURCE_SUFFIXES = ( ".conf.js", diff --git a/apps/backend/tests/test_review_evidence.py b/apps/backend/tests/test_review_evidence.py index cc160232..d8754d17 100644 --- a/apps/backend/tests/test_review_evidence.py +++ b/apps/backend/tests/test_review_evidence.py @@ -85,7 +85,13 @@ def test_roadmap_is_derived_from_findings(tmp_path: Path): ("src/generated/client.ts", "ts", "unknown", False), ("src/client.generated.ts", "ts", "unknown", False), ("src/bundle.min.js", "js", "unknown", False), + ("src/views.g.cs", "cs", "unknown", False), + ("api/service.pb.go", "go", "unknown", False), + ("rpc/service_pb2_grpc.py", "py", "unknown", False), + ("bin/tool.cs", "cs", "unknown", False), ("build/application.ts", "ts", "unknown", False), + ("obj/views.cs", "cs", "unknown", False), + ("obj/views.g.cs", "cs", "unknown", False), ("vite.config.ts", "ts", "configuration", False), ("webpack.config.ts", "ts", "unknown", False), ("src/application.ts", "ts", "service", True), From f4c4af4a8a66fbe5b407006986dc6145bb7a1fbd Mon Sep 17 00:00:00 2001 From: Shaurya K Sharma Date: Sat, 18 Jul 2026 18:05:22 +0100 Subject: [PATCH 118/347] fix(intelligence): address env evidence review feedback (#122) --- apps/backend/app/intelligence/engine.py | 73 +++++++++++++++- .../tests/test_environment_file_review.py | 84 ++++++++++++++++++- docs/architecture/REPOSITORY_INTELLIGENCE.md | 2 +- 3 files changed, 150 insertions(+), 9 deletions(-) diff --git a/apps/backend/app/intelligence/engine.py b/apps/backend/app/intelligence/engine.py index e8532e4d..09dd1702 100644 --- a/apps/backend/app/intelligence/engine.py +++ b/apps/backend/app/intelligence/engine.py @@ -43,13 +43,16 @@ } ENVIRONMENT_TEMPLATE_NAMES = {".env.example", ".env.sample", ".env.template", ".env.dist"} SECRET_KEY_NAME_PATTERN = re.compile( - r"(?:^|_)(?:api_?key|access_?key|auth_?token|client_?secret|credential|password|private_?key|secret|token)(?:$|_)", + r"(?:^|_)(?:api_?key|access_?key|auth_?token|client_?secret|credential|password|private_?key|secret_?key|secret|token)$", flags=re.IGNORECASE, ) PLACEHOLDER_VALUE_PATTERN = re.compile( r"^(?:\$\{[^}]+\}|\$[A-Za-z_][A-Za-z0-9_]*|<[^>]+>|\[[^]]+\]|(?:example|sample|placeholder|replace|your)[\s_-].*|(?:change|replace)[\s_-]?me|not[\s_-]?a[\s_-]?real[\s_-]?.*|x+|\*+)$", flags=re.IGNORECASE, ) +NUMBER_VALUE_PATTERN = re.compile(r"[+-]?(?:\d+(?:\.\d+)?|\.\d+)(?:e[+-]?\d+)?", flags=re.IGNORECASE) +PATH_VALUE_PATTERN = re.compile(r"^(?:[A-Za-z]:[\\/]|[/~]|\.{1,2}[\\/]|file://)", flags=re.IGNORECASE) +URL_VALUE_PATTERN = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*://") DATABASE_TECHNOLOGIES = { "postgres": "PostgreSQL", "postgresql": "PostgreSQL", @@ -84,7 +87,18 @@ class RepositoryIntelligenceEngine: def from_record(self, record: RepositoryRecord) -> RepositoryIntelligence: existing = self.load(record) - if existing and (not existing.discovery.environment_files or existing.discovery.environment_file_evidence): + if existing: + if existing.discovery.environment_files and not existing.discovery.environment_file_evidence: + # Legacy cache entries predate content-derived environment evidence. Upgrade + # them in bounded O(environment files) time without rereading or rebuilding + # the repository. Unknown legacy content is deliberately treated as a runtime + # file, which cannot produce a critical secret-exposure finding. + evidence = [ + EnvironmentFileEvidence(path=path, evidence_class="runtime_env_file_present") + for path in existing.discovery.environment_files + ] + discovery = existing.discovery.model_copy(update={"environment_file_evidence": evidence}) + return existing.model_copy(update={"discovery": discovery}) return existing return self.build( repository_id=record.id, @@ -409,13 +423,39 @@ def _secret_like_keys(self, text: str) -> list[str]: continue key, value = clean.split("=", 1) key = key.strip() - value = value.strip().strip("\"'") + value = self._parse_dotenv_value(value) if not key or self._is_placeholder_value(value): continue - if SECRET_KEY_NAME_PATTERN.search(key) or self._has_embedded_credentials(value): + if self._has_embedded_credentials(value) or ( + SECRET_KEY_NAME_PATTERN.search(key) and self._is_credible_secret_value(value) + ): keys.add(key) return sorted(keys) + def _parse_dotenv_value(self, value: str) -> str: + """Remove dotenv quoting and an unquoted, whitespace-delimited comment.""" + normalized = value.strip() + quote: str | None = None + escaped = False + for index, character in enumerate(normalized): + if escaped: + escaped = False + continue + if quote: + if character == "\\" and quote == '"': + escaped = True + elif character == quote: + quote = None + continue + if character in {'"', "'"}: + quote = character + elif character == "#" and (index == 0 or normalized[index - 1].isspace()): + normalized = normalized[:index].rstrip() + break + if len(normalized) >= 2 and normalized[0] == normalized[-1] and normalized[0] in {'"', "'"}: + normalized = normalized[1:-1] + return normalized + def _is_placeholder_value(self, value: str) -> bool: normalized = value.strip() if not normalized or normalized.lower() in { @@ -438,6 +478,31 @@ def _is_placeholder_value(self, value: str) -> bool: return True return bool(PLACEHOLDER_VALUE_PATTERN.fullmatch(normalized)) + def _is_credible_secret_value(self, value: str) -> bool: + """Require value-shaped evidence in addition to a sensitive key name.""" + normalized = value.strip() + if self._is_placeholder_value(normalized): + return False + if normalized.lower() in {"true", "false", "yes", "no", "on", "off", "enabled", "disabled"}: + return False + if NUMBER_VALUE_PATTERN.fullmatch(normalized): + return False + if PATH_VALUE_PATTERN.match(normalized): + return False + if URL_VALUE_PATTERN.match(normalized): + return self._has_embedded_credentials(normalized) + if len(normalized) < 8: + return False + character_classes = sum( + ( + any(character.islower() for character in normalized), + any(character.isupper() for character in normalized), + any(character.isdigit() for character in normalized), + any(not character.isalnum() for character in normalized), + ) + ) + return character_classes >= 2 or len(normalized) >= 16 + def _has_embedded_credentials(self, value: str) -> bool: if "-----BEGIN" in value and "PRIVATE KEY-----" in value: return True diff --git a/apps/backend/tests/test_environment_file_review.py b/apps/backend/tests/test_environment_file_review.py index 4b275376..12738772 100644 --- a/apps/backend/tests/test_environment_file_review.py +++ b/apps/backend/tests/test_environment_file_review.py @@ -74,6 +74,75 @@ def test_template_with_a_secret_like_value_is_not_trusted_by_filename(tmp_path: assert findings["env-secret-like-value-detected"].severity == "critical" +@pytest.mark.parametrize( + "content", + [ + "PASSWORD_MIN_LENGTH=12\n", + "TOKEN_EXPIRY_SECONDS=3600\n", + "CLIENT_SECRET_REQUIRED=true\n", + "API_KEY_ENABLED=false\n", + "PRIVATE_KEY_PATH=/run/keys/service.pem\n", + ], +) +def test_security_related_configuration_is_not_credible_secret_evidence(tmp_path: Path, content: str): + (tmp_path / ".env").write_text(content, encoding="utf-8") + + intelligence, review = _review(tmp_path) + findings = {finding.id: finding for finding in review.findings} + + assert intelligence.discovery.environment_file_evidence[0].evidence_class == "runtime_env_file_present" + assert "env-secret-like-value-detected" not in findings + assert findings["env-runtime-file-present"].severity == "medium" + + +@pytest.mark.parametrize( + ("key", "value"), + [ + ("SECRET_KEY", "synthetic-secret-123"), + ("AUTH_SECRET_KEY", "synthetic-secret-123"), + ("DJANGO_SECRET_KEY", "synthetic-secret-123"), + ("SESSION_SECRET_KEY", "synthetic-secret-123"), + ], +) +def test_secret_key_names_with_credible_values_remain_secret_evidence(tmp_path: Path, key: str, value: str): + (tmp_path / ".env").write_text(f"{key}={value}\n", encoding="utf-8") + + intelligence, review = _review(tmp_path) + evidence = intelligence.discovery.environment_file_evidence[0] + findings = {finding.id: finding for finding in review.findings} + + assert evidence.evidence_class == "secret_like_value_detected" + assert evidence.secret_keys == [key] + assert findings["env-secret-like-value-detected"].severity == "critical" + + +@pytest.mark.parametrize( + "content", + [ + "TOKEN=${TOKEN} # access token\n", + 'API_KEY="${API_KEY}" # required\n', + ], +) +def test_placeholder_before_inline_comment_is_not_treated_as_a_secret(tmp_path: Path, content: str): + (tmp_path / ".env.example").write_text(content, encoding="utf-8") + + intelligence, review = _review(tmp_path) + findings = {finding.id: finding for finding in review.findings} + + assert intelligence.discovery.environment_file_evidence[0].evidence_class == "template_present" + assert "env-secret-like-value-detected" not in findings + assert findings["env-template-present"].severity == "low" + + +def test_hash_inside_quoted_secret_is_not_parsed_as_an_inline_comment(tmp_path: Path): + (tmp_path / ".env").write_text('API_SECRET="real-secret#fragment" # deployment credential\n', encoding="utf-8") + + intelligence, review = _review(tmp_path) + + assert intelligence.discovery.environment_file_evidence[0].evidence_class == "secret_like_value_detected" + assert "env-secret-like-value-detected" in {finding.id for finding in review.findings} + + @pytest.mark.parametrize( "content", [ @@ -112,7 +181,9 @@ def test_real_url_credential_is_flagged_without_leaking_the_value(tmp_path: Path assert all(secret_value not in key for key in evidence.secret_keys) -def test_cached_intelligence_without_environment_evidence_is_refreshed(tmp_path: Path): +def test_cached_intelligence_without_environment_evidence_uses_bounded_fallback( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): (tmp_path / ".env.example").write_text("API_KEY=\n", encoding="utf-8") tree, meta, total_size = RepositoryParser().parse(tmp_path) intelligence = RepositoryIntelligenceEngine().build("repo-1", "sample", tmp_path, tree, meta, total_size) @@ -137,8 +208,13 @@ def test_cached_intelligence_without_environment_evidence_is_refreshed(tmp_path: file_tree=tree, ) - review = EngineeringReviewBuilder().build(record) + engine = RepositoryIntelligenceEngine() + monkeypatch.setattr(engine, "build", lambda *args, **kwargs: pytest.fail("legacy cache must not be rebuilt")) + + loaded = engine.from_record(record) + review = EngineeringReviewBuilder(engine).build(record) findings = {finding.id: finding for finding in review.findings} - assert "env-template-present" in findings - assert "env-runtime-file-present" not in findings + assert loaded.discovery.environment_file_evidence[0].evidence_class == "runtime_env_file_present" + assert "env-template-present" not in findings + assert findings["env-runtime-file-present"].severity == "medium" diff --git a/docs/architecture/REPOSITORY_INTELLIGENCE.md b/docs/architecture/REPOSITORY_INTELLIGENCE.md index 9b412d19..2e7caea5 100644 --- a/docs/architecture/REPOSITORY_INTELLIGENCE.md +++ b/docs/architecture/REPOSITORY_INTELLIGENCE.md @@ -170,7 +170,7 @@ Two terms with distinct meanings. PARTHA uses them precisely, and supports neith **Evidence: partial.** Graph relationships and engineering-review findings carry the **file paths** they were derived from. That is real evidence, and it is enough to point a reader at the right file. -Environment-file review findings also name their evidence class: a committed template, a runtime environment file with no detected secret-like value, or a secret-like value. A `.env.example`, `.env.sample`, `.env.template`, or `.env.dist` filename is never treated as proof of an exposed secret. The review reports sensitive key names and file paths, never values; rotation advice appears only when a non-placeholder secret-like value is detected. +Environment-file review findings also name their evidence class: a committed template, a runtime environment file with no detected secret-like value, or a secret-like value. A `.env.example`, `.env.sample`, `.env.template`, or `.env.dist` filename is never treated as proof of an exposed secret. Dotenv quoting and inline comments are removed before placeholder checks. Sensitive-looking configuration keys whose names continue with metadata such as `_ENABLED`, `_REQUIRED`, `_PATH`, or `_EXPIRY_SECONDS` do not count as credential keys, and boolean, numeric, path, or ordinary URL values do not provide credible secret evidence. The review reports sensitive key names and file paths, never values; rotation advice appears only when a credential-shaped, non-placeholder value is detected. Legacy cached intelligence without content-derived environment evidence is upgraded in bounded time to non-critical runtime-file evidence instead of rebuilding the repository on every read. **Product-consumed provenance: incomplete.** The new persistence schema can store complete `ri.v1` provenance and the standalone extractors can produce it, but the current product path still consumes the legacy regex engine. Specifically: From 5863a31e7ce1309cab243177d3e8cea2d5f1a89e Mon Sep 17 00:00:00 2001 From: Shaurya K Sharma Date: Sat, 18 Jul 2026 19:02:30 +0100 Subject: [PATCH 119/347] fix(intelligence): recognize credential key variants --- apps/backend/app/intelligence/engine.py | 3 ++- apps/backend/tests/test_environment_file_review.py | 4 ++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/backend/app/intelligence/engine.py b/apps/backend/app/intelligence/engine.py index 09dd1702..d789484f 100644 --- a/apps/backend/app/intelligence/engine.py +++ b/apps/backend/app/intelligence/engine.py @@ -43,7 +43,8 @@ } ENVIRONMENT_TEMPLATE_NAMES = {".env.example", ".env.sample", ".env.template", ".env.dist"} SECRET_KEY_NAME_PATTERN = re.compile( - r"(?:^|_)(?:api_?key|access_?key|auth_?token|client_?secret|credential|password|private_?key|secret_?key|secret|token)$", + r"(?:^|_)(?:api_?key|access_?key(?:_?id)?|auth_?token|client_?secret|" + r"credentials?|passw(?:or)?d|pwd|private_?key|secret_?key|secret|token)$", flags=re.IGNORECASE, ) PLACEHOLDER_VALUE_PATTERN = re.compile( diff --git a/apps/backend/tests/test_environment_file_review.py b/apps/backend/tests/test_environment_file_review.py index 12738772..6464581a 100644 --- a/apps/backend/tests/test_environment_file_review.py +++ b/apps/backend/tests/test_environment_file_review.py @@ -102,6 +102,10 @@ def test_security_related_configuration_is_not_credible_secret_evidence(tmp_path ("AUTH_SECRET_KEY", "synthetic-secret-123"), ("DJANGO_SECRET_KEY", "synthetic-secret-123"), ("SESSION_SECRET_KEY", "synthetic-secret-123"), + ("AWS_ACCESS_KEY_ID", "synthetic-access-key-123"), + ("SERVICE_CREDENTIALS", "synthetic-credentials-123"), + ("DB_PASSWD", "synthetic-password-123"), + ("DB_PWD", "synthetic-password-123"), ], ) def test_secret_key_names_with_credible_values_remain_secret_evidence(tmp_path: Path, key: str, value: str): From fc3cd003c4aefea449437f05bd03ab3a0867d040 Mon Sep 17 00:00:00 2001 From: Shaurya K Sharma Date: Sat, 18 Jul 2026 20:17:56 +0100 Subject: [PATCH 120/347] fix(review): guard env evidence and report truncation --- apps/backend/app/review/review_service.py | 6 +++++- .../tests/test_environment_file_review.py | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/apps/backend/app/review/review_service.py b/apps/backend/app/review/review_service.py index 371feaed..fc6a6856 100644 --- a/apps/backend/app/review/review_service.py +++ b/apps/backend/app/review/review_service.py @@ -187,6 +187,10 @@ def _findings(self, intelligence) -> list[ReviewFinding]: secret_files = [item.path for item in secret_evidence] secret_keys = sorted({key for item in secret_evidence for key in item.secret_keys}) if secret_files: + secret_key_summary = ", ".join(secret_keys[:10]) + omitted_secret_key_count = len(secret_keys) - 10 + if omitted_secret_key_count > 0: + secret_key_summary += f", and {omitted_secret_key_count} more" findings.append( self._finding( "env-secret-like-value-detected", @@ -195,7 +199,7 @@ def _findings(self, intelligence) -> list[ReviewFinding]: "critical", problem=( "Evidence class: secret-like value detected. Committed environment file(s) contain " - f"non-placeholder values for sensitive key(s): {', '.join(secret_keys[:10])}." + f"non-placeholder values for sensitive key(s): {secret_key_summary}." ), 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.", diff --git a/apps/backend/tests/test_environment_file_review.py b/apps/backend/tests/test_environment_file_review.py index 6464581a..35b4c67b 100644 --- a/apps/backend/tests/test_environment_file_review.py +++ b/apps/backend/tests/test_environment_file_review.py @@ -82,6 +82,8 @@ def test_template_with_a_secret_like_value_is_not_trusted_by_filename(tmp_path: "CLIENT_SECRET_REQUIRED=true\n", "API_KEY_ENABLED=false\n", "PRIVATE_KEY_PATH=/run/keys/service.pem\n", + "GOOGLE_APPLICATION_CREDENTIALS=/run/secrets/sa.json\n", + "CLIENT_ID=client-Prod-12345678\n", ], ) def test_security_related_configuration_is_not_credible_secret_evidence(tmp_path: Path, content: str): @@ -120,6 +122,22 @@ def test_secret_key_names_with_credible_values_remain_secret_evidence(tmp_path: assert findings["env-secret-like-value-detected"].severity == "critical" +def test_secret_key_finding_reports_omitted_key_count(tmp_path: Path): + content = "\n".join( + f"SERVICE_{index:02d}_API_KEY=synthetic-secret-{index:02d}" for index in range(12) + ) + (tmp_path / ".env").write_text(f"{content}\n", encoding="utf-8") + + intelligence, review = _review(tmp_path) + evidence = intelligence.discovery.environment_file_evidence[0] + critical = {finding.id: finding for finding in review.findings}["env-secret-like-value-detected"] + + assert len(evidence.secret_keys) == 12 + assert "SERVICE_09_API_KEY" in critical.problem + assert "SERVICE_10_API_KEY" not in critical.problem + assert "and 2 more" in critical.problem + + @pytest.mark.parametrize( "content", [ From ef49ee9c839e0f31b42c42d403168017bcfae7a1 Mon Sep 17 00:00:00 2001 From: parthrohit22 Date: Sat, 18 Jul 2026 18:25:06 +0100 Subject: [PATCH 121/347] fix(intelligence): discover nested dependency manifests --- apps/backend/app/api/routes/analysis.py | 29 ++- apps/backend/app/extraction/manifests.py | 77 +++++-- apps/backend/app/graph/dependency_graph.py | 4 + apps/backend/app/intelligence/engine.py | 194 ++++++++++++++---- apps/backend/app/intelligence/models.py | 30 ++- apps/backend/app/parsers/repository_parser.py | 7 + apps/backend/app/schemas/dependencies.py | 31 ++- .../extraction/test_dependency_manifests.py | 23 +++ apps/backend/tests/test_ingestion_pipeline.py | 67 ++++++ apps/backend/tests/test_openapi_contract.py | 16 ++ apps/backend/tests/test_report_builders.py | 18 +- .../tests/test_repository_intelligence.py | 133 ++++++++++++ .../src/app/pages/DependenciesPage.test.tsx | 17 ++ .../frontend/src/shared/services/api/types.ts | 31 ++- docs/architecture/REPOSITORY_INTELLIGENCE.md | 7 +- 15 files changed, 611 insertions(+), 73 deletions(-) diff --git a/apps/backend/app/api/routes/analysis.py b/apps/backend/app/api/routes/analysis.py index 94cadb98..36b9de78 100644 --- a/apps/backend/app/api/routes/analysis.py +++ b/apps/backend/app/api/routes/analysis.py @@ -102,9 +102,34 @@ def get_architecture( "Dependency inventory and declared relationships.", { "repositoryId": _REPOSITORY_ID, - "nodes": [], + "nodes": [ + { + "id": "dependency: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.1.0", + "ecosystem": "npm", + "version": "^18.3.0", + "type": "production", + } + ], + "size": None, + } + ], "edges": [], - "totalDependencies": 0, + "totalDependencies": 1, + "manifestCount": 1, + "diagnostics": [], "vulnerabilityAssessment": {"status": "not_computed"}, "outdatedAssessment": {"status": "not_computed"}, }, diff --git a/apps/backend/app/extraction/manifests.py b/apps/backend/app/extraction/manifests.py index dc5a23d5..7000c423 100644 --- a/apps/backend/app/extraction/manifests.py +++ b/apps/backend/app/extraction/manifests.py @@ -7,6 +7,7 @@ from __future__ import annotations +from dataclasses import dataclass import json import posixpath import re @@ -28,6 +29,21 @@ _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 _ManifestStructureError(Exception): @@ -61,7 +77,7 @@ class DependencyManifestExtractor: """Extract direct npm/PyPI declarations as observed dependency facts.""" name = "dependency-manifest" - version = "1.0.0" + version = "1.1.0" @property def producer(self) -> str: @@ -113,10 +129,10 @@ def extract(self, path: str, source: bytes) -> ExtractionResult: nodes: list[ExtractedNode] = [] observations: list[ExtractedObservation] = [] diagnostics: list[ExtractedDiagnostic] = [] - for ecosystem, name, line in declarations: - stable_key = self._dependency_key(ecosystem, name) + for declaration in declarations: + stable_key = self._dependency_key(declaration.ecosystem, declaration.name) evidence, diagnostic = build_evidence( - normalized_path, line, line, line_count, producer=self.producer + normalized_path, declaration.line, declaration.line, line_count, producer=self.producer ) if evidence is None: if diagnostic is not None: @@ -126,9 +142,16 @@ def extract(self, path: str, source: bytes) -> ExtractionResult: ExtractedNode( node_kind="dependency", stable_key=stable_key, - name=name, + 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( @@ -136,7 +159,7 @@ def extract(self, path: str, source: bytes) -> ExtractionResult: observed_kind="dependency", subject_kind="dependency", subject_key=stable_key, - referent_text=name, + referent_text=declaration.name, ordinal=0, evidence=evidence, ) @@ -154,12 +177,12 @@ def _dependency_key(ecosystem: str, name: str) -> str: return canonical.normalize_stable_key("dependency", f"dep:{ecosystem}:{name}") @staticmethod - def _npm_declarations(text: str) -> list[tuple[str, str, int]]: + 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 = DependencyManifestExtractor._json_object_member_lines(text) - declarations: list[tuple[str, str, int]] = [] + declarations: list[_ManifestDeclaration] = [] for section in _NPM_SECTIONS: if section not in parsed: continue @@ -176,11 +199,19 @@ def _npm_declarations(text: str) -> list[tuple[str, str, int]]: line = section_lines.get(name) if line is None: raise _ManifestStructureError(f"npm {section} declaration line could not be located") - declarations.append(("npm", str(name), line)) + 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[tuple[str, str, int]]: + def _pyproject_declarations(text: str) -> list[_ManifestDeclaration]: parsed = tomllib.loads(text) project = parsed.get("project", {}) if not isinstance(project, dict): @@ -198,23 +229,39 @@ def _pyproject_declarations(text: str) -> list[tuple[str, str, int]]: # 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[tuple[str, str, int]] = [] + declarations: list[_ManifestDeclaration] = [] for value, line in zip(values, element_lines): name = DependencyManifestExtractor._python_requirement_name(value) if name: - declarations.append(("pypi", name, line)) + 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[tuple[str, str, int]]: - declarations: list[tuple[str, str, int]] = [] + def _requirements_declarations(text: str) -> list[_ManifestDeclaration]: + declarations: list[_ManifestDeclaration] = [] for line_number, raw in enumerate(text.splitlines(), start=1): value = raw.split("#", 1)[0].strip() if not value or value.startswith(("-", ".")): continue name = DependencyManifestExtractor._python_requirement_name(value) if name: - declarations.append(("pypi", name, line_number)) + declarations.append( + _ManifestDeclaration( + ecosystem="pypi", + name=name, + version=value[len(name) :].strip() or None, + dependency_type="production", + line=line_number, + ) + ) return declarations @staticmethod diff --git a/apps/backend/app/graph/dependency_graph.py b/apps/backend/app/graph/dependency_graph.py index 9bcf17c2..fa2143bf 100644 --- a/apps/backend/app/graph/dependency_graph.py +++ b/apps/backend/app/graph/dependency_graph.py @@ -20,6 +20,8 @@ def build(self, record: RepositoryRecord) -> DependencyGraphResponse: name=dependency.name, version=dependency.version, type=dependency.type, + ecosystem=dependency.ecosystem, + declarations=dependency.declarations, size=None, ) for dependency in repository_intelligence.dependencies @@ -35,6 +37,8 @@ def build(self, record: RepositoryRecord) -> DependencyGraphResponse: nodes=nodes, edges=edges, total_dependencies=len(nodes), + manifest_count=repository_intelligence.dependency_manifest_count, + diagnostics=repository_intelligence.dependency_diagnostics, vulnerability_assessment=DependencyAssessment(status="not_computed"), outdated_assessment=DependencyAssessment(status="not_computed"), ) diff --git a/apps/backend/app/intelligence/engine.py b/apps/backend/app/intelligence/engine.py index d789484f..740a80c5 100644 --- a/apps/backend/app/intelligence/engine.py +++ b/apps/backend/app/intelligence/engine.py @@ -1,8 +1,6 @@ from __future__ import annotations -import json import re -import tomllib from collections import Counter, defaultdict from datetime import UTC, datetime from pathlib import Path @@ -10,6 +8,8 @@ from app.intelligence.models import ( EnvironmentFileEvidence, + DependencyDeclaration, + DependencyDiagnostic, KnowledgeGraph, KnowledgeGraphNode, KnowledgeGraphRelationship, @@ -22,6 +22,7 @@ SourceRole, SourceSymbol, ) +from app.intelligence import canonical from app.models.repository import RepositoryRecord from app.schemas.repository import FileTreeNode, RepositoryMeta @@ -136,7 +137,7 @@ def build( 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) + dependencies, dependency_manifest_count, dependency_diagnostics = self._dependencies(root, flat_files) discovery = self._discovery(root, 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) @@ -150,6 +151,8 @@ def build( files=file_intelligence, symbols=symbols, dependencies=dependencies, + dependency_manifest_count=dependency_manifest_count, + dependency_diagnostics=dependency_diagnostics, graph=graph, ) @@ -305,49 +308,127 @@ def _technologies(self, path: str, text: str) -> list[str]: 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(): + def _dependencies( + self, root: Path, files: list[FileTreeNode] + ) -> tuple[list[RepositoryDependency], int, list[DependencyDiagnostic]]: + """Extract declarations from parser-approved manifest inventory only. + + ``RepositoryParser`` has already applied the repository's ignored-path + policy to ``files``. This bridge deliberately never walks ``root`` or + guesses manifest locations; it forwards the selected bytes to the + canonical manifest extractor and retains all returned provenance. + """ + + # Extraction modules import canonical path helpers from this package, + # so load them only after the intelligence package has initialized. + from app.extraction.manifests import DependencyManifestExtractor + from app.extraction.pipeline import ExtractionPipeline + + extractor = DependencyManifestExtractor() + sources: dict[str, bytes] = {} + manifest_paths: list[str] = [] + diagnostics: list[DependencyDiagnostic] = [] + for file in sorted(files, key=lambda item: item.path): + try: + path = canonical.normalize_repo_path(file.path.lstrip("/")) + except canonical.PathEscapeError: + continue + if not extractor.supports(path): + continue + manifest_paths.append(path) 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("#"): + sources[path] = (root / path).read_bytes() + except OSError: + # The parser inventory is the source of truth. A file that + # disappears between inventory and analysis cannot become a + # fabricated zero-dependency success state. + diagnostics.append( + DependencyDiagnostic( + code="RI-SRC-MALFORMED", + category="malformed source", + severity="error", + message="dependency manifest could not be read from the parser-approved inventory", + path=path, + producer=extractor.producer, + ) + ) + + declarations_by_key: dict[str, list[DependencyDeclaration]] = defaultdict(list) + pipeline = ExtractionPipeline((extractor,)) + for run in pipeline.run(sources): + for diagnostic in run.result.diagnostics: + diagnostics.append( + DependencyDiagnostic( + code=diagnostic.code, + category=diagnostic.category, + severity=diagnostic.severity, + message=diagnostic.message, + path=diagnostic.path, + producer=run.producer, + details=dict(diagnostic.details) if diagnostic.details is not None else None, + ) + ) + if run.producer != extractor.producer: + continue + for node in run.result.nodes: + if node.node_kind != "dependency" or node.properties is None or not node.evidence: 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")) + properties = node.properties + evidence = node.evidence[0] + ecosystem = str(properties["ecosystem"]) + version = properties.get("version") + declaration = DependencyDeclaration( + name=node.name or node.stable_key.rsplit(":", 1)[-1], + manifest_path=str(properties["manifest_path"]), + workspace_path=str(properties["workspace_path"]), + start_line=evidence.start_line, + end_line=evidence.end_line, + extractor=run.producer_name, + extractor_version=run.producer_version, + ecosystem=ecosystem, + version=str(version) if version is not None else None, + type=properties["dependency_type"], # type: ignore[arg-type] + ) + declarations_by_key[node.stable_key].append(declaration) - 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, + dependencies: list[RepositoryDependency] = [] + for stable_key, declarations in sorted(declarations_by_key.items()): + ordered = sorted( + declarations, + key=lambda item: ( + item.manifest_path, + item.start_line, + item.end_line, + item.type, + item.version or "", + ), + ) + first = ordered[0] + versions = {declaration.version for declaration in ordered} + types = {declaration.type for declaration in ordered} + dependencies.append( + RepositoryDependency( + id=f"dependency:{stable_key.removeprefix('dep:')}", + name=first.name, + version=next(iter(versions)) if len(versions) == 1 else None, + type=next(iter(types)) if len(types) == 1 else "multiple", + ecosystem=first.ecosystem, + source_file=first.manifest_path, + declarations=ordered, + ) + ) + return ( + sorted(dependencies, key=lambda dependency: (dependency.ecosystem, dependency.name.lower(), dependency.id)), + len(manifest_paths), + sorted( + diagnostics, + key=lambda diagnostic: ( + diagnostic.path or "", + diagnostic.code, + diagnostic.producer, + diagnostic.message, + ), + ), ) def _discovery( @@ -659,9 +740,32 @@ def _knowledge_graph( 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})) + 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, + "declarations": [ + declaration.model_dump(mode="json", by_alias=True) + for declaration in dependency.declarations + ], + }, + ) + ) seen_nodes.add(dependency.id) - relationships.append(self._relationship(f"repository:{repository_id}", dependency.id, "depends_on", [dependency.source_file])) + relationships.append( + self._relationship( + f"repository:{repository_id}", + dependency.id, + "depends_on", + [declaration.manifest_path for declaration in dependency.declarations], + ) + ) deduped_nodes = list({node.id: node for node in nodes}.values()) deduped_relationships = list({relationship.id: relationship for relationship in relationships}.values()) diff --git a/apps/backend/app/intelligence/models.py b/apps/backend/app/intelligence/models.py index 22d41bd1..8d6ca4be 100644 --- a/apps/backend/app/intelligence/models.py +++ b/apps/backend/app/intelligence/models.py @@ -25,7 +25,7 @@ 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"] +DependencyType = Literal["production", "development", "peer", "optional", "multiple"] class RepositoryStatistics(CamelModel): @@ -98,13 +98,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): @@ -138,4 +162,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/parsers/repository_parser.py b/apps/backend/app/parsers/repository_parser.py index 40e786d8..22f0f87c 100644 --- a/apps/backend/app/parsers/repository_parser.py +++ b/apps/backend/app/parsers/repository_parser.py @@ -16,6 +16,13 @@ ".venv", "venv", "__pycache__", + ".cache", + ".mypy_cache", + ".pytest_cache", + ".tox", + "vendor", + "vendors", + "generated", } LANGUAGE_BY_EXTENSION = { diff --git a/apps/backend/app/schemas/dependencies.py b/apps/backend/app/schemas/dependencies.py index c0108203..84c8805f 100644 --- a/apps/backend/app/schemas/dependencies.py +++ b/apps/backend/app/schemas/dependencies.py @@ -3,11 +3,36 @@ from app.schemas.base import CamelModel +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"] + version: str | None + type: Literal["production", "development", "peer", "optional", "multiple"] + ecosystem: str + declarations: list[DependencyDeclaration] size: int | None = None @@ -26,5 +51,7 @@ class DependencyGraphResponse(CamelModel): nodes: list[DependencyNode] edges: list[DependencyEdge] total_dependencies: int + manifest_count: int = 0 + diagnostics: list[DependencyDiagnostic] = [] vulnerability_assessment: DependencyAssessment outdated_assessment: DependencyAssessment diff --git a/apps/backend/tests/extraction/test_dependency_manifests.py b/apps/backend/tests/extraction/test_dependency_manifests.py index 47f16ae8..97cd7c54 100644 --- a/apps/backend/tests/extraction/test_dependency_manifests.py +++ b/apps/backend/tests/extraction/test_dependency_manifests.py @@ -104,6 +104,29 @@ def test_requirements_declarations_stay_on_their_own_line(): 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.1.0" + + # --- Finding 5: fail closed on structurally invalid manifests --------------- diff --git a/apps/backend/tests/test_ingestion_pipeline.py b/apps/backend/tests/test_ingestion_pipeline.py index 0526f848..f23ddc24 100644 --- a/apps/backend/tests/test_ingestion_pipeline.py +++ b/apps/backend/tests/test_ingestion_pipeline.py @@ -160,6 +160,73 @@ def test_empty_dependency_endpoint_still_reports_uncomputed_assessments(auth_cli 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 + + payload = auth_client.get(f"/analysis/{repository_id}/dependencies").json() + assert payload["manifestCount"] == 4 + nodes = {node["id"]: node for node in payload["nodes"]} + assert set(nodes) == {"dependency:npm:react", "dependency:pypi:celery", "dependency:pypi:fastapi"} + assert nodes["dependency:pypi:fastapi"]["version"] is None + assert nodes["dependency:pypi:fastapi"]["declarations"] == [ + { + "name": "fastapi", + "manifestPath": "apps/backend/pyproject.toml", + "workspacePath": "apps/backend", + "startLine": 2, + "endLine": 2, + "extractor": "dependency-manifest", + "extractorVersion": "1.1.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.1.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.1.0", + "details": None, + } + ] + + def _walk_json(value): yield value if isinstance(value, dict): diff --git a/apps/backend/tests/test_openapi_contract.py b/apps/backend/tests/test_openapi_contract.py index af6aa2ad..47fb5eea 100644 --- a/apps/backend/tests/test_openapi_contract.py +++ b/apps/backend/tests/test_openapi_contract.py @@ -199,3 +199,19 @@ def test_readiness_documents_its_actual_non_error_503_payload(client): 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_report_builders.py b/apps/backend/tests/test_report_builders.py index 81195b68..6bbabd85 100644 --- a/apps/backend/tests/test_report_builders.py +++ b/apps/backend/tests/test_report_builders.py @@ -53,8 +53,22 @@ def _dependencies() -> DependencyGraphResponse: return DependencyGraphResponse( repository_id="repo-1", nodes=[ - DependencyNode(id="dependency:npm:react", name="react", version="^18.0.0", type="production"), - DependencyNode(id="dependency:npm:vite", name="vite", version="^5.0.0", type="development"), + DependencyNode( + id="dependency:npm:react", + name="react", + version="^18.0.0", + type="production", + ecosystem="npm", + declarations=[], + ), + DependencyNode( + id="dependency:npm:vite", + name="vite", + version="^5.0.0", + type="development", + ecosystem="npm", + declarations=[], + ), ], edges=[], total_dependencies=2, diff --git a/apps/backend/tests/test_repository_intelligence.py b/apps/backend/tests/test_repository_intelligence.py index 6906e343..99da0c0a 100644 --- a/apps/backend/tests/test_repository_intelligence.py +++ b/apps/backend/tests/test_repository_intelligence.py @@ -109,3 +109,136 @@ def test_feature_consumers_read_repository_intelligence(tmp_path: Path): assert dependencies.vulnerability_assessment.status == "not_computed" assert dependencies.outdated_assessment.status == "not_computed" assert review.summary.total_findings >= 1 + + +def _nested_manifest_repository(root: Path) -> None: + (root / "apps" / "frontend").mkdir(parents=True) + (root / "apps" / "backend").mkdir(parents=True) + (root / "services" / "worker").mkdir(parents=True) + (root / "node_modules" / "hidden").mkdir(parents=True) + (root / ".venv" / "hidden").mkdir(parents=True) + (root / "dist" / "hidden").mkdir(parents=True) + (root / "build" / "hidden").mkdir(parents=True) + (root / ".next" / "hidden").mkdir(parents=True) + (root / ".cache" / "hidden").mkdir(parents=True) + (root / "vendor" / "hidden").mkdir(parents=True) + (root / "apps" / "frontend" / "package.json").write_text( + '''{ + "dependencies": { + "react": "^18.3.0", + "shared-npm": "^1.0.0" + } +} +''', + encoding="utf-8", + ) + (root / "apps" / "backend" / "pyproject.toml").write_text( + '''[project] +dependencies = [ + "fastapi>=0.115", + "requests==2.31.0", +] +''', + encoding="utf-8", + ) + (root / "services" / "worker" / "requirements.txt").write_text( + "requests>=2.32\ncelery==5.4\n", encoding="utf-8" + ) + for directory in ("node_modules", ".venv", "dist", "build", ".next", ".cache", "vendor"): + (root / directory / "hidden" / "package.json").write_text( + '{"dependencies":{"not-visible":"1"}}', encoding="utf-8" + ) + + +def test_nested_workspace_manifest_inventory_preserves_all_declarations_and_graph_evidence(tmp_path: Path): + _nested_manifest_repository(tmp_path) + tree, metadata, total_size = RepositoryParser().parse(tmp_path) + engine = RepositoryIntelligenceEngine() + intelligence = engine.build("repo-nested", "nested", tmp_path, tree, metadata, total_size) + + dependencies = {dependency.id: dependency for dependency in intelligence.dependencies} + assert set(dependencies) == { + "dependency:npm:react", + "dependency:npm:shared-npm", + "dependency:pypi:celery", + "dependency:pypi:fastapi", + "dependency:pypi:requests", + } + assert intelligence.dependency_manifest_count == 3 + assert intelligence.dependency_diagnostics == [] + + react = dependencies["dependency:npm:react"] + assert react.version == "^18.3.0" + assert react.declarations[0].manifest_path == "apps/frontend/package.json" + assert react.declarations[0].workspace_path == "apps/frontend" + assert react.declarations[0].start_line == 3 + assert react.declarations[0].extractor == "dependency-manifest" + assert react.declarations[0].extractor_version == "1.1.0" + + fastapi = dependencies["dependency:pypi:fastapi"] + assert fastapi.declarations[0].manifest_path == "apps/backend/pyproject.toml" + assert fastapi.declarations[0].start_line == 3 + assert fastapi.declarations[0].version == ">=0.115" + + requests = dependencies["dependency:pypi:requests"] + assert requests.version is None # conflicting declarations are not overwritten + assert [ + (item.manifest_path, item.version, item.start_line) + for item in requests.declarations + ] == [ + ("apps/backend/pyproject.toml", "==2.31.0", 4), + ("services/worker/requirements.txt", ">=2.32", 1), + ] + requests_edge = next( + edge + for edge in intelligence.graph.relationships + if edge.type == "depends_on" and edge.target == requests.id + ) + assert requests_edge.evidence == [ + "apps/backend/pyproject.toml", + "services/worker/requirements.txt", + ] + assert all("not-visible" not in dependency.name for dependency in intelligence.dependencies) + + flattened = engine._flatten_files(tree) + first = engine._dependencies(tmp_path, flattened) + second = engine._dependencies(tmp_path, list(reversed(flattened))) + assert first == second + + +def test_nested_malformed_manifests_are_diagnostic_without_erasing_valid_declarations(tmp_path: Path): + (tmp_path / "apps" / "valid").mkdir(parents=True) + (tmp_path / "apps" / "broken-json").mkdir(parents=True) + (tmp_path / "apps" / "broken-toml").mkdir(parents=True) + (tmp_path / "apps" / "valid" / "requirements.txt").write_text("httpx>=0.27\n", encoding="utf-8") + (tmp_path / "apps" / "broken-json" / "package.json").write_text("{", encoding="utf-8") + (tmp_path / "apps" / "broken-toml" / "pyproject.toml").write_text("[project\ndependencies = [", encoding="utf-8") + + tree, metadata, total_size = RepositoryParser().parse(tmp_path) + intelligence = RepositoryIntelligenceEngine().build("repo-malformed", "malformed", tmp_path, tree, metadata, total_size) + + assert [dependency.name for dependency in intelligence.dependencies] == ["httpx"] + assert intelligence.dependency_manifest_count == 3 + assert [ + (diagnostic.code, diagnostic.path, diagnostic.producer) + for diagnostic in intelligence.dependency_diagnostics + ] == [ + ("RI-SRC-MALFORMED", "apps/broken-json/package.json", "dependency-manifest@1.1.0"), + ("RI-SRC-MALFORMED", "apps/broken-toml/pyproject.toml", "dependency-manifest@1.1.0"), + ] + + +def test_empty_valid_manifest_is_distinct_from_a_malformed_manifest(tmp_path: Path): + (tmp_path / "apps" / "empty").mkdir(parents=True) + (tmp_path / "apps" / "broken").mkdir(parents=True) + (tmp_path / "apps" / "empty" / "package.json").write_text("{}", encoding="utf-8") + (tmp_path / "apps" / "broken" / "package.json").write_text("{", encoding="utf-8") + + tree, metadata, total_size = RepositoryParser().parse(tmp_path) + intelligence = RepositoryIntelligenceEngine().build("repo-empty", "empty", tmp_path, tree, metadata, total_size) + + assert intelligence.dependency_manifest_count == 2 + assert intelligence.dependencies == [] + assert [(item.code, item.path) for item in intelligence.dependency_diagnostics] == [ + ("RI-SRC-MALFORMED", "apps/broken/package.json") + ] diff --git a/apps/frontend/src/app/pages/DependenciesPage.test.tsx b/apps/frontend/src/app/pages/DependenciesPage.test.tsx index 028d2888..cf6c1c7d 100644 --- a/apps/frontend/src/app/pages/DependenciesPage.test.tsx +++ b/apps/frontend/src/app/pages/DependenciesPage.test.tsx @@ -52,11 +52,28 @@ describe('DependenciesPage', () => { 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.1.0', + ecosystem: 'npm', + version: '4.17.15', + type: 'production', + }, + ], size: null, }, ], edges: [], totalDependencies: 1, + manifestCount: 1, + diagnostics: [], vulnerabilityAssessment: { status: 'not_computed' }, outdatedAssessment: { status: 'not_computed' }, }, diff --git a/apps/frontend/src/shared/services/api/types.ts b/apps/frontend/src/shared/services/api/types.ts index 6f3c43e6..33a0c507 100644 --- a/apps/frontend/src/shared/services/api/types.ts +++ b/apps/frontend/src/shared/services/api/types.ts @@ -196,11 +196,36 @@ export type ArchitectureResponse = ArchitectureModel; export interface DependencyNode { id: string; name: string; - version: string; - type: 'production' | 'development' | 'peer' | 'optional'; + version: string | null; + type: 'production' | 'development' | 'peer' | 'optional' | 'multiple'; + ecosystem: string; + declarations: DependencyDeclaration[]; size: number | null; } +export interface DependencyDeclaration { + name: string; + manifestPath: string; + workspacePath: string; + startLine: number; + endLine: number; + extractor: string; + extractorVersion: string; + ecosystem: string; + version: string | null; + type: 'production' | 'development' | 'peer' | 'optional'; +} + +export interface DependencyDiagnostic { + code: string; + category: string; + severity: 'fatal' | 'error' | 'warning' | 'info'; + message: string; + path: string | null; + producer: string; + details: Record | null; +} + export interface DependencyEdge { source: string; target: string; @@ -216,6 +241,8 @@ export interface DependencyGraphResponse { nodes: DependencyNode[]; edges: DependencyEdge[]; totalDependencies: number; + manifestCount: number; + diagnostics: DependencyDiagnostic[]; vulnerabilityAssessment: DependencyAssessment; outdatedAssessment: DependencyAssessment; } diff --git a/docs/architecture/REPOSITORY_INTELLIGENCE.md b/docs/architecture/REPOSITORY_INTELLIGENCE.md index 6ef1538f..0055d60a 100644 --- a/docs/architecture/REPOSITORY_INTELLIGENCE.md +++ b/docs/architecture/REPOSITORY_INTELLIGENCE.md @@ -48,7 +48,7 @@ flowchart LR The legacy product path reads repository source from disk in exactly two places: 1. **`RepositoryParser`** walks the extracted tree and produces `FileTreeNode[]` plus `RepositoryMeta` (languages, framework guess, entry point, counts, README/license presence). -2. **`RepositoryIntelligenceEngine`** reads individual file contents during `build()` — capped at 512 KB per file — to extract imports, exports, routes, symbols, and technology hints, and reads dependency manifests from the repository root. +2. **`RepositoryIntelligenceEngine`** reads individual file contents during `build()` — capped at 512 KB per file — to extract imports, exports, routes, and technology hints. Its dependency bridge selects supported manifests from the `RepositoryParser` file inventory and passes their bytes to the canonical `DependencyManifestExtractor`; it does not walk the repository or reimplement manifest parsing. 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. @@ -77,7 +77,7 @@ that build yet. | `files` | Per-file: path, module, language, extension, size, role, imports, exports, API routes, symbols, technologies. | Regex over file text; role from path/filename conventions. | | `modules` | Grouped modules with role, layer, path prefix, files, symbols, dependencies. | Files grouped by a derived `module_id`; role is the most common file role; layer is a lookup from role. | | `symbols` | Functions, classes, interfaces, types, enums, constants, routes. | Regex per language. **Python and TypeScript/JavaScript only.** | -| `dependencies` | Name, version, type, ecosystem, source file. | `package.json`, `requirements.txt`, `pyproject.toml`. | +| `dependencies` | Logical direct dependency plus every declared version/specifier, type, ecosystem, workspace/manifest path, exact declaration span, and extractor identity. | Supported `package.json`, `requirements.txt`, and `pyproject.toml` files at accepted root or nested workspace paths. | | `graph` | Serializable nodes and relationships. | Assembled from the above. | ### Deterministic vs. heuristic @@ -89,7 +89,7 @@ This distinction matters, and consumers must respect it. - file paths, names, extensions, sizes, and the file tree; - file counts and folder counts; - presence of README, license, Dockerfiles, CI workflow files, env files; -- dependency names and version specifiers **as declared in** the three supported manifests; +- dependency names and version specifiers **as declared in** the three supported manifests, including accepted nested workspaces; - literal import statements and route decorator strings matched by the regexes. **Heuristic** — an inference that can be wrong, and is wrong on projects that do not follow common conventions: @@ -196,6 +196,7 @@ Do not describe PARTHA as having evidence-backed or grounded output until line s - **Relationships:** four of the eight declared types are never emitted. An import edge resolves to a declared dependency when the name matches and otherwise creates an `external:` node — there is no real module resolution. - **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, no lockfiles, 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. +- **Dependency inventory:** only direct declarations from accepted `package.json`, `pyproject.toml`, and `requirements.txt` paths are reported. The parser inventory excludes `.git`, dependency/install directories, build output, virtual environments, caches, vendor paths, and generated paths; lockfiles are not read. Multiple workspace declarations remain attached to one logical dependency, including conflicts rather than an arbitrarily selected version. A malformed supported manifest produces a safe `RI-SRC-MALFORMED` diagnostic in the dependency response while valid manifests continue to contribute declarations. No transitive resolution, vulnerability scanning, or outdated-version scanning is implemented. - **Languages:** meaningful extraction covers Python and TypeScript/JavaScript. 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, synchronously, inside the HTTP request. From 5433646d95c16fb57906a83e39cdc3e9df957ec2 Mon Sep 17 00:00:00 2001 From: parthrohit22 Date: Sat, 18 Jul 2026 20:47:51 +0100 Subject: [PATCH 122/347] fix(intelligence): harden manifest inventory --- apps/backend/app/ai/prompt_builder.py | 19 ++- apps/backend/app/ai/repository_context.py | 10 +- apps/backend/app/ai/types.py | 4 +- apps/backend/app/api/routes/analysis.py | 2 +- apps/backend/app/extraction/manifests.py | 10 +- apps/backend/app/intelligence/engine.py | 125 ++++++++++++------ .../extraction/test_dependency_manifests.py | 18 ++- apps/backend/tests/test_ai_architecture.py | 18 +++ apps/backend/tests/test_ingestion_pipeline.py | 6 +- .../tests/test_repository_intelligence.py | 47 ++++++- .../src/app/pages/DependenciesPage.test.tsx | 2 +- docs/architecture/REPOSITORY_INTELLIGENCE.md | 2 +- 12 files changed, 207 insertions(+), 56 deletions(-) diff --git a/apps/backend/app/ai/prompt_builder.py b/apps/backend/app/ai/prompt_builder.py index 04ede750..9aabb185 100644 --- a/apps/backend/app/ai/prompt_builder.py +++ b/apps/backend/app/ai/prompt_builder.py @@ -32,11 +32,22 @@ def render_repository_context(self, repository_context: RepositoryContext) -> st for module in architecture.modules ], "Dependencies:", - *[ - f"- {dependency.name} {dependency.version}" - for dependency in repository_context.dependencies - ], + *[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/repository_context.py b/apps/backend/app/ai/repository_context.py index 2893d6fc..a94b7cf9 100644 --- a/apps/backend/app/ai/repository_context.py +++ b/apps/backend/app/ai/repository_context.py @@ -30,7 +30,15 @@ def build(self, record: RepositoryRecord, selected_file: str | None = None) -> R for module in repository_intelligence.modules[:10] ) dependencies = tuple( - DependencyContext(name=dependency.name, version=dependency.version) + DependencyContext( + name=dependency.name, + version=dependency.version, + declared_versions=tuple( + dict.fromkeys(declaration.version for declaration in dependency.declarations) + ), + has_version_conflict=dependency.version is None + and len({declaration.version for declaration in dependency.declarations}) > 1, + ) for dependency in repository_intelligence.dependencies[:20] ) # No citations are emitted today: the context is built from repository diff --git a/apps/backend/app/ai/types.py b/apps/backend/app/ai/types.py index 056dfcd5..751cb9a9 100644 --- a/apps/backend/app/ai/types.py +++ b/apps/backend/app/ai/types.py @@ -54,7 +54,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) diff --git a/apps/backend/app/api/routes/analysis.py b/apps/backend/app/api/routes/analysis.py index 36b9de78..57c5d500 100644 --- a/apps/backend/app/api/routes/analysis.py +++ b/apps/backend/app/api/routes/analysis.py @@ -117,7 +117,7 @@ def get_architecture( "startLine": 3, "endLine": 3, "extractor": "dependency-manifest", - "extractorVersion": "1.1.0", + "extractorVersion": "1.2.0", "ecosystem": "npm", "version": "^18.3.0", "type": "production", diff --git a/apps/backend/app/extraction/manifests.py b/apps/backend/app/extraction/manifests.py index 7000c423..2716e989 100644 --- a/apps/backend/app/extraction/manifests.py +++ b/apps/backend/app/extraction/manifests.py @@ -77,7 +77,7 @@ class DependencyManifestExtractor: """Extract direct npm/PyPI declarations as observed dependency facts.""" name = "dependency-manifest" - version = "1.1.0" + version = "1.2.0" @property def producer(self) -> str: @@ -248,9 +248,13 @@ def _pyproject_declarations(text: str) -> list[_ManifestDeclaration]: def _requirements_declarations(text: str) -> list[_ManifestDeclaration]: declarations: list[_ManifestDeclaration] = [] for line_number, raw in enumerate(text.splitlines(), start=1): - value = raw.split("#", 1)[0].strip() - if not value or value.startswith(("-", ".")): + # 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( diff --git a/apps/backend/app/intelligence/engine.py b/apps/backend/app/intelligence/engine.py index 740a80c5..ec18203e 100644 --- a/apps/backend/app/intelligence/engine.py +++ b/apps/backend/app/intelligence/engine.py @@ -325,9 +325,10 @@ def _dependencies( from app.extraction.pipeline import ExtractionPipeline extractor = DependencyManifestExtractor() - sources: dict[str, bytes] = {} - manifest_paths: list[str] = [] + pipeline = ExtractionPipeline((extractor,)) + manifest_count = 0 diagnostics: list[DependencyDiagnostic] = [] + declarations_by_key: dict[str, list[DependencyDeclaration]] = defaultdict(list) for file in sorted(files, key=lambda item: item.path): try: path = canonical.normalize_repo_path(file.path.lstrip("/")) @@ -335,9 +336,10 @@ def _dependencies( continue if not extractor.supports(path): continue - manifest_paths.append(path) + manifest_count += 1 + source_path = root / path try: - sources[path] = (root / path).read_bytes() + reported_bytes = source_path.stat().st_size except OSError: # The parser inventory is the source of truth. A file that # disappears between inventory and analysis cannot become a @@ -352,44 +354,93 @@ def _dependencies( producer=extractor.producer, ) ) - - declarations_by_key: dict[str, list[DependencyDeclaration]] = defaultdict(list) - pipeline = ExtractionPipeline((extractor,)) - for run in pipeline.run(sources): - for diagnostic in run.result.diagnostics: + continue + if reported_bytes > pipeline.max_source_bytes: diagnostics.append( DependencyDiagnostic( - code=diagnostic.code, - category=diagnostic.category, - severity=diagnostic.severity, - message=diagnostic.message, - path=diagnostic.path, - producer=run.producer, - details=dict(diagnostic.details) if diagnostic.details is not None else None, + code="RI-LIMIT-SKIP", + category="resource-limit skip", + severity="info", + message="file exceeds the configured source-size budget", + path=path, + producer=f"{pipeline.inventory_name}@{pipeline.inventory_version}", + details={ + "budgetBytes": pipeline.max_source_bytes, + "reportedBytes": reported_bytes, + }, ) ) - if run.producer != extractor.producer: continue - for node in run.result.nodes: - if node.node_kind != "dependency" or node.properties is None or not node.evidence: - continue - properties = node.properties - evidence = node.evidence[0] - ecosystem = str(properties["ecosystem"]) - version = properties.get("version") - declaration = DependencyDeclaration( - name=node.name or node.stable_key.rsplit(":", 1)[-1], - manifest_path=str(properties["manifest_path"]), - workspace_path=str(properties["workspace_path"]), - start_line=evidence.start_line, - end_line=evidence.end_line, - extractor=run.producer_name, - extractor_version=run.producer_version, - ecosystem=ecosystem, - version=str(version) if version is not None else None, - type=properties["dependency_type"], # type: ignore[arg-type] + try: + # Read at most one byte past the configured budget. This is a + # second guard against a file growing after ``stat()`` and keeps + # each candidate bounded rather than retaining all manifests. + with source_path.open("rb") as source_file: + source = source_file.read(pipeline.max_source_bytes + 1) + except OSError: + diagnostics.append( + DependencyDiagnostic( + code="RI-SRC-MALFORMED", + category="malformed source", + severity="error", + message="dependency manifest could not be read from the parser-approved inventory", + path=path, + producer=extractor.producer, + ) + ) + continue + if len(source) > pipeline.max_source_bytes: + diagnostics.append( + DependencyDiagnostic( + code="RI-LIMIT-SKIP", + category="resource-limit skip", + severity="info", + message="file exceeds the configured source-size budget", + path=path, + producer=f"{pipeline.inventory_name}@{pipeline.inventory_version}", + details={ + "budgetBytes": pipeline.max_source_bytes, + "reportedBytes": len(source), + }, + ) ) - declarations_by_key[node.stable_key].append(declaration) + continue + + for run in pipeline.run({path: source}): + for diagnostic in run.result.diagnostics: + diagnostics.append( + DependencyDiagnostic( + code=diagnostic.code, + category=diagnostic.category, + severity=diagnostic.severity, + message=diagnostic.message, + path=diagnostic.path, + producer=run.producer, + details=dict(diagnostic.details) if diagnostic.details is not None else None, + ) + ) + if run.producer != extractor.producer: + continue + for node in run.result.nodes: + if node.node_kind != "dependency" or node.properties is None or not node.evidence: + continue + properties = node.properties + evidence = node.evidence[0] + ecosystem = str(properties["ecosystem"]) + version = properties.get("version") + declaration = DependencyDeclaration( + name=node.name or node.stable_key.rsplit(":", 1)[-1], + manifest_path=str(properties["manifest_path"]), + workspace_path=str(properties["workspace_path"]), + start_line=evidence.start_line, + end_line=evidence.end_line, + extractor=run.producer_name, + extractor_version=run.producer_version, + ecosystem=ecosystem, + version=str(version) if version is not None else None, + type=properties["dependency_type"], # type: ignore[arg-type] + ) + declarations_by_key[node.stable_key].append(declaration) dependencies: list[RepositoryDependency] = [] for stable_key, declarations in sorted(declarations_by_key.items()): @@ -419,7 +470,7 @@ def _dependencies( ) return ( sorted(dependencies, key=lambda dependency: (dependency.ecosystem, dependency.name.lower(), dependency.id)), - len(manifest_paths), + manifest_count, sorted( diagnostics, key=lambda diagnostic: ( diff --git a/apps/backend/tests/extraction/test_dependency_manifests.py b/apps/backend/tests/extraction/test_dependency_manifests.py index 97cd7c54..6288bc64 100644 --- a/apps/backend/tests/extraction/test_dependency_manifests.py +++ b/apps/backend/tests/extraction/test_dependency_manifests.py @@ -124,7 +124,23 @@ def test_manifest_declarations_retain_exact_version_type_and_workspace_provenanc "workspace_path": "apps/frontend", } assert node.evidence[0].start_line == node.evidence[0].end_line == 3 - assert EXTRACTOR.producer == "dependency-manifest@1.1.0" + 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 --------------- diff --git a/apps/backend/tests/test_ai_architecture.py b/apps/backend/tests/test_ai_architecture.py index da265f03..788b22ba 100644 --- a/apps/backend/tests/test_ai_architecture.py +++ b/apps/backend/tests/test_ai_architecture.py @@ -101,6 +101,24 @@ 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): + _sample_repository(tmp_path) + (tmp_path / "requirements.txt").write_text( + "requests==2.31.0\nrequests>=2.32.0\n", encoding="utf-8" + ) + record = _record(tmp_path) + + context = RepositoryContextBuilder(RepositoryIntelligenceEngine()).build(record) + 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() diff --git a/apps/backend/tests/test_ingestion_pipeline.py b/apps/backend/tests/test_ingestion_pipeline.py index f23ddc24..0c1b3c0a 100644 --- a/apps/backend/tests/test_ingestion_pipeline.py +++ b/apps/backend/tests/test_ingestion_pipeline.py @@ -196,7 +196,7 @@ def test_dependency_endpoint_returns_nested_manifest_provenance_and_malformed_di "startLine": 2, "endLine": 2, "extractor": "dependency-manifest", - "extractorVersion": "1.1.0", + "extractorVersion": "1.2.0", "ecosystem": "pypi", "version": ">=0.115", "type": "production", @@ -208,7 +208,7 @@ def test_dependency_endpoint_returns_nested_manifest_provenance_and_malformed_di "startLine": 1, "endLine": 1, "extractor": "dependency-manifest", - "extractorVersion": "1.1.0", + "extractorVersion": "1.2.0", "ecosystem": "pypi", "version": "==0.116", "type": "production", @@ -221,7 +221,7 @@ def test_dependency_endpoint_returns_nested_manifest_provenance_and_malformed_di "severity": "error", "message": "dependency manifest could not be parsed or has an unsupported structure", "path": "apps/broken/package.json", - "producer": "dependency-manifest@1.1.0", + "producer": "dependency-manifest@1.2.0", "details": None, } ] diff --git a/apps/backend/tests/test_repository_intelligence.py b/apps/backend/tests/test_repository_intelligence.py index 99da0c0a..208e8749 100644 --- a/apps/backend/tests/test_repository_intelligence.py +++ b/apps/backend/tests/test_repository_intelligence.py @@ -173,7 +173,7 @@ def test_nested_workspace_manifest_inventory_preserves_all_declarations_and_grap assert react.declarations[0].workspace_path == "apps/frontend" assert react.declarations[0].start_line == 3 assert react.declarations[0].extractor == "dependency-manifest" - assert react.declarations[0].extractor_version == "1.1.0" + assert react.declarations[0].extractor_version == "1.2.0" fastapi = dependencies["dependency:pypi:fastapi"] assert fastapi.declarations[0].manifest_path == "apps/backend/pyproject.toml" @@ -223,8 +223,8 @@ def test_nested_malformed_manifests_are_diagnostic_without_erasing_valid_declara (diagnostic.code, diagnostic.path, diagnostic.producer) for diagnostic in intelligence.dependency_diagnostics ] == [ - ("RI-SRC-MALFORMED", "apps/broken-json/package.json", "dependency-manifest@1.1.0"), - ("RI-SRC-MALFORMED", "apps/broken-toml/pyproject.toml", "dependency-manifest@1.1.0"), + ("RI-SRC-MALFORMED", "apps/broken-json/package.json", "dependency-manifest@1.2.0"), + ("RI-SRC-MALFORMED", "apps/broken-toml/pyproject.toml", "dependency-manifest@1.2.0"), ] @@ -242,3 +242,44 @@ def test_empty_valid_manifest_is_distinct_from_a_malformed_manifest(tmp_path: Pa assert [(item.code, item.path) for item in intelligence.dependency_diagnostics] == [ ("RI-SRC-MALFORMED", "apps/broken/package.json") ] + + +def test_oversized_manifest_is_skipped_before_an_unbounded_read(tmp_path: Path, monkeypatch): + package_json = tmp_path / "apps" / "large" / "package.json" + package_json.parent.mkdir(parents=True) + package_json.write_bytes(b" " * (512 * 1024 + 1)) + + def unexpected_read_bytes(_: Path) -> bytes: + raise AssertionError("manifest candidates must not be loaded with read_bytes") + + monkeypatch.setattr(Path, "read_bytes", unexpected_read_bytes) + tree, metadata, total_size = RepositoryParser().parse(tmp_path) + intelligence = RepositoryIntelligenceEngine().build("repo-large", "large", tmp_path, tree, metadata, total_size) + + assert intelligence.dependency_manifest_count == 1 + assert intelligence.dependencies == [] + assert [(item.code, item.path, item.details) for item in intelligence.dependency_diagnostics] == [ + ( + "RI-LIMIT-SKIP", + "apps/large/package.json", + {"budgetBytes": 512 * 1024, "reportedBytes": 512 * 1024 + 1}, + ) + ] + + +def test_requirements_url_fragments_remain_distinct_during_aggregation(tmp_path: Path): + (tmp_path / "requirements.txt").write_text( + "example @ https://host.example/archive.whl#sha256=first\n" + "example @ https://host.example/archive.whl#sha256=second\n", + encoding="utf-8", + ) + tree, metadata, total_size = RepositoryParser().parse(tmp_path) + intelligence = RepositoryIntelligenceEngine().build("repo-fragments", "fragments", tmp_path, tree, metadata, total_size) + + dependency = intelligence.dependencies[0] + assert dependency.name == "example" + assert dependency.version is None + assert [item.version for item in dependency.declarations] == [ + "@ https://host.example/archive.whl#sha256=first", + "@ https://host.example/archive.whl#sha256=second", + ] diff --git a/apps/frontend/src/app/pages/DependenciesPage.test.tsx b/apps/frontend/src/app/pages/DependenciesPage.test.tsx index cf6c1c7d..66f3541d 100644 --- a/apps/frontend/src/app/pages/DependenciesPage.test.tsx +++ b/apps/frontend/src/app/pages/DependenciesPage.test.tsx @@ -61,7 +61,7 @@ describe('DependenciesPage', () => { startLine: 3, endLine: 3, extractor: 'dependency-manifest', - extractorVersion: '1.1.0', + extractorVersion: '1.2.0', ecosystem: 'npm', version: '4.17.15', type: 'production', diff --git a/docs/architecture/REPOSITORY_INTELLIGENCE.md b/docs/architecture/REPOSITORY_INTELLIGENCE.md index 0055d60a..0b58da6b 100644 --- a/docs/architecture/REPOSITORY_INTELLIGENCE.md +++ b/docs/architecture/REPOSITORY_INTELLIGENCE.md @@ -196,7 +196,7 @@ Do not describe PARTHA as having evidence-backed or grounded output until line s - **Relationships:** four of the eight declared types are never emitted. An import edge resolves to a declared dependency when the name matches and otherwise creates an `external:` node — there is no real module resolution. - **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, no lockfiles, 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. -- **Dependency inventory:** only direct declarations from accepted `package.json`, `pyproject.toml`, and `requirements.txt` paths are reported. The parser inventory excludes `.git`, dependency/install directories, build output, virtual environments, caches, vendor paths, and generated paths; lockfiles are not read. Multiple workspace declarations remain attached to one logical dependency, including conflicts rather than an arbitrarily selected version. A malformed supported manifest produces a safe `RI-SRC-MALFORMED` diagnostic in the dependency response while valid manifests continue to contribute declarations. No transitive resolution, vulnerability scanning, or outdated-version scanning is implemented. +- **Dependency inventory:** only direct declarations from accepted `package.json`, `pyproject.toml`, and `requirements.txt` paths are reported. The parser inventory excludes `.git`, dependency/install directories, build output, virtual environments, caches, vendor paths, and generated paths; lockfiles are not read. Each candidate is size-checked and read with the existing 512 KiB source budget before being processed individually; oversized manifests 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 produces a safe `RI-SRC-MALFORMED` diagnostic in the dependency response while valid manifests continue to contribute declarations. No transitive resolution, vulnerability scanning, or outdated-version scanning is implemented. - **Languages:** meaningful extraction covers Python and TypeScript/JavaScript. 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, synchronously, inside the HTTP request. From bdde6f9af374c532559a9cbb2161d145b2f6821d Mon Sep 17 00:00:00 2001 From: hardikuppal04 <262667963+hardikuppal04@users.noreply.github.com> Date: Sat, 18 Jul 2026 23:01:45 +0530 Subject: [PATCH 123/347] test(upload): cover GitHub import validation (#120) --- .../upload/hooks/useGitHubImport.test.ts | 162 ++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 apps/frontend/src/features/upload/hooks/useGitHubImport.test.ts diff --git a/apps/frontend/src/features/upload/hooks/useGitHubImport.test.ts b/apps/frontend/src/features/upload/hooks/useGitHubImport.test.ts new file mode 100644 index 00000000..0d7e088e --- /dev/null +++ b/apps/frontend/src/features/upload/hooks/useGitHubImport.test.ts @@ -0,0 +1,162 @@ +import { act, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useAppStore } from '@/app/store/useAppStore'; +import { backendService } from '@/shared/services/backend'; +import type { Repository } from '@/shared/types'; +import { useGitHubImport } from './useGitHubImport'; + +const repositoryState = vi.hoisted(() => ({ + repositories: [] as Repository[], + selectRepository: vi.fn(), +})); + +vi.mock('@/features/repositories/hooks/useRepository', () => ({ + useRepository: () => ({ + repositories: repositoryState.repositories, + selectRepository: repositoryState.selectRepository, + }), +})); + +function repository(id: string, name: string): Repository { + return { + id, + name, + source: 'github', + sourceUrl: `https://github.com/example/${name}`, + size: 0, + fileCount: 0, + status: 'analysing', + dataSource: 'real', + analysisStage: 'uploading', + analysisProgress: 0, + uploadedAt: '2026-01-01T00:00:00Z', + meta: null, + fileTree: [], + }; +} + +describe('useGitHubImport', () => { + beforeEach(() => { + repositoryState.repositories = []; + repositoryState.selectRepository.mockReset(); + useAppStore.setState({ repositories: [], activeRepositoryId: null }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it.each(['', ' '])('rejects an empty or whitespace-only URL without calling the backend: %j', async (url) => { + const importSpy = vi.spyOn(backendService, 'importFromGithub'); + const analysisSpy = vi.spyOn(backendService, 'startAnalysis'); + const refreshSpy = vi.spyOn(backendService, 'fetchRepository'); + const hook = renderHook(() => useGitHubImport()); + + act(() => { + hook.result.current.setGithubUrl(url); + }); + + let imported: Repository | null = null; + await act(async () => { + imported = await hook.result.current.analyseGithub(); + }); + + expect(imported).toBeNull(); + expect(hook.result.current.error).toBe('Please enter a GitHub repository URL.'); + expect(importSpy).not.toHaveBeenCalled(); + expect(analysisSpy).not.toHaveBeenCalled(); + expect(refreshSpy).not.toHaveBeenCalled(); + }); + + it.each([ + 'http://github.com/example/project', + 'https://gitlab.com/example/project', + 'https://github.com/example', + 'https://github.com/example/project/tree/main', + ])('rejects malformed or non-GitHub URLs without calling the backend: %s', async (url) => { + const importSpy = vi.spyOn(backendService, 'importFromGithub'); + const analysisSpy = vi.spyOn(backendService, 'startAnalysis'); + const refreshSpy = vi.spyOn(backendService, 'fetchRepository'); + const hook = renderHook(() => useGitHubImport()); + + act(() => { + hook.result.current.setGithubUrl(url); + }); + + let imported: Repository | null = null; + await act(async () => { + imported = await hook.result.current.analyseGithub(); + }); + + expect(imported).toBeNull(); + expect(hook.result.current.error).toBe( + 'Invalid GitHub URL. Format: https://github.com/owner/repository', + ); + expect(importSpy).not.toHaveBeenCalled(); + expect(analysisSpy).not.toHaveBeenCalled(); + expect(refreshSpy).not.toHaveBeenCalled(); + }); + + it('rejects a duplicate repository name case-insensitively without calling the backend', async () => { + repositoryState.repositories = [repository('existing-id', 'Existing-Project')]; + const importSpy = vi.spyOn(backendService, 'importFromGithub'); + const analysisSpy = vi.spyOn(backendService, 'startAnalysis'); + const refreshSpy = vi.spyOn(backendService, 'fetchRepository'); + const hook = renderHook(() => useGitHubImport()); + + act(() => { + hook.result.current.setGithubUrl('https://github.com/example/existing-project.git'); + }); + + let imported: Repository | null = null; + await act(async () => { + imported = await hook.result.current.analyseGithub(); + }); + + expect(imported).toBeNull(); + expect(hook.result.current.error).toBe('A repository named "existing-project" already exists.'); + expect(importSpy).not.toHaveBeenCalled(); + expect(analysisSpy).not.toHaveBeenCalled(); + expect(refreshSpy).not.toHaveBeenCalled(); + }); + + it.each([ + ['https://github.com/example/project', 'project'], + ['https://github.com/example/project.git', 'project'], + ])('imports a valid GitHub URL and preserves its repository name: %s', async (url, name) => { + const importedRepository = repository('imported-id', name); + const refreshedRepository = repository('refreshed-id', name); + const importSpy = vi.spyOn(backendService, 'importFromGithub').mockResolvedValue(importedRepository); + const analysisSpy = vi.spyOn(backendService, 'startAnalysis').mockResolvedValue(null); + const refreshSpy = vi.spyOn(backendService, 'fetchRepository').mockResolvedValue(refreshedRepository); + const hook = renderHook(() => useGitHubImport()); + + act(() => { + hook.result.current.setGithubUrl(` ${url} `); + }); + + expect(hook.result.current.success).toBe(true); + expect(hook.result.current.previewName).toBe(name); + + let imported: Repository | null = null; + await act(async () => { + imported = await hook.result.current.analyseGithub(); + }); + + expect(importSpy).toHaveBeenCalledOnce(); + expect(importSpy).toHaveBeenCalledWith(url); + expect(analysisSpy).toHaveBeenCalledOnce(); + expect(analysisSpy).toHaveBeenCalledWith(importedRepository.id); + expect(refreshSpy).toHaveBeenCalledOnce(); + expect(refreshSpy).toHaveBeenCalledWith(importedRepository.id); + expect(importSpy.mock.invocationCallOrder[0]).toBeLessThan(analysisSpy.mock.invocationCallOrder[0]); + expect(analysisSpy.mock.invocationCallOrder[0]).toBeLessThan(refreshSpy.mock.invocationCallOrder[0]); + expect(imported).toEqual(refreshedRepository); + expect(useAppStore.getState().repositories).toEqual([refreshedRepository]); + expect(repositoryState.selectRepository).toHaveBeenCalledWith(refreshedRepository); + expect(hook.result.current.githubUrl).toBe(''); + expect(hook.result.current.loading).toBe(false); + expect(hook.result.current.error).toBeNull(); + expect(hook.result.current.empty).toBe(true); + }); +}); From c03681950edfcfb48571c7b8bdcc995e321c9a73 Mon Sep 17 00:00:00 2001 From: hardikuppal04 <262667963+hardikuppal04@users.noreply.github.com> Date: Sun, 19 Jul 2026 01:36:41 +0530 Subject: [PATCH 124/347] test(upload): harden GitHub import failures --- .../upload/hooks/useGitHubImport.test.ts | 137 ++++++++++++++---- 1 file changed, 108 insertions(+), 29 deletions(-) diff --git a/apps/frontend/src/features/upload/hooks/useGitHubImport.test.ts b/apps/frontend/src/features/upload/hooks/useGitHubImport.test.ts index 0d7e088e..3c083bf9 100644 --- a/apps/frontend/src/features/upload/hooks/useGitHubImport.test.ts +++ b/apps/frontend/src/features/upload/hooks/useGitHubImport.test.ts @@ -2,6 +2,7 @@ import { act, renderHook } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { useAppStore } from '@/app/store/useAppStore'; import { backendService } from '@/shared/services/backend'; +import { ApiError } from '@/shared/services/api'; import type { Repository } from '@/shared/types'; import { useGitHubImport } from './useGitHubImport'; @@ -35,6 +36,14 @@ function repository(id: string, name: string): Repository { }; } +function mockBackendLifecycle() { + return { + importFromGithub: vi.spyOn(backendService, 'importFromGithub').mockResolvedValue(null), + startAnalysis: vi.spyOn(backendService, 'startAnalysis').mockResolvedValue(null), + fetchRepository: vi.spyOn(backendService, 'fetchRepository').mockResolvedValue(null), + }; +} + describe('useGitHubImport', () => { beforeEach(() => { repositoryState.repositories = []; @@ -47,9 +56,7 @@ describe('useGitHubImport', () => { }); it.each(['', ' '])('rejects an empty or whitespace-only URL without calling the backend: %j', async (url) => { - const importSpy = vi.spyOn(backendService, 'importFromGithub'); - const analysisSpy = vi.spyOn(backendService, 'startAnalysis'); - const refreshSpy = vi.spyOn(backendService, 'fetchRepository'); + const backend = mockBackendLifecycle(); const hook = renderHook(() => useGitHubImport()); act(() => { @@ -63,9 +70,9 @@ describe('useGitHubImport', () => { expect(imported).toBeNull(); expect(hook.result.current.error).toBe('Please enter a GitHub repository URL.'); - expect(importSpy).not.toHaveBeenCalled(); - expect(analysisSpy).not.toHaveBeenCalled(); - expect(refreshSpy).not.toHaveBeenCalled(); + expect(backend.importFromGithub).not.toHaveBeenCalled(); + expect(backend.startAnalysis).not.toHaveBeenCalled(); + expect(backend.fetchRepository).not.toHaveBeenCalled(); }); it.each([ @@ -74,9 +81,7 @@ describe('useGitHubImport', () => { 'https://github.com/example', 'https://github.com/example/project/tree/main', ])('rejects malformed or non-GitHub URLs without calling the backend: %s', async (url) => { - const importSpy = vi.spyOn(backendService, 'importFromGithub'); - const analysisSpy = vi.spyOn(backendService, 'startAnalysis'); - const refreshSpy = vi.spyOn(backendService, 'fetchRepository'); + const backend = mockBackendLifecycle(); const hook = renderHook(() => useGitHubImport()); act(() => { @@ -92,16 +97,14 @@ describe('useGitHubImport', () => { expect(hook.result.current.error).toBe( 'Invalid GitHub URL. Format: https://github.com/owner/repository', ); - expect(importSpy).not.toHaveBeenCalled(); - expect(analysisSpy).not.toHaveBeenCalled(); - expect(refreshSpy).not.toHaveBeenCalled(); + expect(backend.importFromGithub).not.toHaveBeenCalled(); + expect(backend.startAnalysis).not.toHaveBeenCalled(); + expect(backend.fetchRepository).not.toHaveBeenCalled(); }); it('rejects a duplicate repository name case-insensitively without calling the backend', async () => { repositoryState.repositories = [repository('existing-id', 'Existing-Project')]; - const importSpy = vi.spyOn(backendService, 'importFromGithub'); - const analysisSpy = vi.spyOn(backendService, 'startAnalysis'); - const refreshSpy = vi.spyOn(backendService, 'fetchRepository'); + const backend = mockBackendLifecycle(); const hook = renderHook(() => useGitHubImport()); act(() => { @@ -115,9 +118,9 @@ describe('useGitHubImport', () => { expect(imported).toBeNull(); expect(hook.result.current.error).toBe('A repository named "existing-project" already exists.'); - expect(importSpy).not.toHaveBeenCalled(); - expect(analysisSpy).not.toHaveBeenCalled(); - expect(refreshSpy).not.toHaveBeenCalled(); + expect(backend.importFromGithub).not.toHaveBeenCalled(); + expect(backend.startAnalysis).not.toHaveBeenCalled(); + expect(backend.fetchRepository).not.toHaveBeenCalled(); }); it.each([ @@ -126,9 +129,9 @@ describe('useGitHubImport', () => { ])('imports a valid GitHub URL and preserves its repository name: %s', async (url, name) => { const importedRepository = repository('imported-id', name); const refreshedRepository = repository('refreshed-id', name); - const importSpy = vi.spyOn(backendService, 'importFromGithub').mockResolvedValue(importedRepository); - const analysisSpy = vi.spyOn(backendService, 'startAnalysis').mockResolvedValue(null); - const refreshSpy = vi.spyOn(backendService, 'fetchRepository').mockResolvedValue(refreshedRepository); + const backend = mockBackendLifecycle(); + backend.importFromGithub.mockResolvedValue(importedRepository); + backend.fetchRepository.mockResolvedValue(refreshedRepository); const hook = renderHook(() => useGitHubImport()); act(() => { @@ -143,14 +146,18 @@ describe('useGitHubImport', () => { imported = await hook.result.current.analyseGithub(); }); - expect(importSpy).toHaveBeenCalledOnce(); - expect(importSpy).toHaveBeenCalledWith(url); - expect(analysisSpy).toHaveBeenCalledOnce(); - expect(analysisSpy).toHaveBeenCalledWith(importedRepository.id); - expect(refreshSpy).toHaveBeenCalledOnce(); - expect(refreshSpy).toHaveBeenCalledWith(importedRepository.id); - expect(importSpy.mock.invocationCallOrder[0]).toBeLessThan(analysisSpy.mock.invocationCallOrder[0]); - expect(analysisSpy.mock.invocationCallOrder[0]).toBeLessThan(refreshSpy.mock.invocationCallOrder[0]); + expect(backend.importFromGithub).toHaveBeenCalledOnce(); + expect(backend.importFromGithub).toHaveBeenCalledWith(url); + expect(backend.startAnalysis).toHaveBeenCalledOnce(); + expect(backend.startAnalysis).toHaveBeenCalledWith(importedRepository.id); + expect(backend.fetchRepository).toHaveBeenCalledOnce(); + expect(backend.fetchRepository).toHaveBeenCalledWith(importedRepository.id); + expect(backend.importFromGithub.mock.invocationCallOrder[0]).toBeLessThan( + backend.startAnalysis.mock.invocationCallOrder[0], + ); + expect(backend.startAnalysis.mock.invocationCallOrder[0]).toBeLessThan( + backend.fetchRepository.mock.invocationCallOrder[0], + ); expect(imported).toEqual(refreshedRepository); expect(useAppStore.getState().repositories).toEqual([refreshedRepository]); expect(repositoryState.selectRepository).toHaveBeenCalledWith(refreshedRepository); @@ -159,4 +166,76 @@ describe('useGitHubImport', () => { expect(hook.result.current.error).toBeNull(); expect(hook.result.current.empty).toBe(true); }); + + it('reports an import that returns no repository', async () => { + const backend = mockBackendLifecycle(); + const hook = renderHook(() => useGitHubImport()); + + act(() => { + hook.result.current.setGithubUrl('https://github.com/example/project'); + }); + + let imported: Repository | null = null; + await act(async () => { + imported = await hook.result.current.analyseGithub(); + }); + + expect(imported).toBeNull(); + expect(hook.result.current.error).toBe('GitHub import did not return a repository.'); + expect(hook.result.current.loading).toBe(false); + expect(backend.importFromGithub).toHaveBeenCalledOnce(); + expect(backend.startAnalysis).not.toHaveBeenCalled(); + expect(backend.fetchRepository).not.toHaveBeenCalled(); + }); + + it('reports an import that cannot be refreshed after analysis starts', async () => { + const importedRepository = repository('imported-id', 'project'); + const backend = mockBackendLifecycle(); + backend.importFromGithub.mockResolvedValue(importedRepository); + const hook = renderHook(() => useGitHubImport()); + + act(() => { + hook.result.current.setGithubUrl('https://github.com/example/project'); + }); + + let imported: Repository | null = null; + await act(async () => { + imported = await hook.result.current.analyseGithub(); + }); + + expect(imported).toBeNull(); + expect(hook.result.current.error).toBe( + 'Repository was imported but could not be refreshed from the backend.', + ); + expect(hook.result.current.loading).toBe(false); + expect(backend.importFromGithub).toHaveBeenCalledOnce(); + expect(backend.startAnalysis).toHaveBeenCalledOnce(); + expect(backend.fetchRepository).toHaveBeenCalledOnce(); + }); + + it('reports a backend rejection through getErrorMessage', async () => { + const importedRepository = repository('imported-id', 'project'); + const backend = mockBackendLifecycle(); + backend.importFromGithub.mockResolvedValue(importedRepository); + backend.startAnalysis.mockRejectedValue( + new ApiError(503, 'Service Unavailable', null, '/repositories/imported-id/analysis'), + ); + const hook = renderHook(() => useGitHubImport()); + + act(() => { + hook.result.current.setGithubUrl('https://github.com/example/project'); + }); + + let imported: Repository | null = null; + await act(async () => { + imported = await hook.result.current.analyseGithub(); + }); + + expect(imported).toBeNull(); + expect(hook.result.current.error).toBe('Service is temporarily unavailable. Please try again.'); + expect(hook.result.current.loading).toBe(false); + expect(backend.importFromGithub).toHaveBeenCalledOnce(); + expect(backend.startAnalysis).toHaveBeenCalledOnce(); + expect(backend.fetchRepository).not.toHaveBeenCalled(); + }); }); From f3c8cb1d62a33649c12060736ca43a8bb17561b9 Mon Sep 17 00:00:00 2001 From: PARTH J ROHIT Date: Sat, 18 Jul 2026 23:30:56 +0100 Subject: [PATCH 125/347] fix: build architecture relationships from resolved evidence (#123) (#132) * fix(architecture): derive relationships from resolved evidence * test(architecture): cover evidence-backed relationships * fix(architecture): harden resolved relationship mapping --- apps/backend/app/analysis/architecture.py | 423 ++++++++++++++++-- apps/backend/app/api/deps.py | 3 +- apps/backend/app/api/routes/analysis.py | 9 + .../backend/app/intelligence/query_service.py | 105 ++++- apps/backend/app/schemas/architecture.py | 32 ++ .../tests/test_architecture_relationships.py | 272 +++++++++++ .../components/RelationshipPanel.test.tsx | 149 ++++++ .../components/RelationshipPanel.tsx | 43 +- .../frontend/src/shared/types/architecture.ts | 30 ++ docs/architecture/REPOSITORY_INTELLIGENCE.md | 12 +- docs/architecture/SYSTEM_OVERVIEW.md | 4 +- 11 files changed, 1036 insertions(+), 46 deletions(-) create mode 100644 apps/backend/tests/test_architecture_relationships.py create mode 100644 apps/frontend/src/features/architecture/components/RelationshipPanel.test.tsx diff --git a/apps/backend/app/analysis/architecture.py b/apps/backend/app/analysis/architecture.py index dbab1f9d..635bc04c 100644 --- a/apps/backend/app/analysis/architecture.py +++ b/apps/backend/app/analysis/architecture.py @@ -1,11 +1,17 @@ +import posixpath + from app.intelligence.engine import RepositoryIntelligenceEngine +from app.intelligence.query_service import ArchitectureSnapshotFacts, SnapshotQueryService 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, @@ -29,27 +35,61 @@ "unknown": "shared-library", } +RELATIONSHIP_EDGE_TYPES = { + "imports": "import", + "calls": "calls", + "routes_to": "api-call", + "implements": "dependency", + "depends_on": "dependency", +} + class ArchitectureAnalyzer: - def __init__(self, intelligence: RepositoryIntelligenceEngine | None = None) -> None: + def __init__( + self, + intelligence: RepositoryIntelligenceEngine | None = None, + snapshots: SnapshotQueryService | None = None, + ) -> None: self.intelligence = intelligence or RepositoryIntelligenceEngine() + self.snapshots = snapshots def build_architecture(self, record: RepositoryRecord) -> ArchitectureResponse: - repository_intelligence = self.intelligence.from_record(record) - modules = repository_intelligence.modules or [ + # Architecture requests are persisted-data reads. ``from_record`` has a + # legacy compatibility fallback that rebuilds from ``local_path``; using + # it here would make a read endpoint depend on the working tree. + repository_intelligence = self.intelligence.load(record) + modules = (repository_intelligence.modules if repository_intelligence is not None else []) or [ RepositoryModule( id="module:repository", name="Repository", role="unknown", layer="shared", path_prefix="/", - files=[file.path for file in repository_intelligence.files[:25]], + files=( + [file.path for file in repository_intelligence.files] + if repository_intelligence is not None + else self._persisted_file_paths(record.file_tree or []) + ), symbols=[], dependencies=[], ) ] + frameworks = repository_intelligence.discovery.frameworks if repository_intelligence is not None else [] + primary_language = ( + repository_intelligence.discovery.primary_language if repository_intelligence is not None else "Unknown" + ) + entry_points = repository_intelligence.discovery.entry_points if repository_intelligence is not None else [] + facts = self.snapshots.architecture_facts(record.id) if self.snapshots is not None else None 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 +105,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]: @@ -103,34 +145,349 @@ def _nodes_for_modules(self, modules: list[RepositoryModule]) -> list[ArchNode]: ) return nodes - def _edges_for_modules(self, modules: list[RepositoryModule], nodes: list[ArchNode]) -> list[ArchEdge]: + def _persisted_file_paths(self, tree: list[dict]) -> list[str]: + paths: list[str] = [] + for item in tree: + if item.get("type") == "file" and isinstance(item.get("path"), str): + paths.append(item["path"]) + children = item.get("children") + if isinstance(children, list): + paths.extend(self._persisted_file_paths(children)) + return sorted(set(paths)) + + 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 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="low", + estimated_lines=0, + 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"), - ] + diagnostics = [self._architecture_diagnostic(item, modules_by_file, node_ids) for item in facts.diagnostics] + 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 = { + item.path + for evidence_by_fact in (facts.node_evidence, facts.observation_evidence) + for evidence in evidence_by_fact.values() + for item in evidence + if item.extractor != "repository-inventory" + } + + for item in facts.diagnostics: + if item.code not in {"RI-RES-UNRESOLVED", "RI-RES-AMBIGUOUS"}: + continue + 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 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=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} + order = {"presentation": 0, "business-logic": 1, "domain": 2, "infrastructure": 3, "shared": 4, "external": 5} layers: dict[str, list[str]] = {} for node in nodes: layers.setdefault(node.layer, []).append(node.id) diff --git a/apps/backend/app/api/deps.py b/apps/backend/app/api/deps.py index d4bb091e..fe844fcb 100644 --- a/apps/backend/app/api/deps.py +++ b/apps/backend/app/api/deps.py @@ -114,8 +114,9 @@ def get_repository_service( def get_architecture_analyzer( intelligence: RepositoryIntelligenceEngine = Depends(get_repository_intelligence_engine), + snapshots: SnapshotQueryService = Depends(get_snapshot_query_service), ) -> ArchitectureAnalyzer: - return ArchitectureAnalyzer(intelligence) + return ArchitectureAnalyzer(intelligence, snapshots) def get_dependency_graph_builder( diff --git a/apps/backend/app/api/routes/analysis.py b/apps/backend/app/api/routes/analysis.py index 57c5d500..66a1ffad 100644 --- a/apps/backend/app/api/routes/analysis.py +++ b/apps/backend/app/api/routes/analysis.py @@ -74,6 +74,15 @@ def get_analysis_status( "edges": [], "modules": [], "requestFlow": [], + "relationshipSnapshotId": None, + "diagnostics": [ + { + "code": "ARCH-REL-NOT-EXTRACTED", + "category": "relationship extraction", + "severity": "info", + "message": "No sealed repository-intelligence snapshot is available for relationship analysis.", + } + ], "summary": { "language": "Python", "framework": "FastAPI", diff --git a/apps/backend/app/intelligence/query_service.py b/apps/backend/app/intelligence/query_service.py index 8365fd50..cc0ebcc0 100644 --- a/apps/backend/app/intelligence/query_service.py +++ b/apps/backend/app/intelligence/query_service.py @@ -1,12 +1,36 @@ """Read-only, owner-scoped queries over sealed ``ri.v1`` snapshots (#92).""" from collections import defaultdict +from dataclasses import dataclass from sqlalchemy import func, or_, select from sqlalchemy.orm import Session from app.core.exceptions import NotFoundError, UnsupportedSchemaVersionError -from app.models.snapshot import RiAssertion, RiDerivation, RiEdge, RiEvidence, RiNode, RiSnapshot +from app.models.snapshot import ( + RiAssertion, + RiDerivation, + RiDiagnostic, + RiEdge, + RiEvidence, + RiNode, + RiObservation, + RiSnapshot, +) + + +@dataclass(frozen=True) +class ArchitectureSnapshotFacts: + """Persisted facts needed to build evidence-backed architecture relationships.""" + + snapshot: RiSnapshot + nodes: list[RiNode] + observations: list[RiObservation] + edges: list[RiEdge] + node_evidence: dict[int, list[RiEvidence]] + observation_evidence: dict[int, list[RiEvidence]] + edge_evidence: dict[int, list[RiEvidence]] + diagnostics: list[RiDiagnostic] class SnapshotQueryService: @@ -21,6 +45,65 @@ def __init__(self, db: Session, owner_id: str) -> None: def metadata(self, snapshot_id: str) -> RiSnapshot: return self._snapshot(snapshot_id) + 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 + nodes = list( + self.db.scalars( + select(RiNode) + .where(RiNode.snapshot_id == snapshot.snapshot_id) + .order_by(RiNode.stable_key, RiNode.id) + ).all() + ) + edges = list( + self.db.scalars( + select(RiEdge) + .where(RiEdge.snapshot_id == snapshot.snapshot_id) + .order_by(RiEdge.subject_key, RiEdge.predicate, RiEdge.object_key, RiEdge.edge_id, RiEdge.id) + ).all() + ) + observations = list( + self.db.scalars( + select(RiObservation) + .where(RiObservation.snapshot_id == snapshot.snapshot_id) + .order_by(RiObservation.observation_id, RiObservation.id) + ).all() + ) + diagnostics = list( + 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() + ) + return ArchitectureSnapshotFacts( + snapshot=snapshot, + nodes=nodes, + observations=observations, + edges=edges, + node_evidence=self._evidence_for(snapshot, "node_ref", [node.id for node in nodes]), + observation_evidence=self._evidence_for( + snapshot, + "observation_ref", + [observation.id for observation in observations], + ), + edge_evidence=self._evidence_for(snapshot, "edge_ref", [edge.id for edge in edges]), + diagnostics=diagnostics, + ) + 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") @@ -173,6 +256,26 @@ def _snapshot(self, snapshot_id: str) -> RiSnapshot: ) 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() diff --git a/apps/backend/app/schemas/architecture.py b/apps/backend/app/schemas/architecture.py index 875903bd..03717e34 100644 --- a/apps/backend/app/schemas/architecture.py +++ b/apps/backend/app/schemas/architecture.py @@ -1,5 +1,7 @@ from typing import Literal +from pydantic import Field + from app.schemas.base import CamelModel ArchNodeType = Literal[ @@ -22,6 +24,30 @@ "cache", ] 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): @@ -38,6 +64,7 @@ class ArchNode(CamelModel): tags: list[str] layer: str parent_module: str | None = None + relationship_state: RelationshipState = "not-extracted" class ArchEdge(CamelModel): @@ -46,6 +73,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 +121,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/tests/test_architecture_relationships.py b/apps/backend/tests/test_architecture_relationships.py new file mode 100644 index 00000000..a22d856e --- /dev/null +++ b/apps/backend/tests/test_architecture_relationships.py @@ -0,0 +1,272 @@ +from __future__ import annotations + +import io +import shutil +import zipfile + +from app.extraction.manifests import DependencyManifestExtractor +from app.extraction.pipeline import ExtractionPipeline +from app.extraction.typescript import TypeScriptExtractor +from app.intelligence.resolution import RelationshipResolver +from app.intelligence.snapshot_store import Evidence, Revision, SnapshotStore +from app.models.repository import RepositoryRecord + + +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]) -> 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() + assert auth_client.post(f"/analysis/{repository['id']}/start").status_code == 200 + return repository + + +def _persist_snapshot( + repository_id: str, + sources: dict[str, bytes], + *, + snapshot_sources: dict[str, bytes] | None = None, +) -> 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.0.0"}) + + 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) + 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_reports_resolved_facts_without_module_mapping(auth_client): + 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() + diagnostic = next(item for item in architecture["diagnostics"] if item["code"] == "ARCH-REL-ENDPOINT-UNMAPPED") + assert diagnostic["subjectKey"] == "file:unmapped.ts" + assert diagnostic["objectKey"] == "file:src/beta/index.ts" + assert diagnostic["nodeIds"] == ["module:beta"] + + +def test_architecture_without_snapshot_does_not_claim_isolation(auth_client): + repository = _upload(auth_client, {"src/lonely/index.ts": b"export const lonely = 1;\n"}) + + response = auth_client.get(f"/analysis/{repository['id']}/architecture") + + assert response.status_code == 200 + architecture = response.json() + assert architecture["relationshipSnapshotId"] is None + assert architecture["edges"] == [] + assert architecture["nodes"][0]["relationshipState"] == "not-extracted" + assert architecture["diagnostics"] == [ + { + "code": "ARCH-REL-NOT-EXTRACTED", + "category": "relationship extraction", + "severity": "info", + "message": "No sealed repository-intelligence snapshot is available for relationship analysis.", + "path": None, + "startLine": None, + "endLine": None, + "subjectKey": None, + "objectKey": None, + "details": None, + "nodeIds": None, + } + ] 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/shared/types/architecture.ts b/apps/frontend/src/shared/types/architecture.ts index c7f9f7aa..f7bc2c82 100644 --- a/apps/frontend/src/shared/types/architecture.ts +++ b/apps/frontend/src/shared/types/architecture.ts @@ -18,6 +18,8 @@ export type ArchNodeType = | 'cache'; export type ArchEdgeType = 'dependency' | 'import' | 'api-call' | 'data-flow' | 'event' | 'reads' | 'writes' | 'calls' | 'config-usage'; +export type RelationshipState = 'connected' | 'no-observed-relationships' | 'unresolved' | 'not-extracted'; +export type TruthClass = 'resolved' | 'inferred'; export type HeatmapMode = 'none' | 'complexity' | 'usage' | 'size' | 'critical'; @@ -35,6 +37,15 @@ export interface ArchNode { tags: string[]; layer: string; parentModule?: string; + relationshipState: RelationshipState; +} + +export interface ArchEvidence { + snapshotId: string; + factId: string; + path: string; + startLine: number; + endLine: number; } export interface ArchEdge { @@ -43,6 +54,23 @@ export interface ArchEdge { target: string; label?: string; type: ArchEdgeType; + predicate: string; + truthClass: TruthClass; + evidence: ArchEvidence[]; +} + +export interface ArchitectureDiagnostic { + code: string; + category: string; + severity: 'fatal' | 'error' | 'warning' | 'info'; + message: string; + path?: string; + startLine?: number; + endLine?: number; + subjectKey?: string; + objectKey?: string; + details?: Record; + nodeIds?: string[]; } export interface ArchLayer { @@ -78,6 +106,8 @@ export interface ArchitectureModel { edges: ArchEdge[]; modules: ArchModule[]; requestFlow: RequestFlowStep[]; + relationshipSnapshotId?: string; + diagnostics: ArchitectureDiagnostic[]; summary: { language: string; framework: string; diff --git a/docs/architecture/REPOSITORY_INTELLIGENCE.md b/docs/architecture/REPOSITORY_INTELLIGENCE.md index 0b58da6b..2cfb7fbc 100644 --- a/docs/architecture/REPOSITORY_INTELLIGENCE.md +++ b/docs/architecture/REPOSITORY_INTELLIGENCE.md @@ -22,7 +22,7 @@ If your feature needs a repository fact that does not exist yet, the answer is a The production extraction path is still one Pydantic model — `RepositoryIntelligence` ([`app/intelligence/models.py`](../../apps/backend/app/intelligence/models.py)) — built by one engine — `RepositoryIntelligenceEngine` ([`app/intelligence/engine.py`](../../apps/backend/app/intelligence/engine.py)) — and serialized onto the repository row. That blob is retained as explicitly legacy/unverified compatibility data. -The `ri.v1` persistence boundary now also exists: 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, their support matrices, the repository-level source-policy pipeline, and deterministic [relationship resolution](REPOSITORY_INTELLIGENCE_RESOLUTION.md) over stored observations exist under `app/extraction` and `app/intelligence`; the Issue #94 benchmark executes and validates the extraction pipeline. The versioned, owner-scoped `/intelligence/v1/snapshots` API reads sealed `ri.v1` normalized snapshots only and explicitly rejects unsupported snapshot schema versions; it never falls back to legacy metadata or a repository working tree. Product ingestion still does not run either the legacy regex output or the new extractors through the normalized snapshot tables, and durable job orchestration and consumer migration remain separate work (#93 and #95). +The `ri.v1` persistence boundary now also exists: 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, their support matrices, the repository-level source-policy pipeline, and deterministic [relationship resolution](REPOSITORY_INTELLIGENCE_RESOLUTION.md) over stored observations exist under `app/extraction` and `app/intelligence`; the Issue #94 benchmark executes and validates the extraction pipeline. The versioned, owner-scoped `/intelligence/v1/snapshots` API reads sealed `ri.v1` normalized snapshots only and explicitly rejects unsupported snapshot schema versions; it never falls back to legacy metadata or a repository working tree. The Architecture Graph consumes that query boundary for relationships when a sealed snapshot exists, while its module inventory and discovery summary remain legacy compatibility data. Product ingestion still does not run the new extractors through the normalized snapshot tables, so the graph reports relationship extraction as unavailable until a conforming producer has stored a snapshot; durable job orchestration remains separate work (#93). ```mermaid flowchart LR @@ -119,7 +119,7 @@ The repository API returns `revision: {kind,value,ref}` and retains `commitSha` Consumers still call `RepositoryIntelligenceEngine.from_record(record)`, which returns the legacy model if present and **rebuilds it from disk as a fallback** if it is missing or fails validation. That compatibility path is not an `ri.v1` snapshot producer: its regex facts have no valid spans or versioned provenance and are never promoted to `observed`, `resolved`, or `inferred` rows. -The normalized `ri_*` tables are ready for conforming producers. `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 atomically. Completed snapshots reject mutation. The query API exposes sealed snapshot metadata, symbols, stored resolved relationships, inferred assertions, file facts, and evidence spans; product consumers have not yet migrated to it. +The normalized `ri_*` tables are ready for conforming producers. `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 atomically. Completed snapshots reject mutation. The query API exposes sealed snapshot metadata, symbols, stored resolved relationships, inferred assertions, file facts, and evidence spans. Architecture relationship construction now uses its owner-scoped persisted-fact query; other product consumers remain on the legacy model. --- @@ -139,7 +139,7 @@ Each relationship carries an `evidence` list — which currently holds **file pa | Consumer | Module | Reads | | --- | --- | --- | -| Architecture | `app/analysis/` | modules, files, discovery | +| Architecture | `app/analysis/` | legacy modules/discovery plus resolved edges, diagnostics, and evidence from the latest sealed snapshot | | Dependency graph | `app/graph/` | dependencies, `depends_on` relationships | | Engineering review | `app/review/` | discovery, statistics, file roles and sizes | | Documentation | `app/services/documentation_service.py` | discovery, files, routes, architecture, dependencies | @@ -174,13 +174,13 @@ Environment-file review findings also name their evidence class: a committed tem For the `Oversized Source Files` review signal, PARTHA evaluates only authored source-code extensions above the configured threshold. It excludes documentation and configuration files, common dependency lockfiles, generated or minified filenames, and files under vendor, generated, dependency, or build-output directories. The finding includes each retained file's measured byte size; size is a review signal, not a diagnosis of a design issue. -**Product-consumed provenance: incomplete.** The new persistence schema can store complete `ri.v1` provenance and the standalone extractors can produce it, but the current product path still consumes the legacy regex engine. Specifically: +**Product-consumed provenance: incomplete.** Architecture relationships expose exact snapshot fact IDs and line spans when a sealed `ri.v1` snapshot exists, but product ingestion does not create one yet and other product paths still consume the legacy regex engine. Specifically: - **No line spans.** `SourceSymbol` has `id`, `name`, `kind`, `file_path`, and `exported`. It has **no start or end line**. Nothing in the model records where in a file a fact was found. - **No extraction method on the fact.** A consumer cannot tell whether a given fact was matched deterministically or inferred heuristically. That distinction lives in this document, not in the data. - **Revision identity is now exact at the repository boundary.** GitHub imports store a 40-character commit plus resolved ref; uploads store a `sha256:` archive identity. Legacy JSON facts still are not individually revision-addressed, while conforming snapshot rows are. -The honest summary: **the persistence layer can retain exact revisions, spans, producer versions, and derivations, and the extractor boundary emits conforming spans; today's product consumers still receive only the legacy file-level facts.** No line-cited product claim exists until the durable snapshot workflow populates and serves conforming snapshots. +The honest summary: **the persistence layer can retain exact revisions, spans, producer versions, and derivations, and the Architecture Graph can serve evidence-backed relationships from a conforming sealed snapshot; normal product ingestion does not populate that snapshot yet.** Without one, the graph returns `not-extracted` rather than claiming that a module is isolated. 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). @@ -192,7 +192,7 @@ Do not describe PARTHA as having evidence-backed or grounded output until line s - **Symbols:** regex-derived, Python and TS/JS only, no line spans, no signatures, no nesting, no cross-file resolution. Matches inside comments and strings are not excluded. - **Line spans:** emitted by the Python and TypeScript extractors and returned unchanged when a sealed snapshot exists, but product ingestion does not yet create those snapshots. -- **Graph production and consumption:** normalized immutable graph tables, syntax-aware producers, and a sealed-snapshot query API exist, but no durable product job populates them and product surfaces still read the legacy JSON blob. +- **Graph production and consumption:** normalized immutable graph tables, syntax-aware producers, and a sealed-snapshot query API exist. Architecture relationships consume sealed facts when present, but no durable product job populates them; module classification and other product surfaces still read the legacy JSON blob. - **Relationships:** four of the eight declared types are never emitted. An import edge resolves to a declared dependency when the name matches and otherwise creates an `external:` node — there is no real module resolution. - **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, no lockfiles, 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. diff --git a/docs/architecture/SYSTEM_OVERVIEW.md b/docs/architecture/SYSTEM_OVERVIEW.md index 98606336..b673a89a 100644 --- a/docs/architecture/SYSTEM_OVERVIEW.md +++ b/docs/architecture/SYSTEM_OVERVIEW.md @@ -116,7 +116,7 @@ This runs **synchronously inside the HTTP request**. A large repository blocks a | Relational DB | `users`, `refresh_tokens`, `repositories`, `ai_provider_configs`, and normalized `ri_*` snapshot tables | SQLite by default for local development; PostgreSQL under Docker Compose. The current migration head adds revision identity plus immutable snapshot persistence. | | `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) | Parser metadata and the **legacy/unverified** serialized Repository Intelligence under the `intelligence` key. | New imports no longer stash `commitSha` here. Existing legacy facts are retained for compatibility and are not copied into `ri.v1` observed facts. | -| `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. | The persistence boundary, sealing rules, syntax-aware producers, and golden benchmark are implemented. Query APIs, durable product jobs, and consumer migration remain separate work, so current product consumers do not read these tables yet. | +| `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. | The persistence boundary, sealing rules, syntax-aware producers, query APIs, and golden benchmark are implemented. Architecture relationships consume a sealed snapshot when present; durable product jobs do not populate one yet. | | `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 and never returned to the client. | | Filesystem (`STORAGE_PATH`) | Extracted archives and cloned repositories; uploaded archives (deleted after extraction). | Repository source is read from here on demand for file preview. | @@ -232,7 +232,7 @@ These are properties of the system as built, not a wish list. 1. **Extraction is heuristic, not language-aware.** File roles, modules, and layers are inferred from path segments and filenames. Symbols come from regular expressions. `TreeSitterParser` returns nothing, even though `tree-sitter` is a declared dependency. 2. **No line-level provenance in production output.** The snapshot schema can store validated spans and derivations, but the current regex engine emits neither and is deliberately not promoted into `ri.v1`. -3. **The graph store has no production producers or consumers yet.** Immutable normalized tables exist, but product surfaces still read the legacy JSON blob. Four of the eight legacy relationship types are never emitted; syntax-aware extraction/resolution and snapshot queries remain later issues. +3. **The graph store has no production producer yet.** Immutable normalized tables, syntax-aware extraction/resolution, and snapshot queries exist. Architecture relationships consume a sealed snapshot when one is present and expose honest unavailable/diagnostic states otherwise; normal ingestion still creates only the legacy JSON blob. 4. **Processing is synchronous and whole-repository.** No background jobs, no incremental re-analysis, no cancellation. 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, no lockfiles, 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. From 38f8a3fa64b5ff050d9f3ea2433a454ae1af0e7c Mon Sep 17 00:00:00 2001 From: hardikuppal04 Date: Sun, 19 Jul 2026 04:12:31 +0530 Subject: [PATCH 126/347] test(upload): cover archive validation (#119) (#131) * test(upload): cover archive validation (#119) * test(upload): cover maximum size boundary --------- Co-authored-by: hardikuppal04 <262667963+hardikuppal04@users.noreply.github.com> --- .../features/upload/hooks/useUpload.test.ts | 195 ++++++++++++++++++ 1 file changed, 195 insertions(+) create mode 100644 apps/frontend/src/features/upload/hooks/useUpload.test.ts diff --git a/apps/frontend/src/features/upload/hooks/useUpload.test.ts b/apps/frontend/src/features/upload/hooks/useUpload.test.ts new file mode 100644 index 00000000..00beac40 --- /dev/null +++ b/apps/frontend/src/features/upload/hooks/useUpload.test.ts @@ -0,0 +1,195 @@ +import { act, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useAppStore } from '@/app/store/useAppStore'; +import { backendService } from '@/shared/services/backend'; +import type { Repository } from '@/shared/types'; +import { MAX_FILE_SIZE, useUpload } from './useUpload'; + +const repositoryState = vi.hoisted(() => ({ + repositories: [] as Repository[], + selectRepository: vi.fn(), +})); + +vi.mock('@/features/repositories/hooks/useRepository', () => ({ + useRepository: () => ({ + repositories: repositoryState.repositories, + selectRepository: repositoryState.selectRepository, + }), +})); + +const LAST_MODIFIED = 1_700_000_000_000; + +function repository(id: string, name: string): Repository { + return { + id, + name, + source: 'upload', + size: 0, + fileCount: 0, + status: 'uploading', + dataSource: 'real', + analysisStage: 'uploading', + analysisProgress: 0, + uploadedAt: '2026-01-01T00:00:00Z', + meta: null, + fileTree: [], + }; +} + +function archiveFile(name: string, type = 'application/zip'): File { + return new File(['archive'], name, { type, lastModified: LAST_MODIFIED }); +} + +function oversizedArchiveFile(): File { + const file = archiveFile('too-large.zip'); + Object.defineProperty(file, 'size', { value: MAX_FILE_SIZE + 1 }); + return file; +} + +function mockUpload() { + return vi.spyOn(backendService, 'uploadRepository').mockResolvedValue(null); +} + +describe('useUpload', () => { + beforeEach(() => { + repositoryState.repositories = []; + repositoryState.selectRepository.mockReset(); + useAppStore.setState({ repositories: [], activeRepositoryId: null }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('handles an empty file selection without starting an upload', () => { + const uploadRepository = mockUpload(); + const hook = renderHook(() => useUpload()); + + act(() => { + hook.result.current.rejectFile(); + }); + + act(() => { + hook.result.current.selectFile([]); + }); + + expect(hook.result.current.uploadFile).toBeNull(); + expect(hook.result.current.loading).toBe(false); + expect(hook.result.current.error).toBeNull(); + expect(hook.result.current.empty).toBe(true); + expect(hook.result.current.success).toBe(false); + expect(uploadRepository).not.toHaveBeenCalled(); + }); + + it('rejects an archive larger than 100 MiB with the exact user-facing error', () => { + const uploadRepository = mockUpload(); + const file = oversizedArchiveFile(); + const hook = renderHook(() => useUpload()); + + expect(file.size).toBe(MAX_FILE_SIZE + 1); + + act(() => { + hook.result.current.selectFile([file]); + }); + + expect(hook.result.current.uploadFile).toBeNull(); + expect(hook.result.current.error).toBe('File too large. Maximum size is 100 MB.'); + expect(hook.result.current.empty).toBe(true); + expect(hook.result.current.success).toBe(false); + expect(uploadRepository).not.toHaveBeenCalled(); + }); + + it('accepts an archive of exactly the maximum size', () => { + const uploadRepository = mockUpload(); + const file = archiveFile('at-limit.zip'); + Object.defineProperty(file, 'size', { value: MAX_FILE_SIZE }); + const hook = renderHook(() => useUpload()); + + act(() => { + hook.result.current.selectFile([file]); + }); + + expect(hook.result.current.uploadFile?.size).toBe(MAX_FILE_SIZE); + expect(hook.result.current.error).toBeNull(); + expect(hook.result.current.empty).toBe(false); + expect(hook.result.current.success).toBe(true); + expect(uploadRepository).not.toHaveBeenCalled(); + }); + + it('rejects duplicate archive repository names case-insensitively', () => { + repositoryState.repositories = [repository('existing-id', 'Existing-Project')]; + const uploadRepository = mockUpload(); + const hook = renderHook(() => useUpload()); + + act(() => { + hook.result.current.selectFile([archiveFile('existing-project.TAR.GZ', 'application/gzip')]); + }); + + expect(hook.result.current.uploadFile).toBeNull(); + expect(hook.result.current.error).toBe('A repository named "existing-project" already exists.'); + expect(hook.result.current.empty).toBe(true); + expect(hook.result.current.success).toBe(false); + expect(uploadRepository).not.toHaveBeenCalled(); + }); + + it.each([ + ['repository.zip', 'application/zip'], + ['repository.tar.gz', 'application/gzip'], + ])('selects an accepted archive and exposes its upload state: %s', (name, type) => { + const uploadRepository = mockUpload(); + const file = archiveFile(name, type); + const hook = renderHook(() => useUpload()); + + act(() => { + hook.result.current.selectFile([file]); + }); + + expect(hook.result.current.uploadFile).toEqual({ + file, + name, + size: file.size, + type, + lastModified: LAST_MODIFIED, + }); + expect(hook.result.current.loading).toBe(false); + expect(hook.result.current.error).toBeNull(); + expect(hook.result.current.empty).toBe(false); + expect(hook.result.current.success).toBe(true); + expect(uploadRepository).not.toHaveBeenCalled(); + }); + + it('reports a rejected archive type without selecting a file', () => { + const uploadRepository = mockUpload(); + const hook = renderHook(() => useUpload()); + + act(() => { + hook.result.current.rejectFile(); + }); + + expect(hook.result.current.uploadFile).toBeNull(); + expect(hook.result.current.error).toBe('Invalid file type. Please upload a ZIP or TAR.GZ file.'); + expect(hook.result.current.empty).toBe(true); + expect(hook.result.current.success).toBe(false); + expect(uploadRepository).not.toHaveBeenCalled(); + }); + + it('removes a selected archive and returns to the empty state', () => { + const uploadRepository = mockUpload(); + const file = archiveFile('repository.zip'); + const hook = renderHook(() => useUpload()); + + act(() => { + hook.result.current.selectFile([file]); + }); + + act(() => { + hook.result.current.removeFile(); + }); + + expect(hook.result.current.uploadFile).toBeNull(); + expect(hook.result.current.error).toBeNull(); + expect(hook.result.current.empty).toBe(true); + expect(hook.result.current.success).toBe(false); + expect(uploadRepository).not.toHaveBeenCalled(); + }); +}); From bfa7e90cf324da3f1453379dfb79a215fed48f84 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 20 Jul 2026 06:21:41 +0000 Subject: [PATCH 127/347] chore(deps-dev): bump the frontend-minor-and-patch group Bumps the frontend-minor-and-patch group in /apps/frontend with 4 updates: [autoprefixer](https://github.com/postcss/autoprefixer), [postcss](https://github.com/postcss/postcss), [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) and [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite). Updates `autoprefixer` from 10.5.2 to 10.5.4 - [Release notes](https://github.com/postcss/autoprefixer/releases) - [Changelog](https://github.com/postcss/autoprefixer/blob/main/CHANGELOG.md) - [Commits](https://github.com/postcss/autoprefixer/compare/10.5.2...10.5.4) Updates `postcss` from 8.5.19 to 8.5.20 - [Release notes](https://github.com/postcss/postcss/releases) - [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md) - [Commits](https://github.com/postcss/postcss/compare/8.5.19...8.5.20) Updates `typescript-eslint` from 8.63.0 to 8.64.0 - [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases) - [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md) - [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.64.0/packages/typescript-eslint) Updates `vite` from 8.1.4 to 8.1.5 - [Release notes](https://github.com/vitejs/vite/releases) - [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md) - [Commits](https://github.com/vitejs/vite/commits/v8.1.5/packages/vite) --- updated-dependencies: - dependency-name: autoprefixer dependency-version: 10.5.4 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: frontend-minor-and-patch - dependency-name: postcss dependency-version: 8.5.20 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: frontend-minor-and-patch - dependency-name: typescript-eslint dependency-version: 8.64.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: frontend-minor-and-patch - dependency-name: vite dependency-version: 8.1.5 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: frontend-minor-and-patch ... Signed-off-by: dependabot[bot] --- apps/frontend/package-lock.json | 200 ++++++++++++++++---------------- apps/frontend/package.json | 8 +- 2 files changed, 104 insertions(+), 104 deletions(-) diff --git a/apps/frontend/package-lock.json b/apps/frontend/package-lock.json index 78b007c4..651bffcb 100644 --- a/apps/frontend/package-lock.json +++ b/apps/frontend/package-lock.json @@ -32,17 +32,17 @@ "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^6.0.3", "@vitest/coverage-v8": "^4.1.10", - "autoprefixer": "^10.4.20", + "autoprefixer": "^10.5.4", "eslint": "^10.7.0", "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.3", "globals": "^17.7.0", "jsdom": "^29.1.1", - "postcss": "^8.5.19", + "postcss": "^8.5.20", "tailwindcss": "^3.4.10", "typescript": "^5.5.4", - "typescript-eslint": "^8.63.0", - "vite": "^8.1.4", + "typescript-eslint": "^8.64.0", + "vite": "^8.1.5", "vitest": "^4.1.10" } }, @@ -1423,17 +1423,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.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.64.0.tgz", + "integrity": "sha512-CGvQPBxN3wZLu6Rz2kFUpZeoCm78xUic92ck39KPePkO1NPOwjCqdQnm5Q87tpWw9vcBvW8XLrDXjH9PWYtJ3Q==", "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.64.0", + "@typescript-eslint/type-utils": "8.64.0", + "@typescript-eslint/utils": "8.64.0", + "@typescript-eslint/visitor-keys": "8.64.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -1446,15 +1446,15 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.63.0", + "@typescript-eslint/parser": "^8.64.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.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", "dev": true, "license": "MIT", "engines": { @@ -1462,16 +1462,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.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.64.0.tgz", + "integrity": "sha512-KA0OshtlcCCXmbfqyZkM5pV3/WNraJf7DkJRLpyrmwPtud57H5BDX7C3k0LPSPxpprfRL+cJDGabF10mvNCoCw==", "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.64.0", + "@typescript-eslint/types": "8.64.0", + "@typescript-eslint/typescript-estree": "8.64.0", + "@typescript-eslint/visitor-keys": "8.64.0", "debug": "^4.4.3" }, "engines": { @@ -1487,14 +1487,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.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.64.0.tgz", + "integrity": "sha512-tk4WpOJ6IEbGrVHaNmM0YRrwAD3exZlIK3iadQNAxh4YKk6jvUQ4ecq18n+v7+meh+cJ3j+D8nbk8sRKhlwLQg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.63.0", - "@typescript-eslint/types": "^8.63.0", + "@typescript-eslint/tsconfig-utils": "^8.64.0", + "@typescript-eslint/types": "^8.64.0", "debug": "^4.4.3" }, "engines": { @@ -1509,14 +1509,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.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.64.0.tgz", + "integrity": "sha512-CXEaFdYXjSTgKhisNkwCcJwTP8Pl+fmRrEQrri4nm3vU743bALrxzLmq7fHG/7e6a5xO0lDYeURpZmBuhHk54w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.63.0", - "@typescript-eslint/visitor-keys": "8.63.0" + "@typescript-eslint/types": "8.64.0", + "@typescript-eslint/visitor-keys": "8.64.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1527,9 +1527,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.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.64.0.tgz", + "integrity": "sha512-2yo8rRNKuzbVWQp5kslhANqZ2uDAeROQHBRZNPu8JDsHmeFNj/XJJhX/FhNUWmkHHvoNsKa6+tHJiig87EzsQw==", "dev": true, "license": "MIT", "engines": { @@ -1544,15 +1544,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.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.64.0.tgz", + "integrity": "sha512-XWG4Fmmv/6SvyS9nH8jWrKs6terwJvE8cyRt1CzYYqzp9OrPhCT4cMc/f7C6RZCwG+qMmiffJS1/qJP8G1URtg==", "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.64.0", + "@typescript-eslint/typescript-estree": "8.64.0", + "@typescript-eslint/utils": "8.64.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -1569,9 +1569,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.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.64.0.tgz", + "integrity": "sha512-qjhfuTfLXjA4IOzXvz0rTjT01BqEiIgPoUeMwiEjnaHKJMTNo8rH5pYW1a2L/0Dnux2fPC85AeyJoWaGa8WxTA==", "dev": true, "license": "MIT", "engines": { @@ -1583,16 +1583,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.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.64.0.tgz", + "integrity": "sha512-Pztpsn1aCE1oWDvDEfUk31nngvvF7vUB5SwHFEaZIFpvw7WJtqUHHL4plBZDA9HfWJJjL13BdG0YrJInTUvoVA==", "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.64.0", + "@typescript-eslint/tsconfig-utils": "8.64.0", + "@typescript-eslint/types": "8.64.0", + "@typescript-eslint/visitor-keys": "8.64.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -1624,16 +1624,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.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.64.0.tgz", + "integrity": "sha512-aJUGVB3+U0htrrCjoA8qukw8cm8fNCGAxK/tVoS70k8aeb7DETKeFozRiVFIwEeN9WJLsjaP3ph8I60tY2XZoQ==", "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.64.0", + "@typescript-eslint/types": "8.64.0", + "@typescript-eslint/typescript-estree": "8.64.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1648,13 +1648,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.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.64.0.tgz", + "integrity": "sha512-mrtuL8Nsn6gi2H4mo5KMTp823M+3Q19Ew/i+Zlikq20tIMm99C3Ez0dCmkWWnxut20esQvTg8aUSEhMcAOXhEw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/types": "8.64.0", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -2016,9 +2016,9 @@ } }, "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": [ { @@ -2036,8 +2036,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" @@ -2123,9 +2123,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.6", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.6.tgz", + "integrity": "sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw==", "dev": true, "funding": [ { @@ -2144,9 +2144,9 @@ "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", + "caniuse-lite": "^1.0.30001803", + "electron-to-chromium": "^1.5.389", + "node-releases": "^2.0.51", "update-browserslist-db": "^1.2.3" }, "bin": { @@ -2166,9 +2166,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": [ { @@ -2520,9 +2520,9 @@ } }, "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" }, @@ -3858,9 +3858,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.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", "funding": [ { "type": "github", @@ -3883,9 +3883,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": { @@ -4066,9 +4066,9 @@ } }, "node_modules/postcss": { - "version": "8.5.19", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz", - "integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==", + "version": "8.5.20", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.20.tgz", + "integrity": "sha512-lW616l85ucIQL+FocMmL7pQFPqBmwejrCMg+iPxyImlrANNJG9NHq/RkyCZopDhd8C3LA03PHRJDjkbGu8vvug==", "funding": [ { "type": "opencollective", @@ -4085,7 +4085,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -4937,16 +4937,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.64.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.64.0.tgz", + "integrity": "sha512-0qg+pDNMnqYzqH9AnNK+39tejHvsShUOUUoRUgtnTGE7QuMZhiFDnozq8nHJVq+Wae6NMLKNWLg5WmkcC/ndyQ==", "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.64.0", + "@typescript-eslint/parser": "8.64.0", + "@typescript-eslint/typescript-estree": "8.64.0", + "@typescript-eslint/utils": "8.64.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5027,16 +5027,16 @@ "license": "MIT" }, "node_modules/vite": { - "version": "8.1.4", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.4.tgz", - "integrity": "sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==", + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", + "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.5", - "postcss": "^8.5.16", - "rolldown": "~1.1.4", + "postcss": "^8.5.17", + "rolldown": "~1.1.5", "tinyglobby": "^0.2.17" }, "bin": { diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 37cf5376..50650664 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -36,17 +36,17 @@ "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^6.0.3", "@vitest/coverage-v8": "^4.1.10", - "autoprefixer": "^10.4.20", + "autoprefixer": "^10.5.4", "eslint": "^10.7.0", "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.3", "globals": "^17.7.0", "jsdom": "^29.1.1", - "postcss": "^8.5.19", + "postcss": "^8.5.20", "tailwindcss": "^3.4.10", "typescript": "^5.5.4", - "typescript-eslint": "^8.63.0", - "vite": "^8.1.4", + "typescript-eslint": "^8.64.0", + "vite": "^8.1.5", "vitest": "^4.1.10" }, "overrides": { From 9eb385b8c25058f4114f266a22942ae04bc509d7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 20 Jul 2026 06:22:15 +0000 Subject: [PATCH 128/347] chore(deps): bump zustand from 4.5.7 to 5.0.14 in /apps/frontend Bumps [zustand](https://github.com/pmndrs/zustand) from 4.5.7 to 5.0.14. - [Release notes](https://github.com/pmndrs/zustand/releases) - [Commits](https://github.com/pmndrs/zustand/compare/4.5.7...v5.0.14) --- updated-dependencies: - dependency-name: zustand dependency-version: 5.0.14 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- apps/frontend/package-lock.json | 49 ++++++++++++++++++++++++++------- apps/frontend/package.json | 2 +- 2 files changed, 40 insertions(+), 11 deletions(-) diff --git a/apps/frontend/package-lock.json b/apps/frontend/package-lock.json index 651bffcb..e5605dfc 100644 --- a/apps/frontend/package-lock.json +++ b/apps/frontend/package-lock.json @@ -22,7 +22,7 @@ "sonner": "^2.0.7", "tailwind-merge": "^2.5.2", "tailwindcss-animate": "^1.0.7", - "zustand": "^4.5.5" + "zustand": "^5.0.14" }, "devDependencies": { "@eslint/js": "^10.0.1", @@ -1860,6 +1860,34 @@ } } }, + "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", @@ -5372,20 +5400,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.14", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.14.tgz", + "integrity": "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==", "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": { @@ -5396,6 +5422,9 @@ }, "react": { "optional": true + }, + "use-sync-external-store": { + "optional": true } } } diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 50650664..62b0e6e7 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -26,7 +26,7 @@ "sonner": "^2.0.7", "tailwind-merge": "^2.5.2", "tailwindcss-animate": "^1.0.7", - "zustand": "^4.5.5" + "zustand": "^5.0.14" }, "devDependencies": { "@eslint/js": "^10.0.1", From 6c138f1a19f81cafea8177abcaed0d823923dc9a Mon Sep 17 00:00:00 2001 From: Shaurya K Sharma Date: Tue, 21 Jul 2026 11:06:31 +0100 Subject: [PATCH 129/347] security: enforce outbound AI provider egress policy (#109) (#135) * security(ai): enforce provider egress policy (#109) * fix(ai): address egress review feedback * fix(ai): complete egress review follow-up --------- Co-authored-by: PARTH J ROHIT --- .env.example | 8 + README.md | 12 +- apps/backend/.env.example | 6 + apps/backend/README.md | 17 + apps/backend/app/ai/providers/__init__.py | 2 - apps/backend/app/ai/providers/anthropic.py | 6 +- apps/backend/app/ai/providers/config_store.py | 21 +- apps/backend/app/ai/providers/gemini.py | 15 +- apps/backend/app/ai/providers/http.py | 95 +- apps/backend/app/ai/providers/legacy.py | 82 -- apps/backend/app/ai/providers/ollama.py | 11 +- apps/backend/app/ai/providers/openai.py | 6 +- apps/backend/app/ai/providers/openrouter.py | 6 +- apps/backend/app/api/deps.py | 29 +- apps/backend/app/core/ai_egress.py | 415 +++++++++ apps/backend/app/core/config.py | 47 + apps/backend/app/core/logging.py | 7 + apps/backend/pyproject.toml | 6 +- apps/backend/tests/test_ai_egress_policy.py | 830 ++++++++++++++++++ apps/backend/tests/test_ai_providers.py | 210 ++--- docker-compose.yml | 21 + docs/README.md | 1 + docs/architecture/SYSTEM_OVERVIEW.md | 8 +- docs/security/AI_PROVIDER_EGRESS.md | 136 +++ 24 files changed, 1731 insertions(+), 266 deletions(-) delete mode 100644 apps/backend/app/ai/providers/legacy.py create mode 100644 apps/backend/app/core/ai_egress.py create mode 100644 apps/backend/tests/test_ai_egress_policy.py create mode 100644 docs/security/AI_PROVIDER_EGRESS.md diff --git a/.env.example b/.env.example index 59140246..18965018 100644 --- a/.env.example +++ b/.env.example @@ -8,6 +8,14 @@ STORAGE_PATH=./apps/backend/.local/storage CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173 AUTO_CREATE_TABLES=true +# 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= + # 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 diff --git a/README.md b/README.md index a7565961..52a1b1c0 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ Statuses below were checked against the implementation, not against prior docume | Architecture output | **Implemented but limited** | Modules, layers, relationships, and an interactive graph — with heuristic module and layer assignment. | | Dependency inventory | **Implemented but limited** | Reads `package.json`, `requirements.txt`, and `pyproject.toml`. Other ecosystems and lockfiles are not parsed. | | Engineering review | **Implemented but limited** | A fixed set of heuristic checks with derived category scores. Scores are arithmetic over finding severities, not a measured quality metric. | -| AI provider integration | **Implemented but limited** | Several providers behind one abstraction. Provider configuration is per-user, with the API key encrypted at rest and injected per request. | +| AI provider integration | **Implemented but limited** | Several providers behind one abstraction. Provider configuration is per-user, with the API key encrypted at rest and injected per request; outbound destinations are centrally allowlisted and DNS-pinned. See [AI provider egress policy](docs/security/AI_PROVIDER_EGRESS.md). | | Authorization and owner isolation | **Implemented** | All repository, analysis, AI, documentation, and export routes require authentication and are owner-scoped in the service layer; a non-owner request returns 404. Rate-limit budgets are keyed per authenticated user. | | Citations and grounded AI answers | **Not implemented** | No source content or line numbers are sent to providers, and no citations are returned. | | Asynchronous / incremental processing | **Not implemented** | Ingestion and analysis run synchronously in the request; there is no background job system and no incremental re-analysis. | @@ -205,6 +205,14 @@ The app is then on `http://localhost:5173` and expects the backend on `http://lo Compose runs the API against PostgreSQL and Redis for local development. It is not deployment guidance. +The API provider egress policy defaults to `hosted`, which does not enable a +tenant-configurable local endpoint. A trusted local or internal Ollama endpoint +requires explicit administrator-owned `AI_EGRESS_MODE=self_hosted`, exact base +URL, and CIDR settings. Compose isolates PostgreSQL and Redis on an internal +data network, but it cannot enforce an exact API destination allowlist; hosted +and shared deployments still require a firewall, egress proxy, cloud rule, or +service-mesh policy. See [AI provider egress policy](docs/security/AI_PROVIDER_EGRESS.md). + ```bash npm run docker:config # validate the Compose file npm run docker:up # start the local stack @@ -232,7 +240,7 @@ Backend coverage is the stronger of the two. Frontend coverage is thin and there ## Limitations -- **Not yet hardened for public multi-tenant use.** Authentication and owner isolation are enforced across the backend routes, and provider keys are encrypted at rest, but PARTHA has not been operated as a hardened multi-tenant deployment. It is not production-ready and should not be exposed to the public internet without further review. Outside `development`/`test`, set `AUTH_SECRET_KEY` and `AI_ENCRYPTION_KEY` (a Fernet key); the backend refuses to start without them. +- **Not yet hardened for public multi-tenant use.** Authentication and owner isolation are enforced across the backend routes, provider keys are encrypted at rest, and AI provider egress is centrally constrained, but PARTHA has not been operated as a hardened multi-tenant deployment. It is not production-ready and should not be exposed to the public internet without further review. Outside `development`/`test`, set `AUTH_SECRET_KEY` and `AI_ENCRYPTION_KEY` (a Fernet key); the backend refuses to start without them. Production also needs an independent network egress control; application validation is not a firewall. - **Extraction is heuristic.** File roles, modules, and layers are inferred from paths and filenames; symbols come from regular expressions. Expect wrong answers on projects that do not follow common conventions, and do not treat heuristic output as guaranteed fact. - **Evidence and provenance are partial.** File-level only — no line spans, no per-fact extraction method, no revision-addressed facts. - **No persistent semantic graph.** Repository facts are serialized as JSON onto the repository row rather than into a queryable graph store. diff --git a/apps/backend/.env.example b/apps/backend/.env.example index 7f7d3f11..f8268920 100644 --- a/apps/backend/.env.example +++ b/apps/backend/.env.example @@ -14,6 +14,12 @@ REFRESH_TOKEN_TTL_SECONDS=1209600 # 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 diff --git a/apps/backend/README.md b/apps/backend/README.md index 45d27116..d88ab531 100644 --- a/apps/backend/README.md +++ b/apps/backend/README.md @@ -70,6 +70,23 @@ docker compose up --build Compose runs the API against PostgreSQL and Redis. It does not run the frontend. +## 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. Compose keeps PostgreSQL and Redis on an internal data +network, but production 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. + ## First import ```bash 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/config_store.py b/apps/backend/app/ai/providers/config_store.py index 4b3b3b37..5b76b0c9 100644 --- a/apps/backend/app/ai/providers/config_store.py +++ b/apps/backend/app/ai/providers/config_store.py @@ -15,6 +15,7 @@ from sqlalchemy import select from sqlalchemy.orm import Session +from app.core.ai_egress import DestinationPolicyError, ProviderEgressPolicy from app.ai.types import DEFAULT_MODELS, AiProviderConfig from app.core.crypto import InvalidToken, ProviderKeyCipher from app.core.exceptions import ValidationServiceError @@ -41,10 +42,21 @@ def config_for_test(self, request: AiProviderTestRequest) -> AiProviderConfig: class EncryptedProviderConfigStore: """Persists one provider configuration per user, key encrypted at rest.""" - def __init__(self, db: Session, cipher: ProviderKeyCipher, owner_id: str) -> None: + 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) @@ -85,6 +97,13 @@ def read_config(self) -> AiProviderConfig | None: ) 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] 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..9834f748 100644 --- a/apps/backend/app/ai/providers/http.py +++ b/apps/backend/app/ai/providers/http.py @@ -1,19 +1,102 @@ +"""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 +class ProviderHttpSender(Protocol): + async def post(self, config: AiProviderConfig, url: str, **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, **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) + + async with httpx.AsyncClient( + timeout=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: if not config.api_key: raise ValidationServiceError("API key is required for the selected AI provider.") -async def post(config: AiProviderConfig, url: str, **kwargs) -> httpx.Response: +async def post( + config: AiProviderConfig, + url: str, + *, + sender: ProviderHttpSender | None = None, + **kwargs: object, +) -> httpx.Response: + """Apply normalized provider errors around the central outbound sender.""" + + 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, **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.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..67976e23 100644 --- a/apps/backend/app/ai/providers/ollama.py +++ b/apps/backend/app/ai/providers/ollama.py @@ -1,11 +1,16 @@ -from app.ai.providers.http import post +from app.ai.providers.http import ProviderHttpSender, post from app.ai.types import DEFAULT_MODELS, AiProviderConfig, AiProviderResponse, PromptBundle from app.core.exceptions import ExternalServiceError 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 +19,7 @@ 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) + response = await post(config, f"{base_url}/api/chat", sender=self.sender, 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/api/deps.py b/apps/backend/app/api/deps.py index fe844fcb..fadddb03 100644 --- a/apps/backend/app/api/deps.py +++ b/apps/backend/app/api/deps.py @@ -2,12 +2,14 @@ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from sqlalchemy.orm import Session +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 @@ -135,12 +137,23 @@ def get_provider_cipher(settings: Settings = Depends(get_settings)) -> ProviderK return build_provider_cipher(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) + return EncryptedProviderConfigStore(db, cipher, current_user.id, policy) def get_repository_context_builder( @@ -153,13 +166,15 @@ 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 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 33f6a836..cba120f4 100644 --- a/apps/backend/app/core/config.py +++ b/apps/backend/app/core/config.py @@ -1,6 +1,7 @@ import base64 import binascii import hashlib +import ipaddress import logging from functools import lru_cache from pathlib import Path @@ -10,6 +11,8 @@ 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 @@ -68,6 +71,12 @@ class Settings(BaseSettings): clone_timeout_seconds: int = 120 max_upload_size_bytes: int = 100 * 1024 * 1024 max_clone_size_bytes: int = 500 * 1024 * 1024 + # 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) model_config = SettingsConfigDict( env_file=".env", @@ -82,6 +91,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: @@ -143,6 +161,35 @@ def validate_rate_limit_backend(cls, value: str) -> str: 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( "clone_timeout_seconds", 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/pyproject.toml b/apps/backend/pyproject.toml index 891d3e4d..63bc0721 100644 --- a/apps/backend/pyproject.toml +++ b/apps/backend/pyproject.toml @@ -22,7 +22,11 @@ dependencies = [ "tree-sitter>=0.22.0", "tree-sitter-typescript>=0.23.0", "python-multipart>=0.0.9", - "httpx>=0.27.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", "xhtml2pdf>=0.2.16", "pytest>=8.3.0" ] 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..d8d129e2 --- /dev/null +++ b/apps/backend/tests/test_ai_egress_policy.py @@ -0,0 +1,830 @@ +"""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.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"]) + _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", "/ai/stream"): + 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_providers.py b/apps/backend/tests/test_ai_providers.py index 827ad545..4f5d3707 100644 --- a/apps/backend/tests/test_ai_providers.py +++ b/apps/backend/tests/test_ai_providers.py @@ -1,191 +1,134 @@ 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 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 - - def json(self) -> dict[str, Any]: - return self.payload - - -class RecordingAsyncClient: - calls: list[dict[str, Any]] = [] - payload: dict[str, Any] = {} - exception: httpx.HTTPError | None = None - - 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 config_for_test(self, request: AiProviderTestRequest) -> AiProviderConfig: - return self.config + 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 _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) -def _malformed_payload(provider_name: str) -> dict[str, Any]: - if provider_name == "anthropic": - return {"content": None} - return {} + response = asyncio.run(provider_class(sender).complete(config, PROMPT)) + 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" -@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) + asyncio.run(GeminiProvider(sender).complete(AiProviderConfig(provider="gemini", api_key=key), PROMPT)) - 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({}) - 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 + with pytest.raises(ValidationServiceError, match="API key is required"): + asyncio.run(provider_class(sender).complete(config, PROMPT)) + assert sender.calls == [] -@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") - 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))) +def test_provider_network_errors_remain_normalized(): + request = httpx.Request("POST", "https://provider.example") + sender = RecordingSender({}, httpx.ConnectError("network failed", request=request)) - legacy_error = run(LegacyProvider()) - provider_error = run(provider_class()) + 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." + assert caught.value.details == {"provider": "openai"} -@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)) +def test_provider_response_parsing_errors_remain_normalized(): + sender = RecordingSender({}) - 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 + with pytest.raises(ExternalServiceError) as caught: + asyncio.run(OpenAIProvider(sender).complete(AiProviderConfig(provider="openai", api_key="key"), PROMPT)) + 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,42 +136,3 @@ 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) - - resolved = ProviderFactory(registry).resolve(AiProviderConfig(provider="openai", api_key="key")) - - assert resolved is provider - - -def test_connection_testing_uses_resolved_dedicated_provider(monkeypatch: pytest.MonkeyPatch): - 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), - owner_id="owner-1", - ) - - 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."}, - ] diff --git a/docker-compose.yml b/docker-compose.yml index 3a88f9c2..3f2b0635 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -22,6 +22,11 @@ services: RATE_LIMIT_AUTH_PER_MINUTE: ${RATE_LIMIT_AUTH_PER_MINUTE:-10} RATE_LIMIT_AI_PER_MINUTE: ${RATE_LIMIT_AI_PER_MINUTE:-20} RATE_LIMIT_HEAVY_PER_MINUTE: ${RATE_LIMIT_HEAVY_PER_MINUTE:-30} + # Hosted is the fail-safe default. Exact custom endpoint and CIDR + # approval belongs to the deployment administrator, never a tenant. + AI_EGRESS_MODE: ${AI_EGRESS_MODE:-hosted} + AI_EGRESS_ALLOWED_BASE_URLS: ${AI_EGRESS_ALLOWED_BASE_URLS:-} + AI_EGRESS_ALLOWED_CIDRS: ${AI_EGRESS_ALLOWED_CIDRS:-} volumes: - partha_storage:/data/partha depends_on: @@ -41,6 +46,9 @@ services: timeout: 5s retries: 6 start_period: 10s + networks: + - data + - egress postgres: image: postgres:16-alpine @@ -57,12 +65,25 @@ services: interval: 5s timeout: 5s retries: 10 + networks: + - data redis: image: redis:7-alpine ports: - "6379:6379" + networks: + - data volumes: postgres_data: partha_storage: + +networks: + # Database and cache are isolated from outbound networks. The API joins this + # network solely to reach its data dependencies. + data: + internal: true + # Compose cannot express an exact provider destination allowlist. Production + # deployments must apply their own firewall, egress proxy, or mesh policy. + egress: diff --git a/docs/README.md b/docs/README.md index 8ba9507a..bb1ec07f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -11,6 +11,7 @@ Every document listed here is maintained and describes the system as it currentl | [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. | | [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, Compose boundary, and required production network controls. | | [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.** | diff --git a/docs/architecture/SYSTEM_OVERVIEW.md b/docs/architecture/SYSTEM_OVERVIEW.md index b673a89a..c5e743d8 100644 --- a/docs/architecture/SYSTEM_OVERVIEW.md +++ b/docs/architecture/SYSTEM_OVERVIEW.md @@ -67,7 +67,7 @@ flowchart LR | `analysis/` | Architecture model — modules, layers, edges, request-flow hints. **Consumer.** | Read the filesystem. | | `graph/` | Dependency graph response model. **Consumer.** | Re-read dependency manifests. | | `review/` | Engineering review findings, scores, roadmap. **Consumer.** | Re-read the filesystem. | -| `ai/` | Context builder, prompt builder, orchestrator, provider registry/factory, and five provider implementations. **Consumer.** | Parse repositories or read source files. | +| `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. | — | @@ -118,7 +118,7 @@ This runs **synchronously inside the HTTP request**. A large repository blocks a | `repositories.repo_metadata` (JSON column) | Parser metadata and the **legacy/unverified** serialized Repository Intelligence under the `intelligence` key. | New imports no longer stash `commitSha` here. Existing legacy facts are retained for compatibility and are not copied into `ri.v1` observed facts. | | `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. | The persistence boundary, sealing rules, syntax-aware producers, query APIs, and golden benchmark are implemented. Architecture relationships consume a sealed snapshot when present; durable product jobs do not populate one yet. | | `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 and never returned to the client. | +| `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. | | 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. @@ -186,7 +186,7 @@ Everything else calls `RepositoryIntelligenceEngine.from_record(record)` and tra | --- | --- | --- | | `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; none required. | AI workspace is unusable until the user configures a provider; the rest of the system is unaffected. | +| 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 | Compose and CI only. Redis backs the rate limiter when `RATE_LIMIT_BACKEND=redis`. | Local development uses SQLite and the in-memory rate limiter; neither service is required. | --- @@ -222,7 +222,7 @@ flowchart TB - **Uploaded archives and cloned repositories are untrusted input.** Extraction rejects path traversal and symlink escape; 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. - **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. 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. +- **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). --- diff --git a/docs/security/AI_PROVIDER_EGRESS.md b/docs/security/AI_PROVIDER_EGRESS.md new file mode 100644 index 00000000..537715fb --- /dev/null +++ b/docs/security/AI_PROVIDER_EGRESS.md @@ -0,0 +1,136 @@ +# AI provider egress policy + +PARTHA treats an AI provider destination as a deployment security boundary. A +tenant may choose from the supported providers and supply their own provider +credential, but a tenant cannot expand 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, tenant-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 tenant-facing forms, +and do not add a wildcard so users 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`, `/ai/query`, and `/ai/stream`, 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. An administrator or 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. + +## Docker Compose and production network controls + +The Compose file places PostgreSQL and Redis on the explicit internal `data` +network. Only the API joins the separate `egress` network, in addition to +`data`, so the API can reach its data dependencies without making the data +services outbound-capable. + +That topology is useful local isolation, but Compose cannot enforce an exact +provider destination allowlist for the API. A hosted or shared deployment still +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 tenants 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 variables wired into +local Docker Compose. From 03eda5ad2b5217920b537fd963565da3f43ddce6 Mon Sep 17 00:00:00 2001 From: hardikuppal04 Date: Wed, 22 Jul 2026 17:39:39 +0530 Subject: [PATCH 130/347] fix(dependencies): clarify empty inventory states (#125) (#136) * fix(dependencies): clarify empty inventory states (#125) * fix(dependencies): distinguish blocking diagnostics (#125) --------- Co-authored-by: hardikuppal04 <262667963+hardikuppal04@users.noreply.github.com> Co-authored-by: PARTH J ROHIT --- .../src/app/pages/DependenciesPage.test.tsx | 289 +++++++++++++----- .../src/app/pages/DependenciesPage.tsx | 67 +++- .../dependencies/hooks/useDependencies.ts | 2 +- 3 files changed, 270 insertions(+), 88 deletions(-) diff --git a/apps/frontend/src/app/pages/DependenciesPage.test.tsx b/apps/frontend/src/app/pages/DependenciesPage.test.tsx index 66f3541d..632d948d 100644 --- a/apps/frontend/src/app/pages/DependenciesPage.test.tsx +++ b/apps/frontend/src/app/pages/DependenciesPage.test.tsx @@ -1,94 +1,119 @@ -import { render, screen } from '@testing-library/react'; +import { fireEvent, render, screen } 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 type { DependencyDiagnostic, DependencyGraphResponse } from '@/shared/services/api/types'; import { DependenciesPage } from './DependenciesPage'; vi.mock('@/features/dependencies/hooks/useDependencies', () => ({ useDependencies: vi.fn(), })); -describe('DependenciesPage', () => { - beforeEach(() => { - vi.mocked(useDependencies).mockReturnValue({ - activeRepository: { - id: 'repo-1', - name: 'sample', - source: 'upload', - size: 100, - fileCount: 2, - status: 'completed', - dataSource: 'real', - 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, +const graph: DependencyGraphResponse = { + repositoryId: 'repo-1', + nodes: [ + { + id: 'dependency: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', }, - fileTree: [], - }, - completedRepositories: [], - status: 'success', - loading: false, - error: null, - empty: false, - success: true, - source: 'real', - emptyReason: null, - graph: { - repositoryId: 'repo-1', - nodes: [ - { - id: 'dependency: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', - }, - ], - size: null, - }, - ], - edges: [], - totalDependencies: 1, - manifestCount: 1, - diagnostics: [], - vulnerabilityAssessment: { status: 'not_computed' }, - outdatedAssessment: { status: 'not_computed' }, - }, - retry: vi.fn(), - refresh: vi.fn(), + ], + size: null, + }, + ], + edges: [], + 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', + dataSource: 'real', + 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, + empty: false, + success: true, + source: 'real' 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(); }); it('shows uncomputed assessments without clean badges or numeric fallbacks', () => { - render( - - - , - ); + renderPage(); expect(screen.getAllByText('Not computed')).toHaveLength(2); expect(screen.getByText('Vulnerability and outdated-version assessments have not been run.')).toBeInTheDocument(); @@ -96,4 +121,114 @@ describe('DependenciesPage', () => { expect(screen.queryByText('vulnerable')).not.toBeInTheDocument(); expect(screen.queryByText('outdated')).not.toBeInTheDocument(); }); + + it('shows an empty inventory instead of a search-miss message', () => { + mockDependencies({ + graph: { + ...graph, + nodes: [], + 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: [], + 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: [], + 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(); + }); }); diff --git a/apps/frontend/src/app/pages/DependenciesPage.tsx b/apps/frontend/src/app/pages/DependenciesPage.tsx index 9bacd648..b70dac81 100644 --- a/apps/frontend/src/app/pages/DependenciesPage.tsx +++ b/apps/frontend/src/app/pages/DependenciesPage.tsx @@ -13,13 +13,20 @@ export function DependenciesPage() { 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') { @@ -74,21 +81,36 @@ export function DependenciesPage() { ); } + if (!graph) { + return ( +
+ + + + +
+ ); + } + return (
- - + +

@@ -106,12 +128,34 @@ export function DependenciesPage() { 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" />

- +
- {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.

) : (
@@ -134,7 +178,10 @@ export function DependenciesPage() {
)}
- {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 backend package manifests.
diff --git a/apps/frontend/src/features/dependencies/hooks/useDependencies.ts b/apps/frontend/src/features/dependencies/hooks/useDependencies.ts index 25707f3d..bdcd3ae2 100644 --- a/apps/frontend/src/features/dependencies/hooks/useDependencies.ts +++ b/apps/frontend/src/features/dependencies/hooks/useDependencies.ts @@ -50,6 +50,6 @@ export function useDependencies() { error: state.error || error, retry: refresh, refresh, - packageManager: state.activeRepository?.meta?.packageManager || 'npm', + packageManager: state.activeRepository?.meta?.packageManager ?? null, }; } From da115ada7acfe84fcbcf8a161e559e962b5c8fab Mon Sep 17 00:00:00 2001 From: hardikuppal04 <262667963+hardikuppal04@users.noreply.github.com> Date: Mon, 20 Jul 2026 00:58:28 +0530 Subject: [PATCH 131/347] fix(sidebar): remove placeholder identity (#111) --- .../shared/components/layout/Sidebar.test.tsx | 55 +++++++++++++++++++ .../src/shared/components/layout/Sidebar.tsx | 19 +++---- 2 files changed, 63 insertions(+), 11 deletions(-) create mode 100644 apps/frontend/src/shared/components/layout/Sidebar.test.tsx diff --git a/apps/frontend/src/shared/components/layout/Sidebar.test.tsx b/apps/frontend/src/shared/components/layout/Sidebar.test.tsx new file mode 100644 index 00000000..1a49a75c --- /dev/null +++ b/apps/frontend/src/shared/components/layout/Sidebar.test.tsx @@ -0,0 +1,55 @@ +import { render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { useAppStore } from '@/app/store/useAppStore'; +import { useAuthStore } from '@/app/store/useAuthStore'; +import { Sidebar } from './Sidebar'; + +const initialAppState = useAppStore.getState(); +const initialAuthState = useAuthStore.getState(); + +function renderSidebar() { + return render( + + + , + ); +} + +describe('Sidebar', () => { + beforeEach(() => { + useAppStore.setState({ ...initialAppState, sidebarCollapsed: false }); + useAuthStore.setState({ + ...initialAuthState, + status: 'authenticated', + accessToken: 'test-token', + user: { id: 'user-1', email: 'hardik@example.com', createdAt: '2026-07-20T00:00:00Z' }, + }); + }); + + afterEach(() => { + useAppStore.setState(initialAppState); + useAuthStore.setState(initialAuthState); + }); + + it('shows the authenticated email without a fabricated identity or plan', () => { + renderSidebar(); + + expect(screen.getByText('hardik@example.com')).toBeInTheDocument(); + expect(screen.getByText('H')).toBeInTheDocument(); + expect(screen.queryByText('Developer')).not.toBeInTheDocument(); + expect(screen.queryByText('Free Plan')).not.toBeInTheDocument(); + }); + + it('keeps core navigation available and hides deferred surfaces from primary navigation', () => { + renderSidebar(); + + expect(screen.getByRole('link', { name: 'Dashboard' })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Architecture' })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Dependency Graph' })).toBeInTheDocument(); + expect(screen.queryByRole('link', { name: 'Engineering Review' })).not.toBeInTheDocument(); + expect(screen.queryByRole('link', { name: 'AI Workspace' })).not.toBeInTheDocument(); + expect(screen.queryByRole('link', { name: 'Documentation' })).not.toBeInTheDocument(); + expect(screen.queryByRole('link', { name: 'Insights' })).not.toBeInTheDocument(); + }); +}); diff --git a/apps/frontend/src/shared/components/layout/Sidebar.tsx b/apps/frontend/src/shared/components/layout/Sidebar.tsx index 4537a410..d16c9dd1 100644 --- a/apps/frontend/src/shared/components/layout/Sidebar.tsx +++ b/apps/frontend/src/shared/components/layout/Sidebar.tsx @@ -6,33 +6,29 @@ import { Upload, Network, GitBranch, - Bot, - FileText, - Lightbulb, Settings, ChevronLeft, Hexagon, - ShieldCheck, } from 'lucide-react'; import { cn } from '@/shared/utils/cn'; import { useAppStore } from '@/app/store/useAppStore'; +import { useAuthStore } from '@/app/store/useAuthStore'; const navItems = [ { label: 'Dashboard', icon: LayoutDashboard, path: '/' }, { label: 'Repositories', icon: FolderGit2, path: '/repositories' }, { label: 'Upload Repository', icon: Upload, path: '/upload' }, { label: 'Architecture', icon: Network, path: '/architecture' }, - { label: 'Engineering Review', icon: ShieldCheck, path: '/review' }, { label: 'Dependency Graph', icon: GitBranch, path: '/dependencies' }, - { label: 'AI Workspace', icon: Bot, path: '/ai-workspace' }, - { label: 'Documentation', icon: FileText, path: '/documentation' }, - { label: 'Insights', icon: Lightbulb, path: '/insights' }, { label: 'Settings', icon: Settings, path: '/settings' }, ]; export function Sidebar() { const location = useLocation(); const { sidebarCollapsed, toggleSidebar } = useAppStore(); + const user = useAuthStore((state) => state.user); + const userEmail = user?.email ?? null; + const avatarInitial = userEmail?.charAt(0).toUpperCase() ?? '?'; return (
- P + {avatarInitial}
{!sidebarCollapsed && ( @@ -117,8 +113,9 @@ export function Sidebar() { transition={{ duration: 0.15 }} className="overflow-hidden" > -

Developer

-

Free Plan

+

+ {userEmail ?? 'Account identity unavailable'} +

)}
From 08143fa34934a6f2ff4712f52b787efab95291ff Mon Sep 17 00:00:00 2001 From: hardikuppal04 <262667963+hardikuppal04@users.noreply.github.com> Date: Mon, 20 Jul 2026 15:00:04 +0530 Subject: [PATCH 132/347] fix(alembic): escape percent-encoded database URLs (#110) --- apps/backend/alembic/env.py | 2 +- apps/backend/tests/test_migrations.py | 38 +++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/apps/backend/alembic/env.py b/apps/backend/alembic/env.py index 8eb71f48..64cf2b23 100644 --- a/apps/backend/alembic/env.py +++ b/apps/backend/alembic/env.py @@ -9,7 +9,7 @@ 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:": diff --git a/apps/backend/tests/test_migrations.py b/apps/backend/tests/test_migrations.py index c93b80c1..1d815a6f 100644 --- a/apps/backend/tests/test_migrations.py +++ b/apps/backend/tests/test_migrations.py @@ -79,6 +79,44 @@ def test_migrations_upgrade_and_downgrade_run_clean(tmp_path, monkeypatch): 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 ): From fb7929bb6ce9661eb4c97b9184aff0c67ba20473 Mon Sep 17 00:00:00 2001 From: Shaurya K Sharma Date: Wed, 22 Jul 2026 16:53:26 +0100 Subject: [PATCH 133/347] fix: stop presenting fabricated analysis results --- .../versions/0006_remove_data_source.py | 29 +++++++ apps/backend/app/api/routes/ai.py | 12 +-- apps/backend/app/api/routes/repositories.py | 1 - apps/backend/app/models/repository.py | 1 - apps/backend/app/schemas/repository.py | 2 - .../app/services/repository_service.py | 3 - apps/backend/tests/test_ai_architecture.py | 1 - apps/backend/tests/test_ai_stream.py | 6 +- apps/backend/tests/test_migrations.py | 2 + .../tests/test_repository_intelligence.py | 1 - apps/backend/tests/test_review_evidence.py | 1 - apps/frontend/src/app/pages/DashboardPage.tsx | 2 +- .../src/app/pages/DependenciesPage.test.tsx | 3 +- .../src/app/pages/RepositoriesPage.tsx | 2 +- .../src/app/store/useAuthStore.test.ts | 1 - .../features/ai/hooks/useAIWorkspace.test.tsx | 82 +++++++++++++++++++ .../src/features/ai/hooks/useAIWorkspace.ts | 40 +-------- .../analysis/hooks/useAnalysisPipeline.ts | 3 +- .../components/ArchSummaryBar.tsx | 4 +- .../architecture/components/ArchWorkspace.tsx | 6 +- .../architecture/hooks/useArchitecture.ts | 6 +- .../repositories/hooks/useRepositoryTree.ts | 2 +- .../src/features/review/hooks/useReview.ts | 6 +- .../features/upload/hooks/useGitHubImport.ts | 6 +- .../src/features/upload/hooks/useUpload.ts | 6 +- .../components/ui/DataSourceBadge.test.tsx | 21 +++++ .../shared/components/ui/DataSourceBadge.tsx | 19 ++--- .../src/shared/feature-state/featureState.ts | 6 +- .../useRepositoryFeatureStatus.ts | 2 +- apps/frontend/src/shared/services/api/ai.ts | 2 + .../frontend/src/shared/services/api/types.ts | 3 +- apps/frontend/src/shared/services/backend.ts | 1 - apps/frontend/src/shared/types/index.ts | 2 - .../REPOSITORY_INTELLIGENCE_V1_RFC.md | 2 +- docs/architecture/SYSTEM_OVERVIEW.md | 4 +- 35 files changed, 185 insertions(+), 105 deletions(-) create mode 100644 apps/backend/alembic/versions/0006_remove_data_source.py create mode 100644 apps/frontend/src/features/ai/hooks/useAIWorkspace.test.tsx create mode 100644 apps/frontend/src/shared/components/ui/DataSourceBadge.test.tsx diff --git a/apps/backend/alembic/versions/0006_remove_data_source.py b/apps/backend/alembic/versions/0006_remove_data_source.py new file mode 100644 index 00000000..ee2a411a --- /dev/null +++ b/apps/backend/alembic/versions/0006_remove_data_source.py @@ -0,0 +1,29 @@ +"""remove the meaningless repository data-source field + +Revision ID: 0006_remove_data_source +Revises: 0005_revision_snapshots +Create Date: 2026-07-22 + +``repositories.source`` already records the actual repository provenance +(``upload`` or ``github``). The separate ``data_source`` column only ever held +the hardcoded value ``real`` and could not represent a meaningful state. +""" + +from alembic import op +import sqlalchemy as sa + + +revision = "0006_remove_data_source" +down_revision = "0005_revision_snapshots" +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/app/api/routes/ai.py b/apps/backend/app/api/routes/ai.py index 5ff9658a..130f7a56 100644 --- a/apps/backend/app/api/routes/ai.py +++ b/apps/backend/app/api/routes/ai.py @@ -129,8 +129,8 @@ async def query_ai( response_class=StreamingResponse, responses={ 200: response_example( - "Server-sent events containing a repository-aware AI response.", - 'data: {"type":"content","content":"Authentication "}\\n\\n' + "Buffered server-sent events containing a repository-aware AI response.", + 'data: {"type":"content","content":"Authentication is handled by the auth module."}\\n\\n' 'data: {"type":"done"}\\n\\n', media_type="text/event-stream", schema={"type": "string"}, @@ -147,13 +147,13 @@ async def stream_ai( # (404) or a missing provider key (422) must surface as a normal error # response here — not inside the generator, where 200 headers would already # have been sent and the failure could only abort a stream that "succeeded". - # query already computes the full response before any word is emitted, so - # awaiting it here changes nothing on the success path. + # The provider contract is buffered, so this endpoint is an SSE transport + # for compatibility rather than token streaming. Emit one complete content + # event instead of presenting word-splitting as live provider output. response = await service.query(request) async def events(): - for word in response.message.content.split(" "): - yield f"data: {json.dumps({'type': 'content', 'content': word + ' '})}\n\n" + yield f"data: {json.dumps({'type': 'content', 'content': response.message.content})}\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" diff --git a/apps/backend/app/api/routes/repositories.py b/apps/backend/app/api/routes/repositories.py index 5d247551..2a8d2996 100644 --- a/apps/backend/app/api/routes/repositories.py +++ b/apps/backend/app/api/routes/repositories.py @@ -28,7 +28,6 @@ "size": 2048, "fileCount": 12, "status": "completed", - "dataSource": "real", "analysisStage": "completed", "analysisProgress": 100, "uploadedAt": "2026-07-17T00:00:00Z", diff --git a/apps/backend/app/models/repository.py b/apps/backend/app/models/repository.py index d417911b..affc8c82 100644 --- a/apps/backend/app/models/repository.py +++ b/apps/backend/app/models/repository.py @@ -48,7 +48,6 @@ class RepositoryRecord(Base): 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)) diff --git a/apps/backend/app/schemas/repository.py b/apps/backend/app/schemas/repository.py index 79bf5fa8..ec375dbd 100644 --- a/apps/backend/app/schemas/repository.py +++ b/apps/backend/app/schemas/repository.py @@ -7,7 +7,6 @@ RepositorySource = Literal["upload", "github"] RepositoryStatus = Literal["uploading", "analysing", "completed", "error"] -DataSource = Literal["real"] AnalysisStage = Literal[ "uploading", "extracting", @@ -70,7 +69,6 @@ class RepositoryResponse(CamelModel): size: int file_count: int status: RepositoryStatus - data_source: DataSource analysis_stage: AnalysisStage | None = None analysis_progress: int uploaded_at: datetime diff --git a/apps/backend/app/services/repository_service.py b/apps/backend/app/services/repository_service.py index 3318a015..e0d30f8b 100644 --- a/apps/backend/app/services/repository_service.py +++ b/apps/backend/app/services/repository_service.py @@ -117,7 +117,6 @@ def import_github_repository(self, request: GitHubImportRequest) -> RepositoryRe size=total_size, file_count=meta.total_files, status="analysing", - data_source="real", analysis_stage="building-file-tree", analysis_progress=70, uploaded_at=now, @@ -170,7 +169,6 @@ async def import_uploaded_repository(self, file: UploadFile) -> RepositoryRespon size=total_size, file_count=meta.total_files, status="analysing", - data_source="real", analysis_stage="building-file-tree", analysis_progress=70, uploaded_at=now, @@ -264,7 +262,6 @@ 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, diff --git a/apps/backend/tests/test_ai_architecture.py b/apps/backend/tests/test_ai_architecture.py index da265f03..307e25df 100644 --- a/apps/backend/tests/test_ai_architecture.py +++ b/apps/backend/tests/test_ai_architecture.py @@ -36,7 +36,6 @@ def _record(root: Path) -> RepositoryRecord: size=total_size, file_count=meta.total_files, status="completed", - data_source="real", analysis_stage="completed", analysis_progress=100, uploaded_at=datetime.now(UTC), diff --git a/apps/backend/tests/test_ai_stream.py b/apps/backend/tests/test_ai_stream.py index 5c3a94df..88372697 100644 --- a/apps/backend/tests/test_ai_stream.py +++ b/apps/backend/tests/test_ai_stream.py @@ -1,4 +1,4 @@ -"""Authorization and error-ordering for POST /ai/stream (#63, #65). +"""Authorization and buffered-SSE behavior for POST /ai/stream (#63, #65, #96). Regression cover for the defect where ``service.query`` ran inside the SSE generator, after ``StreamingResponse`` had already emitted 200 headers: a @@ -81,8 +81,8 @@ def test_stream_succeeds_for_owner_with_valid_prerequisites(client): assert _is_event_stream(response) events = _parse_events(response.text) assert events[-1] == {"type": "done"} - content = "".join(event["content"] for event in events if event["type"] == "content") - assert "Two" in content and "words" in content + content_events = [event for event in events if event["type"] == "content"] + assert content_events == [{"type": "content", "content": "Two words"}] finally: client.app.dependency_overrides.pop(get_provider_registry, None) diff --git a/apps/backend/tests/test_migrations.py b/apps/backend/tests/test_migrations.py index 96dcdef9..79824ca9 100644 --- a/apps/backend/tests/test_migrations.py +++ b/apps/backend/tests/test_migrations.py @@ -108,6 +108,7 @@ def test_revision_backfill_classifies_exact_legacy_values_and_downgrade_preserve connection.execute(repositories.insert(), rows) command.upgrade(cfg, "head") + 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: @@ -145,6 +146,7 @@ def test_revision_backfill_classifies_exact_legacy_values_and_downgrade_preserve 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: diff --git a/apps/backend/tests/test_repository_intelligence.py b/apps/backend/tests/test_repository_intelligence.py index 6906e343..736365a4 100644 --- a/apps/backend/tests/test_repository_intelligence.py +++ b/apps/backend/tests/test_repository_intelligence.py @@ -51,7 +51,6 @@ def _record(root: Path, intelligence) -> RepositoryRecord: 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), diff --git a/apps/backend/tests/test_review_evidence.py b/apps/backend/tests/test_review_evidence.py index 8a893b5a..84563d7b 100644 --- a/apps/backend/tests/test_review_evidence.py +++ b/apps/backend/tests/test_review_evidence.py @@ -31,7 +31,6 @@ def _review(root: Path): 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), diff --git a/apps/frontend/src/app/pages/DashboardPage.tsx b/apps/frontend/src/app/pages/DashboardPage.tsx index 3b844776..12cdf1d5 100644 --- a/apps/frontend/src/app/pages/DashboardPage.tsx +++ b/apps/frontend/src/app/pages/DashboardPage.tsx @@ -92,7 +92,7 @@ export function DashboardPage() {
- + {repo.meta && ( {repo.meta.totalFiles} files diff --git a/apps/frontend/src/app/pages/DependenciesPage.test.tsx b/apps/frontend/src/app/pages/DependenciesPage.test.tsx index 028d2888..d76d967e 100644 --- a/apps/frontend/src/app/pages/DependenciesPage.test.tsx +++ b/apps/frontend/src/app/pages/DependenciesPage.test.tsx @@ -18,7 +18,6 @@ describe('DependenciesPage', () => { size: 100, fileCount: 2, status: 'completed', - dataSource: 'real', analysisStage: 'completed', analysisProgress: 100, uploadedAt: '2026-07-15T00:00:00Z', @@ -42,7 +41,7 @@ describe('DependenciesPage', () => { error: null, empty: false, success: true, - source: 'real', + source: 'upload', emptyReason: null, graph: { repositoryId: 'repo-1', diff --git a/apps/frontend/src/app/pages/RepositoriesPage.tsx b/apps/frontend/src/app/pages/RepositoriesPage.tsx index b7e1e796..f027bd75 100644 --- a/apps/frontend/src/app/pages/RepositoriesPage.tsx +++ b/apps/frontend/src/app/pages/RepositoriesPage.tsx @@ -125,7 +125,7 @@ export function RepositoriesPage() {
{repo.status} - +
diff --git a/apps/frontend/src/app/store/useAuthStore.test.ts b/apps/frontend/src/app/store/useAuthStore.test.ts index 3b0b62f7..edbcc3e0 100644 --- a/apps/frontend/src/app/store/useAuthStore.test.ts +++ b/apps/frontend/src/app/store/useAuthStore.test.ts @@ -16,7 +16,6 @@ function fakeRepository(id: string): Repository { size: 0, fileCount: 0, status: 'completed', - dataSource: 'real', analysisStage: 'completed', analysisProgress: 100, uploadedAt: new Date().toISOString(), diff --git a/apps/frontend/src/features/ai/hooks/useAIWorkspace.test.tsx b/apps/frontend/src/features/ai/hooks/useAIWorkspace.test.tsx new file mode 100644 index 00000000..e29bddff --- /dev/null +++ b/apps/frontend/src/features/ai/hooks/useAIWorkspace.test.tsx @@ -0,0 +1,82 @@ +import { act, renderHook } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useAIWorkspace } from './useAIWorkspace'; +import { useRepositoryFeatureStatus } from '@/shared/feature-state/useRepositoryFeatureStatus'; +import { aiService } from '@/shared/services/api'; +import type { AiQueryResponse } from '@/shared/services/api/types'; +import type { Repository } from '@/shared/types'; + +vi.mock('@/shared/feature-state/useRepositoryFeatureStatus', () => ({ + useRepositoryFeatureStatus: vi.fn(), +})); + +vi.mock('@/shared/services/api', () => ({ + aiService: { + query: vi.fn(), + streamQuery: vi.fn(), + }, + getErrorMessage: vi.fn((error: unknown) => String(error)), +})); + +const repository: Repository = { + id: 'repo-1', + name: 'sample', + source: 'upload', + size: 0, + fileCount: 0, + status: 'completed', + analysisStage: 'completed', + analysisProgress: 100, + uploadedAt: '2026-07-22T00:00:00Z', + meta: null, + fileTree: [], +}; + +describe('useAIWorkspace', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(useRepositoryFeatureStatus).mockReturnValue({ + activeRepository: repository, + completedRepositories: [repository], + status: 'success', + loading: false, + error: null, + empty: false, + success: true, + source: 'upload', + emptyReason: null, + retry: vi.fn(), + refresh: vi.fn(), + }); + }); + + it('uses the buffered query response instead of pseudo-streaming words', async () => { + const response: AiQueryResponse = { + message: { + role: 'assistant', + content: 'The answer is complete.', + timestamp: '2026-07-22T00:00:01Z', + citations: [], + }, + suggestions: ['Ask a follow-up'], + }; + vi.mocked(aiService.query).mockResolvedValue(response); + + const { result } = renderHook(() => useAIWorkspace()); + act(() => result.current.setQuery('Explain this repository.')); + + await act(async () => { + await result.current.ask(); + }); + + expect(aiService.query).toHaveBeenCalledWith({ + repositoryId: 'repo-1', + query: 'Explain this repository.', + context: { conversationHistory: [] }, + }); + expect(aiService.streamQuery).not.toHaveBeenCalled(); + expect(result.current.messages).toHaveLength(2); + expect(result.current.messages[1]).toEqual(response.message); + expect(result.current.suggestions).toEqual(response.suggestions); + }); +}); diff --git a/apps/frontend/src/features/ai/hooks/useAIWorkspace.ts b/apps/frontend/src/features/ai/hooks/useAIWorkspace.ts index 1daa1870..e7293122 100644 --- a/apps/frontend/src/features/ai/hooks/useAIWorkspace.ts +++ b/apps/frontend/src/features/ai/hooks/useAIWorkspace.ts @@ -28,47 +28,13 @@ export function useAIWorkspace() { setError(null); try { - const assistantTimestamp = new Date().toISOString(); - const assistantMessage: AiMessage = { - role: 'assistant', - content: '', - timestamp: assistantTimestamp, - citations: [], - }; - setMessages((current) => [...current, assistantMessage]); - - await aiService.streamQuery({ + const response = await aiService.query({ repositoryId: activeRepository.id, query: trimmed, context: { conversationHistory: messages.slice(-8) }, - }, (chunk) => { - if (chunk.type === 'content' && chunk.content) { - setMessages((current) => - current.map((message) => - message.timestamp === assistantTimestamp - ? { ...message, content: `${message.content}${chunk.content}` } - : message, - ), - ); - } - if (chunk.type === 'citation' && chunk.citation) { - setMessages((current) => - current.map((message) => - message.timestamp === assistantTimestamp - ? { ...message, citations: [...(message.citations || []), chunk.citation!] } - : message, - ), - ); - } - if (chunk.type === 'error' && chunk.error) { - setError(chunk.error); - } }); - setSuggestions([ - 'Explain the main architecture boundaries.', - 'What files should I read first?', - 'What are the highest-risk engineering issues?', - ]); + setMessages((current) => [...current, response.message]); + setSuggestions(response.suggestions || []); } catch (caught) { setError(getErrorMessage(caught)); } finally { diff --git a/apps/frontend/src/features/analysis/hooks/useAnalysisPipeline.ts b/apps/frontend/src/features/analysis/hooks/useAnalysisPipeline.ts index 302fdb50..3bedca4c 100644 --- a/apps/frontend/src/features/analysis/hooks/useAnalysisPipeline.ts +++ b/apps/frontend/src/features/analysis/hooks/useAnalysisPipeline.ts @@ -66,7 +66,6 @@ export function useAnalysisPipeline(repositoryId: string | undefined) { if (!response || cancelled) return; const repositoryUpdates: Partial = { - dataSource: 'real', analysisStage: response.stage, analysisProgress: response.progress, errorMessage: response.error || undefined, @@ -135,7 +134,7 @@ export function useAnalysisPipeline(repositoryId: string | undefined) { error, empty: !repository, success: repositoryStatus === 'completed', - source: repository?.dataSource || null, + source: repository?.source || null, retry: refresh, refresh, cancel, diff --git a/apps/frontend/src/features/architecture/components/ArchSummaryBar.tsx b/apps/frontend/src/features/architecture/components/ArchSummaryBar.tsx index d79fea73..df4387fd 100644 --- a/apps/frontend/src/features/architecture/components/ArchSummaryBar.tsx +++ b/apps/frontend/src/features/architecture/components/ArchSummaryBar.tsx @@ -1,11 +1,11 @@ import { Code2, Layers, Box, FileCode, Route, Cpu } from 'lucide-react'; -import type { DataSource } from '@/shared/types'; +import type { RepositorySource } from '@/shared/types'; import type { ArchitectureModel } from '@/shared/types/architecture'; import { DataSourceBadge } from '@/shared/components/ui/DataSourceBadge'; interface ArchSummaryBarProps { model: ArchitectureModel; - source?: DataSource | null; + source?: RepositorySource | null; } export function ArchSummaryBar({ model, source }: ArchSummaryBarProps) { diff --git a/apps/frontend/src/features/architecture/components/ArchWorkspace.tsx b/apps/frontend/src/features/architecture/components/ArchWorkspace.tsx index a126f774..5f2ecc19 100644 --- a/apps/frontend/src/features/architecture/components/ArchWorkspace.tsx +++ b/apps/frontend/src/features/architecture/components/ArchWorkspace.tsx @@ -26,14 +26,14 @@ import { RelationshipPanel } from './RelationshipPanel'; import { getLayoutedElements } from '../layout'; import { useArchitectureStore } from '../store'; import { cn } from '@/shared/utils/cn'; -import type { DataSource } from '@/shared/types'; +import type { RepositorySource } from '@/shared/types'; import type { ArchitectureModel } from '@/shared/types/architecture'; const nodeTypes = { architectureNode: ArchitectureNode }; interface ArchWorkspaceInnerProps { model: ArchitectureModel; - source?: DataSource | null; + source?: RepositorySource | null; } function ArchWorkspaceInner({ model, source }: ArchWorkspaceInnerProps) { @@ -346,7 +346,7 @@ function ArchWorkspaceInner({ model, source }: ArchWorkspaceInnerProps) { ); } -export function ArchWorkspace({ model, source }: { model: ArchitectureModel; source?: DataSource | null }) { +export function ArchWorkspace({ model, source }: { model: ArchitectureModel; source?: RepositorySource | null }) { return ( diff --git a/apps/frontend/src/features/architecture/hooks/useArchitecture.ts b/apps/frontend/src/features/architecture/hooks/useArchitecture.ts index 31655977..25939f8d 100644 --- a/apps/frontend/src/features/architecture/hooks/useArchitecture.ts +++ b/apps/frontend/src/features/architecture/hooks/useArchitecture.ts @@ -1,5 +1,5 @@ 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'; @@ -13,7 +13,7 @@ export function useArchitecture() { const setStoreModel = useArchitectureStore((state) => state.setModel); const storeModel = useArchitectureStore((state) => state.model); const [model, setModel] = useState(storeModel); - const [source, setSource] = useState(null); + const [source, setSource] = useState(null); const [status, setStatus] = useState('idle'); const [error, setError] = useState(null); const [refreshKey, setRefreshKey] = useState(0); @@ -50,7 +50,7 @@ export function useArchitecture() { setModel(nextModel); setStoreModel(nextModel); - setSource('real'); + setSource(activeRepository.source); setStatus('success'); } catch (caught) { if (cancelled) return; diff --git a/apps/frontend/src/features/repositories/hooks/useRepositoryTree.ts b/apps/frontend/src/features/repositories/hooks/useRepositoryTree.ts index 59354570..6b69f380 100644 --- a/apps/frontend/src/features/repositories/hooks/useRepositoryTree.ts +++ b/apps/frontend/src/features/repositories/hooks/useRepositoryTree.ts @@ -13,6 +13,6 @@ export function useRepositoryTree(repository: Repository | null) { success: hasTree, retry: () => undefined, refresh: () => undefined, - source: repository?.dataSource || null, + source: repository?.source || null, }; } diff --git a/apps/frontend/src/features/review/hooks/useReview.ts b/apps/frontend/src/features/review/hooks/useReview.ts index e8f93c83..8d717f02 100644 --- a/apps/frontend/src/features/review/hooks/useReview.ts +++ b/apps/frontend/src/features/review/hooks/useReview.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, useState } from 'react'; -import type { DataSource, FeatureStatus } from '@/shared/types'; +import type { RepositorySource, FeatureStatus } from '@/shared/types'; import type { EngineeringReview } from '@/shared/types/review'; import { backendService } from '@/shared/services/backend'; import { getErrorMessage } from '@/shared/services/api'; @@ -12,7 +12,7 @@ export function useReview() { const { activeRepository, completedRepositories } = useRepository(); const { review: storeReview, setReview } = useReviewStore(); const [review, setLocalReview] = useState(storeReview); - const [source, setSource] = useState(null); + const [source, setSource] = useState(null); const [status, setStatus] = useState('idle'); const [error, setError] = useState(null); const [refreshKey, setRefreshKey] = useState(0); @@ -49,7 +49,7 @@ export function useReview() { setLocalReview(nextReview); setReview(nextReview); - setSource('real'); + setSource(activeRepository.source); setStatus('success'); } catch (caught) { if (cancelled) return; diff --git a/apps/frontend/src/features/upload/hooks/useGitHubImport.ts b/apps/frontend/src/features/upload/hooks/useGitHubImport.ts index 33af1148..2fe028d2 100644 --- a/apps/frontend/src/features/upload/hooks/useGitHubImport.ts +++ b/apps/frontend/src/features/upload/hooks/useGitHubImport.ts @@ -1,5 +1,5 @@ import { useCallback, useMemo, useState } from 'react'; -import type { DataSource, Repository } from '@/shared/types'; +import type { Repository } from '@/shared/types'; import { backendService } from '@/shared/services/backend'; import { getErrorMessage } from '@/shared/services/api'; import { useAppStore } from '@/app/store/useAppStore'; @@ -22,8 +22,6 @@ export function useGitHubImport() { const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - const dataSource: DataSource = 'real'; - const repositoryNames = useMemo( () => new Set(repositories.map((repo) => repo.name.toLowerCase())), [repositories], @@ -89,7 +87,7 @@ export function useGitHubImport() { error, empty: githubUrl.trim().length === 0, success: isValidGithubUrl(githubUrl), - source: dataSource, + source: 'github' as const, previewName: isValidGithubUrl(githubUrl) ? extractRepoName(githubUrl) : null, analyseGithub, retry: clearError, diff --git a/apps/frontend/src/features/upload/hooks/useUpload.ts b/apps/frontend/src/features/upload/hooks/useUpload.ts index 2eb76a75..7ec7be1d 100644 --- a/apps/frontend/src/features/upload/hooks/useUpload.ts +++ b/apps/frontend/src/features/upload/hooks/useUpload.ts @@ -1,5 +1,5 @@ import { useCallback, useMemo, useState } from 'react'; -import type { DataSource, Repository, UploadFile } from '@/shared/types'; +import type { Repository, UploadFile } from '@/shared/types'; import { backendService } from '@/shared/services/backend'; import { getErrorMessage } from '@/shared/services/api'; import { formatFileSize } from '@/shared/utils/cn'; @@ -28,8 +28,6 @@ export function useUpload() { const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - const dataSource: DataSource = 'real'; - const repositoryNames = useMemo( () => new Set(repositories.map((repo) => repo.name.toLowerCase())), [repositories], @@ -112,7 +110,7 @@ export function useUpload() { error, empty: !uploadFile, success: Boolean(uploadFile), - source: dataSource, + source: 'upload' as const, selectFile, rejectFile, removeFile, diff --git a/apps/frontend/src/shared/components/ui/DataSourceBadge.test.tsx b/apps/frontend/src/shared/components/ui/DataSourceBadge.test.tsx new file mode 100644 index 00000000..f40d34b8 --- /dev/null +++ b/apps/frontend/src/shared/components/ui/DataSourceBadge.test.tsx @@ -0,0 +1,21 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { DataSourceBadge } from './DataSourceBadge'; + +describe('DataSourceBadge', () => { + it('shows actual repository provenance instead of a generic real-data claim', () => { + const { rerender } = render(); + + expect(screen.getByTestId('repository-source')).toHaveTextContent('Uploaded archive'); + expect(screen.queryByText('Real data')).not.toBeInTheDocument(); + + rerender(); + expect(screen.getByTestId('repository-source')).toHaveTextContent('GitHub repository'); + }); + + it('renders nothing when provenance is unavailable', () => { + const { container } = render(); + + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/apps/frontend/src/shared/components/ui/DataSourceBadge.tsx b/apps/frontend/src/shared/components/ui/DataSourceBadge.tsx index 0d9bf990..3bdbad33 100644 --- a/apps/frontend/src/shared/components/ui/DataSourceBadge.tsx +++ b/apps/frontend/src/shared/components/ui/DataSourceBadge.tsx @@ -1,16 +1,15 @@ -import type { DataSource } from '@/shared/types'; +import type { RepositorySource } from '@/shared/types'; interface DataSourceBadgeProps { - source: DataSource | null | undefined; + source: RepositorySource | null | undefined; } -// This badge previously always rendered "Real data" regardless of its input. -// The `DataSource` type has a single value ('real') and the backend hard-codes -// data_source to "real", so the badge conveyed no information (audit F20). It now -// renders nothing. Call sites are intentionally left as harmless no-ops, and the -// data_source field/column is retained — dropping the NOT NULL column is a schema -// migration that is out of scope for this low-risk change. export function DataSourceBadge({ source }: DataSourceBadgeProps) { - void source; - return null; + if (!source) return null; + + return ( + + {source === 'github' ? 'GitHub repository' : 'Uploaded archive'} + + ); } diff --git a/apps/frontend/src/shared/feature-state/featureState.ts b/apps/frontend/src/shared/feature-state/featureState.ts index 2d69e322..148e901c 100644 --- a/apps/frontend/src/shared/feature-state/featureState.ts +++ b/apps/frontend/src/shared/feature-state/featureState.ts @@ -1,4 +1,4 @@ -import type { DataSource, FeatureStatus } from '@/shared/types'; +import type { RepositorySource, FeatureStatus } from '@/shared/types'; export interface FeatureState { data: T | null; @@ -7,7 +7,7 @@ export interface FeatureState { error: string | null; empty: boolean; success: boolean; - source: DataSource | null; + source: RepositorySource | null; retry: () => void; refresh: () => void; } @@ -23,7 +23,7 @@ export function createFeatureState({ data: T | null; status: FeatureStatus; error?: string | null; - source?: DataSource | null; + source?: RepositorySource | null; retry: () => void; refresh: () => void; }): FeatureState { diff --git a/apps/frontend/src/shared/feature-state/useRepositoryFeatureStatus.ts b/apps/frontend/src/shared/feature-state/useRepositoryFeatureStatus.ts index 62a1b87e..376addb2 100644 --- a/apps/frontend/src/shared/feature-state/useRepositoryFeatureStatus.ts +++ b/apps/frontend/src/shared/feature-state/useRepositoryFeatureStatus.ts @@ -26,7 +26,7 @@ export function useRepositoryFeatureStatus() { error: null, empty: status === 'empty', success: status === 'success', - source: activeRepository?.dataSource || null, + source: activeRepository?.source || null, emptyReason, retry: () => undefined, refresh: () => undefined, diff --git a/apps/frontend/src/shared/services/api/ai.ts b/apps/frontend/src/shared/services/api/ai.ts index f523e603..cbf9da0a 100644 --- a/apps/frontend/src/shared/services/api/ai.ts +++ b/apps/frontend/src/shared/services/api/ai.ts @@ -32,6 +32,8 @@ export const aiService = { onChunk: (chunk: AiStreamChunk) => void, config?: RequestConfig, ): Promise { + // Compatibility transport only: the backend currently emits one complete + // content event because providers expose buffered completion, not tokens. return streamRequest( '/ai/stream', request, diff --git a/apps/frontend/src/shared/services/api/types.ts b/apps/frontend/src/shared/services/api/types.ts index 296ad8d8..b6148493 100644 --- a/apps/frontend/src/shared/services/api/types.ts +++ b/apps/frontend/src/shared/services/api/types.ts @@ -1,4 +1,4 @@ -import type { DataSource, FileTreeNode, RepositoryMeta, AnalysisStage } from '@/shared/types'; +import type { FileTreeNode, RepositoryMeta, AnalysisStage } from '@/shared/types'; import type { ArchitectureModel } from '@/shared/types/architecture'; import type { EngineeringReview } from '@/shared/types/review'; @@ -43,7 +43,6 @@ export interface RepositoryResponse { size: number; fileCount: number; status: 'uploading' | 'analysing' | 'completed' | 'error'; - dataSource: DataSource; analysisStage: AnalysisStage | null; analysisProgress: number; uploadedAt: string; diff --git a/apps/frontend/src/shared/services/backend.ts b/apps/frontend/src/shared/services/backend.ts index 79952cfb..cb367f6c 100644 --- a/apps/frontend/src/shared/services/backend.ts +++ b/apps/frontend/src/shared/services/backend.ts @@ -90,7 +90,6 @@ function mapRepositoryResponse(response: RepositoryResponse): Repository { size: response.size, fileCount: response.fileCount, status: response.status, - dataSource: response.dataSource, analysisStage: response.analysisStage, analysisProgress: response.analysisProgress, uploadedAt: response.uploadedAt, diff --git a/apps/frontend/src/shared/types/index.ts b/apps/frontend/src/shared/types/index.ts index 4bf671ca..a90ccbd1 100644 --- a/apps/frontend/src/shared/types/index.ts +++ b/apps/frontend/src/shared/types/index.ts @@ -1,7 +1,6 @@ export type AppStatus = 'empty' | 'repository-selected' | 'uploading' | 'analysing' | 'completed' | 'error'; export type RepositorySource = 'upload' | 'github'; -export type DataSource = 'real'; export type FeatureStatus = 'idle' | 'loading' | 'success' | 'error' | 'empty'; export type AnalysisStage = @@ -68,7 +67,6 @@ export interface Repository { size: number; fileCount: number; status: 'uploading' | 'analysing' | 'completed' | 'error'; - dataSource: DataSource; analysisStage: AnalysisStage | null; analysisProgress: number; uploadedAt: string; diff --git a/docs/architecture/REPOSITORY_INTELLIGENCE_V1_RFC.md b/docs/architecture/REPOSITORY_INTELLIGENCE_V1_RFC.md index c782f853..b776ad53 100644 --- a/docs/architecture/REPOSITORY_INTELLIGENCE_V1_RFC.md +++ b/docs/architecture/REPOSITORY_INTELLIGENCE_V1_RFC.md @@ -1792,7 +1792,7 @@ this RFC to imply otherwise. - [`apps/backend/app/intelligence/models.py`](../../apps/backend/app/intelligence/models.py) — current serialized model; `SourceSymbol` has no span. - [`apps/backend/app/intelligence/engine.py`](../../apps/backend/app/intelligence/engine.py) — current regex extraction. -- [`apps/backend/app/parsers/tree_sitter_parser.py`](../../apps/backend/app/parsers/tree_sitter_parser.py) — placeholder parser (returns no symbols). +- [`apps/backend/app/extraction/typescript.py`](../../apps/backend/app/extraction/typescript.py) — syntax-aware TypeScript producer; production ingestion integration remains downstream work. - [`apps/backend/app/services/repository_service.py`](../../apps/backend/app/services/repository_service.py) — `_metadata_with_intelligence`, `_content_hash_for_upload`, 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`). diff --git a/docs/architecture/SYSTEM_OVERVIEW.md b/docs/architecture/SYSTEM_OVERVIEW.md index 98606336..73a2efd3 100644 --- a/docs/architecture/SYSTEM_OVERVIEW.md +++ b/docs/architecture/SYSTEM_OVERVIEW.md @@ -63,7 +63,7 @@ flowchart LR | `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. `TreeSitterParser` is a **placeholder** — it maps extensions to language names and always returns zero symbols. | Produce feature-specific output. | +| `parsers/` | `RepositoryParser` walks the extracted tree and produces the file tree plus basic metadata. Syntax-aware `PythonExtractor` and `TypeScriptExtractor` live under `extraction/`; legacy ingestion still uses the parser's heuristic symbol path. | Produce feature-specific output. | | `analysis/` | Architecture model — modules, layers, edges, request-flow hints. **Consumer.** | Read the filesystem. | | `graph/` | Dependency graph response model. **Consumer.** | Re-read dependency manifests. | | `review/` | Engineering review findings, scores, roadmap. **Consumer.** | Re-read the filesystem. | @@ -230,7 +230,7 @@ flowchart TB These are properties of the system as built, not a wish list. -1. **Extraction is heuristic, not language-aware.** File roles, modules, and layers are inferred from path segments and filenames. Symbols come from regular expressions. `TreeSitterParser` returns nothing, even though `tree-sitter` is a declared dependency. +1. **Production ingestion remains partly heuristic.** File roles, modules, and layers are inferred from path segments and filenames, and legacy ingestion symbols come from regular expressions. Standalone syntax-aware Python and TypeScript extractors exist, but durable product integration remains a separate workflow. 2. **No line-level provenance in production output.** The snapshot schema can store validated spans and derivations, but the current regex engine emits neither and is deliberately not promoted into `ri.v1`. 3. **The graph store has no production producers or consumers yet.** Immutable normalized tables exist, but product surfaces still read the legacy JSON blob. Four of the eight legacy relationship types are never emitted; syntax-aware extraction/resolution and snapshot queries remain later issues. 4. **Processing is synchronous and whole-repository.** No background jobs, no incremental re-analysis, no cancellation. From a70cc0a621d27c11be758e2bb035a4f7e9dc2535 Mon Sep 17 00:00:00 2001 From: PARTH J ROHIT Date: Wed, 22 Jul 2026 16:57:12 +0100 Subject: [PATCH 134/347] feat(analysis): add durable cancellable analysis jobs (#147) * feat(analysis): add durable analysis job model Adds the analysis_jobs table backing the durable, cancellable analysis job lifecycle (#93): status/stage/progress columns, bounded-retry bookkeeping (attempt/max_attempts/next_attempt_at), a worker lease (worker_id/lease_expires_at), and a partial unique index preventing duplicate concurrent jobs per (repository, revision, config) identity. * feat(ingestion): bound decompressed archive size, entry count, and file count Archive extraction and repository ingestion previously bounded only the compressed upload size and the post-clone directory size, leaving decompressed/extracted size, archive member count, and total file count unbounded. Adds max_extracted_size_bytes, max_extracted_entries, and max_file_count budgets, rejected before extraction runs or before the job pipeline consumes an oversized file tree. * feat(analysis): add durable analysis job submission service Introduce AnalysisJobService.submit/status/cancel over the analysis_jobs table. Submission is idempotent on the (repository_id, revision_value, config_hash) semantic identity: an already-sealed snapshot short-circuits to a completed job, an active job is returned unchanged, and a racing concurrent insert reconciles via the partial unique index instead of erroring. * feat(analysis): add durable analysis job worker AnalysisWorker.run_once() atomically claims one queued job (portable compare-and-swap, no SKIP LOCKED) and runs it off the request path: the legacy RepositoryIntelligenceEngine build followed by the evidence-backed extraction pipeline that seals a Repository Intelligence snapshot. Includes cooperative cancellation, bounded exponential-backoff retry, and the deliberate two-commit (seal vs. job-completion) design documented inline. * feat(analysis): run analysis via durable jobs off the request path Convert POST /analysis/{id}/start to enqueue-and-return via AnalysisJobService.submit, report the five real job states through GET /status, and add POST /cancel. Widen the status schema Literal (processing->running, +cancelled) and add jobId. Start the worker on a gated daemon thread in the app lifespan and disable autostart in tests so they drive run_once() deterministically. Drop the now-dead AnalysisService.start/status and fix every existing test that assumed synchronous /start completion. * feat(analysis): recover stale durable jobs * feat(frontend): support analysis cancellation * docs(analysis): document durable job lifecycle * fix(analysis): guard reclaimed job ownership * docs: add analysis job screenshots * fix(analysis): harden retry and recovery * fix analysis job concurrency edge cases * fix final cancellation and parser races * fix parser file-count preflight iterator * fix analysis job lifecycle races * fix sqlite heartbeat cleanup * interrupt cancelled analysis stages --------- Co-authored-by: parthrohit22 <205336108+parthrohit22@users.noreply.github.com> --- README.md | 12 +- apps/backend/README.md | 30 +- .../alembic/versions/0006_analysis_jobs.py | 91 ++ apps/backend/app/api/deps.py | 10 +- apps/backend/app/api/routes/analysis.py | 78 +- apps/backend/app/api/routes/reports.py | 3 +- apps/backend/app/core/config.py | 22 + apps/backend/app/extraction/pipeline.py | 13 +- apps/backend/app/graph/dependency_graph.py | 13 +- apps/backend/app/intelligence/engine.py | 152 ++- apps/backend/app/intelligence/resolution.py | 48 +- apps/backend/app/main.py | 66 ++ apps/backend/app/models/__init__.py | 2 + apps/backend/app/models/analysis_job.py | 88 ++ apps/backend/app/parsers/repository_parser.py | 28 +- apps/backend/app/review/review_service.py | 8 +- apps/backend/app/schemas/analysis.py | 11 +- apps/backend/app/schemas/repository.py | 2 +- .../app/services/analysis_job_service.py | 442 +++++++ apps/backend/app/services/analysis_service.py | 60 +- .../app/services/repository_service.py | 33 +- apps/backend/app/storage/local.py | 38 +- apps/backend/app/workers/analysis_worker.py | 1032 +++++++++++++++++ apps/backend/tests/analysis_helpers.py | 26 + apps/backend/tests/conftest.py | 4 + .../backend/tests/extraction/test_pipeline.py | 30 + .../tests/intelligence/test_resolution.py | 38 + apps/backend/tests/test_analysis_job_model.py | 294 +++++ .../tests/test_analysis_job_service.py | 571 +++++++++ apps/backend/tests/test_analysis_worker.py | 898 ++++++++++++++ .../tests/test_architecture_relationships.py | 17 +- apps/backend/tests/test_export_api.py | 18 +- apps/backend/tests/test_ingestion_pipeline.py | 68 +- .../tests/test_ingestion_resource_budgets.py | 250 ++++ apps/backend/tests/test_migrations.py | 18 + apps/backend/tests/test_openapi_contract.py | 5 +- apps/backend/tests/test_rate_limit.py | 2 +- .../tests/test_repository_intelligence.py | 36 + apps/backend/tests/test_repository_parser.py | 74 +- apps/backend/tests/test_system.py | 13 + .../app/pages/AnalysisPipelinePage.test.tsx | 81 ++ .../src/app/pages/AnalysisPipelinePage.tsx | 50 +- apps/frontend/src/app/pages/DashboardPage.tsx | 2 +- .../src/app/pages/RepositoriesPage.tsx | 4 +- .../hooks/useAnalysisPipeline.test.ts | 271 +++++ .../analysis/hooks/useAnalysisPipeline.ts | 174 ++- .../repositories/hooks/useRepositoryDetail.ts | 5 +- .../src/features/repositories/status.ts | 1 + .../src/shared/components/layout/TopBar.tsx | 2 + .../src/shared/services/api/analysis.ts | 4 + .../frontend/src/shared/services/api/types.ts | 10 +- apps/frontend/src/shared/services/backend.ts | 5 + apps/frontend/src/shared/types/index.ts | 4 +- docs/README.md | 2 +- docs/architecture/REPOSITORY_INTELLIGENCE.md | 66 +- .../REPOSITORY_INTELLIGENCE_V1_RFC.md | 19 +- docs/architecture/SYSTEM_OVERVIEW.md | 39 +- docs/screenshots/analysis-active.png | Bin 0 -> 84951 bytes docs/screenshots/analysis-cancelled.png | Bin 0 -> 89539 bytes 59 files changed, 5151 insertions(+), 232 deletions(-) create mode 100644 apps/backend/alembic/versions/0006_analysis_jobs.py create mode 100644 apps/backend/app/models/analysis_job.py create mode 100644 apps/backend/app/services/analysis_job_service.py create mode 100644 apps/backend/app/workers/analysis_worker.py create mode 100644 apps/backend/tests/analysis_helpers.py create mode 100644 apps/backend/tests/test_analysis_job_model.py create mode 100644 apps/backend/tests/test_analysis_job_service.py create mode 100644 apps/backend/tests/test_analysis_worker.py create mode 100644 apps/backend/tests/test_ingestion_resource_budgets.py create mode 100644 apps/frontend/src/app/pages/AnalysisPipelinePage.test.tsx create mode 100644 apps/frontend/src/features/analysis/hooks/useAnalysisPipeline.test.ts create mode 100644 docs/screenshots/analysis-active.png create mode 100644 docs/screenshots/analysis-cancelled.png diff --git a/README.md b/README.md index 52a1b1c0..2d49a6de 100644 --- a/README.md +++ b/README.md @@ -60,17 +60,17 @@ Statuses below were checked against the implementation, not against prior docume | Repository explorer and file preview | **Implemented** | File tree, text and image preview, binary detection, truncation of large files. | | Documentation and report export | **Implemented** | JSON, Markdown, HTML, and PDF through a shared report pipeline. | | Authentication and frontend session flow | **Implemented** | Email/password with Argon2, short-lived access tokens, rotating refresh tokens with reuse detection. All frontend routes are behind an auth guard. | -| Repository Intelligence | **Implemented but limited** | Extracts discovery facts, file roles, imports/exports, routes, symbols, modules, and dependencies, then persists and reuses them. Extraction is regex- and path-convention-based, not language-aware. | +| Repository Intelligence | **Implemented but limited** | A durable analysis job preserves the legacy discovery model and also seals normalized, revision-addressed Python/TypeScript facts with evidence. Legacy module and review consumers remain heuristic. | | Architecture output | **Implemented but limited** | Modules, layers, relationships, and an interactive graph — with heuristic module and layer assignment. | | Dependency inventory | **Implemented but limited** | Reads `package.json`, `requirements.txt`, and `pyproject.toml`. Other ecosystems and lockfiles are not parsed. | | Engineering review | **Implemented but limited** | A fixed set of heuristic checks with derived category scores. Scores are arithmetic over finding severities, not a measured quality metric. | | AI provider integration | **Implemented but limited** | Several providers behind one abstraction. Provider configuration is per-user, with the API key encrypted at rest and injected per request; outbound destinations are centrally allowlisted and DNS-pinned. See [AI provider egress policy](docs/security/AI_PROVIDER_EGRESS.md). | | Authorization and owner isolation | **Implemented** | All repository, analysis, AI, documentation, and export routes require authentication and are owner-scoped in the service layer; a non-owner request returns 404. Rate-limit budgets are keyed per authenticated user. | | Citations and grounded AI answers | **Not implemented** | No source content or line numbers are sent to providers, and no citations are returned. | -| Asynchronous / incremental processing | **Not implemented** | Ingestion and analysis run synchronously in the request; there is no background job system and no incremental re-analysis. | +| Asynchronous / incremental processing | **Partially implemented** | Import and initial file-tree parsing remain synchronous. Analysis runs in a durable, cancellable background job with progress, bounded retry, and stale-worker recovery; incremental re-analysis is not implemented. | | Change-impact analysis | **Not implemented** | — | | Vulnerability and outdated-dependency scanning | **Not implemented** | The API exposes explicit `not_computed` assessment statuses. It emits no clean result or count because no scanning is performed. | -| Persistent semantic knowledge graph | **Not implemented** | The graph is serialized as JSON onto the repository row; there is no queryable graph store. | +| Persistent semantic knowledge graph | **Implemented but limited** | Analysis seals normalized `ri.v1` snapshot tables with provenance and query APIs for Python/TypeScript facts. Several product consumers still use the legacy JSON compatibility model. | A capability is listed as implemented only where the behaviour exists in code — not because a model, an API field, a class name, or an issue describes it. @@ -242,9 +242,9 @@ Backend coverage is the stronger of the two. Frontend coverage is thin and there - **Not yet hardened for public multi-tenant use.** Authentication and owner isolation are enforced across the backend routes, provider keys are encrypted at rest, and AI provider egress is centrally constrained, but PARTHA has not been operated as a hardened multi-tenant deployment. It is not production-ready and should not be exposed to the public internet without further review. Outside `development`/`test`, set `AUTH_SECRET_KEY` and `AI_ENCRYPTION_KEY` (a Fernet key); the backend refuses to start without them. Production also needs an independent network egress control; application validation is not a firewall. - **Extraction is heuristic.** File roles, modules, and layers are inferred from paths and filenames; symbols come from regular expressions. Expect wrong answers on projects that do not follow common conventions, and do not treat heuristic output as guaranteed fact. -- **Evidence and provenance are partial.** File-level only — no line spans, no per-fact extraction method, no revision-addressed facts. -- **No persistent semantic graph.** Repository facts are serialized as JSON onto the repository row rather than into a queryable graph store. -- **Analysis is synchronous and whole-repository.** No background jobs, no incremental re-analysis. +- **Evidence and provenance are partial at the product layer.** Normalized Python/TypeScript snapshot facts carry revision identity, line spans, and extractor versions, but legacy modules, reviews, documentation, exports, and AI answers do not all consume them yet. +- **The persistent graph is not the only read model yet.** Durable analysis seals queryable `ri.v1` snapshots, while several compatibility consumers still read serialized legacy intelligence from the repository row. +- **Analysis is whole-repository.** It now runs as a cancellable background job, but there is no incremental re-analysis. - **No change-impact analysis, and no vulnerability or outdated-dependency scanning.** - **AI answers are not evidence-backed.** They are grounded in repository structure and file paths only, with no citations. Treat them as a hypothesis to verify. diff --git a/apps/backend/README.md b/apps/backend/README.md index d88ab531..a1d49d67 100644 --- a/apps/backend/README.md +++ b/apps/backend/README.md @@ -95,7 +95,35 @@ curl -X POST http://localhost:8000/repositories/github \ -d '{"url":"https://github.com/octocat/Hello-World"}' ``` -Only public GitHub HTTPS URLs are accepted. Ingestion and analysis run **synchronously** inside the request — a large repository will block until the clone, parse, and analysis finish. +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`. Repository responses include first-class source identity: 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/app/api/deps.py b/apps/backend/app/api/deps.py index fadddb03..10f13c3b 100644 --- a/apps/backend/app/api/deps.py +++ b/apps/backend/app/api/deps.py @@ -32,6 +32,7 @@ from app.reports.export_service import ExportService from app.review.review_service import EngineeringReviewBuilder 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.repository_service import RepositoryService @@ -99,7 +100,6 @@ 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: @@ -108,7 +108,6 @@ def get_repository_service( storage=storage, github=github, parser=parser, - intelligence=intelligence, settings=settings, owner_id=current_user.id, ) @@ -202,6 +201,13 @@ def get_analysis_service( ) +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_service( repository: RepositoryRepository = Depends(get_repository_repository), config_store: EncryptedProviderConfigStore = Depends(get_ai_config_store), diff --git a/apps/backend/app/api/routes/analysis.py b/apps/backend/app/api/routes/analysis.py index 66a1ffad..3b33e042 100644 --- a/apps/backend/app/api/routes/analysis.py +++ b/apps/backend/app/api/routes/analysis.py @@ -1,18 +1,45 @@ from fastapi import APIRouter, Depends -from app.api.deps import get_analysis_service, get_current_user +from app.api.deps import get_analysis_job_service, get_analysis_service, get_current_user 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.dependencies import DependencyGraphResponse from app.schemas.review import EngineeringReviewResponse +from app.services.analysis_job_service import AnalysisJobService from app.services.analysis_service import AnalysisService -# Every analysis route requires auth; records are owner-scoped in AnalysisService. +# 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, 409, 429, 500) + + +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( @@ -20,17 +47,18 @@ response_model=AnalysisStartResponse, responses=documented_responses( 200, - "Repository analysis completed synchronously.", - {"repositoryId": _REPOSITORY_ID, "status": "completed"}, + "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( @@ -38,10 +66,11 @@ def start_analysis( response_model=AnalysisStatusResponse, responses=documented_responses( 200, - "Current repository-analysis status.", + "Current durable analysis-job status.", { "repositoryId": _REPOSITORY_ID, "status": "completed", + "jobId": _JOB_ID, "stage": "completed", "progress": 100, "startedAt": "2026-07-17T00:00:00Z", @@ -54,9 +83,36 @@ def start_analysis( ) def get_analysis_status( repository_id: str, - service: AnalysisService = Depends(get_analysis_service), + service: AnalysisJobService = Depends(get_analysis_job_service), +) -> AnalysisStatusResponse: + return _status_response(repository_id, service.status(repository_id)) + + +@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 service.status(repository_id) + return _status_response(repository_id, service.cancel(repository_id)) @router.get( @@ -158,7 +214,7 @@ def get_dependencies( response_model=EngineeringReviewResponse, responses=documented_responses( 200, - "Engineering-review findings and improvement roadmap.", + "Computed engineering-review findings and roadmap; pending analysis returns 409.", { "repositoryId": _REPOSITORY_ID, "repositoryName": "example-service", @@ -176,7 +232,7 @@ def get_dependencies( "findings": [], "roadmap": [], }, - *_COMMON_ERRORS, + *_REVIEW_ERRORS, ), openapi_extra=suppress_automatic_validation_error(), ) diff --git a/apps/backend/app/api/routes/reports.py b/apps/backend/app/api/routes/reports.py index 948163bc..8e13ca59 100644 --- a/apps/backend/app/api/routes/reports.py +++ b/apps/backend/app/api/routes/reports.py @@ -32,10 +32,11 @@ response_model=ExportResponse, responses=documented_responses( 200, - "Repository report rendered in the requested format.", + "Repository report rendered in the requested format; an uncomputed review returns 409.", _RESPONSE_EXAMPLE, 401, 404, + 409, 422, 429, 500, diff --git a/apps/backend/app/core/config.py b/apps/backend/app/core/config.py index cba120f4..050d841c 100644 --- a/apps/backend/app/core/config.py +++ b/apps/backend/app/core/config.py @@ -68,9 +68,26 @@ class Settings(BaseSettings): 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). ``analysis_worker_autostart`` gates the + # background daemon thread started in ``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 loop sleeps + # between empty polls; the lease bounds how long a claimed job is owned before + # a future stale-job sweep may reclaim it. + analysis_worker_autostart: bool = True + analysis_job_poll_interval_seconds: int = 5 + analysis_job_lease_seconds: int = 300 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. @@ -192,9 +209,14 @@ def validate_ai_egress_allowed_cidrs(cls, value: list[str]) -> list[str]: return normalized @field_validator( + "analysis_job_poll_interval_seconds", + "analysis_job_lease_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", diff --git a/apps/backend/app/extraction/pipeline.py b/apps/backend/app/extraction/pipeline.py index 7c7ccd68..15c8aa34 100644 --- a/apps/backend/app/extraction/pipeline.py +++ b/apps/backend/app/extraction/pipeline.py @@ -12,7 +12,7 @@ from __future__ import annotations import posixpath -from collections.abc import Mapping, Sequence +from collections.abc import Callable, Mapping, Sequence from dataclasses import dataclass from app.extraction.base import ( @@ -61,13 +61,20 @@ def __init__( self.extractors = tuple(extractors) self.max_source_bytes = max_source_bytes - def run(self, sources: Mapping[str, bytes]) -> tuple[ProducedExtraction, ...]: + def run( + self, + sources: Mapping[str, bytes], + *, + check_cancelled: Callable[[], None] | None = None, + ) -> tuple[ProducedExtraction, ...]: inventory_nodes: list[ExtractedNode] = [] inventory_diagnostics: list[ExtractedDiagnostic] = [] produced: list[ProducedExtraction] = [] repository_evidence: ExtractedEvidence | None = None for raw_path, source in sorted(sources.items()): + if check_cancelled is not None: + check_cancelled() try: path = canonical.normalize_repo_path(raw_path) except canonical.PathEscapeError: @@ -116,6 +123,8 @@ def run(self, sources: Mapping[str, bytes]) -> tuple[ProducedExtraction, ...]: if matches: extractor = matches[0] result = extractor.extract(path, source) + if check_cancelled is not None: + check_cancelled() produced.append( ProducedExtraction(extractor.name, extractor.version, result) ) diff --git a/apps/backend/app/graph/dependency_graph.py b/apps/backend/app/graph/dependency_graph.py index fa2143bf..fa5da242 100644 --- a/apps/backend/app/graph/dependency_graph.py +++ b/apps/backend/app/graph/dependency_graph.py @@ -13,7 +13,18 @@ def __init__(self, intelligence: RepositoryIntelligenceEngine | None = None) -> self.intelligence = intelligence or RepositoryIntelligenceEngine() def build(self, record: RepositoryRecord) -> DependencyGraphResponse: - repository_intelligence = self.intelligence.from_record(record) + repository_intelligence = self.intelligence.load(record) + if repository_intelligence is None: + return DependencyGraphResponse( + repository_id=record.id, + nodes=[], + edges=[], + total_dependencies=0, + manifest_count=0, + diagnostics=[], + vulnerability_assessment=DependencyAssessment(status="not_computed"), + outdated_assessment=DependencyAssessment(status="not_computed"), + ) nodes = [ DependencyNode( id=dependency.id, diff --git a/apps/backend/app/intelligence/engine.py b/apps/backend/app/intelligence/engine.py index ec18203e..8b52f577 100644 --- a/apps/backend/app/intelligence/engine.py +++ b/apps/backend/app/intelligence/engine.py @@ -2,6 +2,7 @@ import re from collections import Counter, defaultdict +from collections.abc import Callable from datetime import UTC, datetime from pathlib import Path from typing import Any @@ -87,7 +88,13 @@ class RepositoryIntelligenceEngine: and Python; wiring those into this build path is #93. """ - def from_record(self, record: RepositoryRecord) -> RepositoryIntelligence: + def from_record( + self, + record: RepositoryRecord, + *, + check_cancelled: Callable[[], None] | None = None, + ) -> RepositoryIntelligence: + self._check_cancelled(check_cancelled) existing = self.load(record) if existing: if existing.discovery.environment_files and not existing.discovery.environment_file_evidence: @@ -95,20 +102,30 @@ def from_record(self, record: RepositoryRecord) -> RepositoryIntelligence: # them in bounded O(environment files) time without rereading or rebuilding # the repository. Unknown legacy content is deliberately treated as a runtime # file, which cannot produce a critical secret-exposure finding. - evidence = [ - EnvironmentFileEvidence(path=path, evidence_class="runtime_env_file_present") - for path in existing.discovery.environment_files - ] + evidence: list[EnvironmentFileEvidence] = [] + for path in existing.discovery.environment_files: + self._check_cancelled(check_cancelled) + evidence.append( + EnvironmentFileEvidence( + path=path, + evidence_class="runtime_env_file_present", + ) + ) discovery = existing.discovery.model_copy(update={"environment_file_evidence": evidence}) return existing.model_copy(update={"discovery": discovery}) return existing + tree: list[FileTreeNode] = [] + for node in record.file_tree or []: + self._check_cancelled(check_cancelled) + tree.append(FileTreeNode.model_validate(node)) 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 []], + tree=tree, metadata=RepositoryMeta.model_validate(record.repo_metadata or {}), total_size=record.size, + check_cancelled=check_cancelled, ) def load(self, record: RepositoryRecord) -> RepositoryIntelligence | None: @@ -133,14 +150,43 @@ def build( tree: list[FileTreeNode], metadata: RepositoryMeta, total_size: int, + *, + check_cancelled: Callable[[], None] | None = None, ) -> 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, dependency_manifest_count, dependency_diagnostics = self._dependencies(root, flat_files) - discovery = self._discovery(root, 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) + self._check_cancelled(check_cancelled) + flat_files = self._flatten_files(tree, check_cancelled=check_cancelled) + file_intelligence: list[SourceFileIntelligence] = [] + for node in flat_files: + self._check_cancelled(check_cancelled) + file_intelligence.append(self._file_intelligence(root, node)) + symbols: list[SourceSymbol] = [] + for file in file_intelligence: + self._check_cancelled(check_cancelled) + symbols.extend(file.symbols) + dependencies, dependency_manifest_count, dependency_diagnostics = self._dependencies( + root, + flat_files, + check_cancelled=check_cancelled, + ) + discovery = self._discovery( + root, + metadata, + tree, + file_intelligence, + dependencies, + total_size, + check_cancelled=check_cancelled, + ) + modules = self._modules(file_intelligence, check_cancelled=check_cancelled) + graph = self._knowledge_graph( + repository_id, + repository_name, + modules, + file_intelligence, + symbols, + dependencies, + check_cancelled=check_cancelled, + ) return RepositoryIntelligence( repository_id=repository_id, repository_name=repository_name, @@ -156,15 +202,31 @@ def build( graph=graph, ) - def _flatten_files(self, nodes: list[FileTreeNode]) -> list[FileTreeNode]: + def _flatten_files( + self, + nodes: list[FileTreeNode], + *, + check_cancelled: Callable[[], None] | None = None, + ) -> list[FileTreeNode]: result: list[FileTreeNode] = [] for node in nodes: + self._check_cancelled(check_cancelled) if node.type == "file": result.append(node) if node.children: - result.extend(self._flatten_files(node.children)) + result.extend( + self._flatten_files( + node.children, + check_cancelled=check_cancelled, + ) + ) return result + @staticmethod + def _check_cancelled(check_cancelled: Callable[[], None] | None) -> None: + if check_cancelled is not None: + check_cancelled() + def _file_intelligence(self, root: Path, node: FileTreeNode) -> SourceFileIntelligence: path = node.path extension = node.extension @@ -309,7 +371,11 @@ def _technologies(self, path: str, text: str) -> list[str]: return sorted(technologies) def _dependencies( - self, root: Path, files: list[FileTreeNode] + self, + root: Path, + files: list[FileTreeNode], + *, + check_cancelled: Callable[[], None] | None = None, ) -> tuple[list[RepositoryDependency], int, list[DependencyDiagnostic]]: """Extract declarations from parser-approved manifest inventory only. @@ -330,6 +396,7 @@ def _dependencies( diagnostics: list[DependencyDiagnostic] = [] declarations_by_key: dict[str, list[DependencyDeclaration]] = defaultdict(list) for file in sorted(files, key=lambda item: item.path): + self._check_cancelled(check_cancelled) try: path = canonical.normalize_repo_path(file.path.lstrip("/")) except canonical.PathEscapeError: @@ -406,7 +473,8 @@ def _dependencies( ) continue - for run in pipeline.run({path: source}): + for run in pipeline.run({path: source}, check_cancelled=check_cancelled): + self._check_cancelled(check_cancelled) for diagnostic in run.result.diagnostics: diagnostics.append( DependencyDiagnostic( @@ -444,6 +512,7 @@ def _dependencies( dependencies: list[RepositoryDependency] = [] for stable_key, declarations in sorted(declarations_by_key.items()): + self._check_cancelled(check_cancelled) ordered = sorted( declarations, key=lambda item: ( @@ -490,16 +559,23 @@ def _discovery( files: list[SourceFileIntelligence], dependencies: list[RepositoryDependency], total_size: int, + *, + check_cancelled: Callable[[], None] | None = None, ) -> RepositoryDiscovery: + self._check_cancelled(check_cancelled) language_counts = Counter(file.language for file in files if file.language) - folders = self._count_folders(tree) + folders = self._count_folders(tree, check_cancelled=check_cancelled) 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")] - environment_file_evidence = self._environment_file_evidence(root, env_files) + environment_file_evidence = self._environment_file_evidence( + root, + env_files, + check_cancelled=check_cancelled, + ) 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) @@ -531,9 +607,16 @@ def _discovery( ), ) - def _environment_file_evidence(self, root: Path, paths: list[str]) -> list[EnvironmentFileEvidence]: + def _environment_file_evidence( + self, + root: Path, + paths: list[str], + *, + check_cancelled: Callable[[], None] | None = None, + ) -> list[EnvironmentFileEvidence]: evidence: list[EnvironmentFileEvidence] = [] for path in paths: + self._check_cancelled(check_cancelled) secret_keys = self._secret_like_keys(self._read_text(root, path)) if secret_keys: evidence_class = "secret_like_value_detected" @@ -646,13 +729,22 @@ def _has_embedded_credentials(self, value: str) -> bool: userinfo = re.search(r"://[^/@\s]+:([^/@\s]+)@", value) return bool(userinfo) and not self._is_placeholder_value(userinfo.group(1)) - def _count_folders(self, nodes: list[FileTreeNode]) -> int: + def _count_folders( + self, + nodes: list[FileTreeNode], + *, + check_cancelled: Callable[[], None] | None = None, + ) -> int: count = 0 for node in nodes: + self._check_cancelled(check_cancelled) if node.type == "folder": count += 1 if node.children: - count += self._count_folders(node.children) + count += self._count_folders( + node.children, + check_cancelled=check_cancelled, + ) return count def _frameworks_from_dependencies(self, dep_names: set[str]) -> set[str]: @@ -680,12 +772,19 @@ def _build_systems(self, paths: list[str], dep_names: set[str]) -> list[str]: systems.add("Docker") return sorted(systems) - def _modules(self, files: list[SourceFileIntelligence]) -> list[RepositoryModule]: + def _modules( + self, + files: list[SourceFileIntelligence], + *, + check_cancelled: Callable[[], None] | None = None, + ) -> list[RepositoryModule]: grouped: dict[str, list[SourceFileIntelligence]] = defaultdict(list) for file in files: + self._check_cancelled(check_cancelled) grouped[file.module_id].append(file) modules: list[RepositoryModule] = [] for module_id, module_files in grouped.items(): + self._check_cancelled(check_cancelled) 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}) @@ -760,21 +859,26 @@ def _knowledge_graph( files: list[SourceFileIntelligence], symbols: list[SourceSymbol], dependencies: list[RepositoryDependency], + *, + check_cancelled: Callable[[], None] | None = None, ) -> 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: + self._check_cancelled(check_cancelled) 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: + self._check_cancelled(check_cancelled) 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: + self._check_cancelled(check_cancelled) dependency = self._dependency_for_import(import_name, dependencies) target = dependency.id if dependency else f"external:{import_name}" if dependency is None: @@ -783,6 +887,7 @@ def _knowledge_graph( seen_nodes = {node.id for node in nodes} for symbol in symbols: + self._check_cancelled(check_cancelled) 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: @@ -790,6 +895,7 @@ def _knowledge_graph( seen_nodes.add(symbol.id) for dependency in dependencies: + self._check_cancelled(check_cancelled) if dependency.id not in seen_nodes: nodes.append( KnowledgeGraphNode( diff --git a/apps/backend/app/intelligence/resolution.py b/apps/backend/app/intelligence/resolution.py index 4307c135..f591b5fb 100644 --- a/apps/backend/app/intelligence/resolution.py +++ b/apps/backend/app/intelligence/resolution.py @@ -9,6 +9,7 @@ from __future__ import annotations from collections import defaultdict +from collections.abc import Callable from dataclasses import dataclass import posixpath import re @@ -78,7 +79,12 @@ def __init__(self, store: SnapshotStore) -> None: def producer(self) -> str: return f"{self.name}@{self.version}" - def resolve(self, snapshot: RiSnapshot) -> ResolutionResult: + 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 @@ -87,6 +93,7 @@ def resolve(self, snapshot: RiSnapshot) -> ResolutionResult: 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): @@ -110,44 +117,56 @@ def resolve(self, snapshot: RiSnapshot) -> ResolutionResult: for evidence in self.store.db.scalars( select(RiEvidence).where(RiEvidence.snapshot_id == snapshot.snapshot_id) ): + 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 = [ - _ObservedInput(observation, sorted(evidence_by_observation.get(observation.id, ()), key=self._evidence_key)[0]) - for observation in observations - if evidence_by_observation.get(observation.id) - ] + 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 = { - self._reference_input_key(input_) - for input_ in inputs_by_kind["call_shadowed"] - } + 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" @@ -160,6 +179,7 @@ def resolve(self, snapshot: RiSnapshot) -> ResolutionResult: 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" ) @@ -167,14 +187,17 @@ def resolve(self, snapshot: RiSnapshot) -> ResolutionResult: 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 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, ()), @@ -186,6 +209,11 @@ def resolve(self, snapshot: RiSnapshot) -> ResolutionResult: 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]: diff --git a/apps/backend/app/main.py b/apps/backend/app/main.py index 4dd7f2fb..22cdf15d 100644 --- a/apps/backend/app/main.py +++ b/apps/backend/app/main.py @@ -2,8 +2,10 @@ from contextlib import asynccontextmanager from os import getpid import logging +import threading from time import perf_counter from typing import Any, Literal +from uuid import uuid4 from fastapi import FastAPI, Request, status from fastapi.middleware.cors import CORSMiddleware @@ -27,6 +29,8 @@ logger = logging.getLogger(__name__) +_ANALYSIS_STALE_SWEEP_INTERVAL = 10 + _READINESS_SCHEMA = { "type": "object", "required": ["status", "environment", "checks"], @@ -84,15 +88,77 @@ def check_storage_ready() -> bool: return True +def _analysis_worker_id() -> str: + """Return a process-observable, globally unique worker ownership token.""" + + return f"analysis-worker-{getpid()}-{uuid4().hex}" + + +def _start_analysis_worker() -> tuple[threading.Thread, threading.Event, Any] | None: + """Start the durable analysis worker on a daemon thread (#93). + + The loop claims and runs one queued job per iteration, sleeping only when the + queue is empty so a backlog drains promptly. It is gated by + ``analysis_worker_autostart`` so tests drive ``run_once`` deterministically + instead of racing this thread. + """ + + settings = get_settings() + if not settings.analysis_worker_autostart: + return None + + from app.core.database import SessionLocal + from app.workers.analysis_worker import AnalysisWorker + + worker = AnalysisWorker( + SessionLocal, + worker_id=_analysis_worker_id(), + lease_seconds=settings.analysis_job_lease_seconds, + ) + stop_event = threading.Event() + + def _loop() -> None: + polls_since_sweep = 0 + while not stop_event.is_set(): + try: + claimed = worker.run_once() + polls_since_sweep += 1 + if polls_since_sweep >= _ANALYSIS_STALE_SWEEP_INTERVAL: + 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: + stop_event.wait(settings.analysis_job_poll_interval_seconds) + + # Reclaim jobs orphaned by a previous hard process exit immediately on + # startup; the loop repeats the sweep periodically for later crashes. + try: + worker.sweep_stale() + except Exception: # noqa: BLE001 - stale cleanup must not prevent API startup + logger.exception("Initial stale analysis-job sweep failed") + + thread = threading.Thread(target=_loop, name="analysis-worker", daemon=True) + thread.start() + return thread, stop_event, worker + + @asynccontextmanager 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) + worker_handle = _start_analysis_worker() try: yield finally: + if worker_handle is not None: + thread, stop_event, worker = worker_handle + stop_event.set() + worker.shutdown() + thread.join(timeout=10) aclose = getattr(app.state.rate_limit_store, "aclose", None) if aclose is not None: await aclose() diff --git a/apps/backend/app/models/__init__.py b/apps/backend/app/models/__init__.py index 5e328d0f..90fd6800 100644 --- a/apps/backend/app/models/__init__.py +++ b/apps/backend/app/models/__init__.py @@ -1,4 +1,5 @@ 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 ( @@ -15,6 +16,7 @@ __all__ = [ "AiProviderConfigRecord", + "AnalysisJob", "RefreshToken", "RepositoryRecord", "RiAssertion", 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/parsers/repository_parser.py b/apps/backend/app/parsers/repository_parser.py index 22f0f87c..4d226fe3 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 @@ -64,8 +65,19 @@ } +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 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"] @@ -94,6 +106,20 @@ 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]) -> None: + """Stream the tree and abort before sorting or allocating file nodes.""" + + with os.scandir(path) as entries: + for entry in entries: + if entry.name in IGNORED_DIRS: + continue + if entry.is_dir(): + self._enforce_file_count(Path(entry.path), max_file_count, file_count) + 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())): diff --git a/apps/backend/app/review/review_service.py b/apps/backend/app/review/review_service.py index fc6a6856..18bee707 100644 --- a/apps/backend/app/review/review_service.py +++ b/apps/backend/app/review/review_service.py @@ -1,6 +1,7 @@ from datetime import UTC, datetime from pathlib import PurePosixPath +from app.core.exceptions import ConflictServiceError from app.intelligence.engine import SOURCE_EXTENSIONS, RepositoryIntelligenceEngine from app.intelligence.models import EnvironmentFileEvidence, SourceFileIntelligence from app.models.repository import RepositoryRecord @@ -112,7 +113,12 @@ def __init__(self, intelligence: RepositoryIntelligenceEngine | None = None) -> self.intelligence = intelligence or RepositoryIntelligenceEngine() def build(self, record: RepositoryRecord) -> EngineeringReviewResponse: - repository_intelligence = self.intelligence.from_record(record) + repository_intelligence = self.intelligence.load(record) + if repository_intelligence is None: + raise ConflictServiceError( + "Engineering review is unavailable until repository analysis completes.", + {"repositoryId": record.id, "status": record.status}, + ) findings = self._findings(repository_intelligence) scores = self._scores(findings) summary = self._summary(findings, scores) 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/repository.py b/apps/backend/app/schemas/repository.py index 79bf5fa8..450cc82f 100644 --- a/apps/backend/app/schemas/repository.py +++ b/apps/backend/app/schemas/repository.py @@ -6,7 +6,7 @@ from app.schemas.base import CamelModel RepositorySource = Literal["upload", "github"] -RepositoryStatus = Literal["uploading", "analysing", "completed", "error"] +RepositoryStatus = Literal["uploading", "analysing", "completed", "cancelled", "error"] DataSource = Literal["real"] AnalysisStage = Literal[ "uploading", 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..fa7a8a4e --- /dev/null +++ b/apps/backend/app/services/analysis_job_service.py @@ -0,0 +1,442 @@ +"""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.pipeline import ExtractionPipeline +from app.extraction.python import PythonExtractor +from app.extraction.typescript import TypeScriptExtractor +from app.intelligence import canonical +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"{PythonExtractor.name}@{PythonExtractor.version}", + f"{TypeScriptExtractor.name}@{TypeScriptExtractor.version}", + f"{RelationshipResolver.name}@{RelationshipResolver.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 d5300ba2..693da860 100644 --- a/apps/backend/app/services/analysis_service.py +++ b/apps/backend/app/services/analysis_service.py @@ -1,18 +1,23 @@ -from datetime import UTC, datetime - from app.analysis.architecture import ArchitectureAnalyzer from app.graph.dependency_graph import DependencyGraphBuilder from app.intelligence.engine import RepositoryIntelligenceEngine 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.dependencies import DependencyGraphResponse from app.schemas.review import EngineeringReviewResponse -from app.core.exceptions import NotFoundError, ServiceError +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, + and review read models from already-persisted intelligence, which the export + service also reuses. + """ + def __init__( self, repository: RepositoryRepository, @@ -29,53 +34,6 @@ def __init__( self.intelligence = intelligence self.owner_id = owner_id - 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, - ) - def architecture_model(self, repository_id: str) -> ArchitectureResponse: return self.architecture.build_architecture(self._get_record(repository_id)) diff --git a/apps/backend/app/services/repository_service.py b/apps/backend/app/services/repository_service.py index 3318a015..14ef7239 100644 --- a/apps/backend/app/services/repository_service.py +++ b/apps/backend/app/services/repository_service.py @@ -11,14 +11,15 @@ 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.parsers.repository_parser import RepositoryFileLimitExceeded, RepositoryParser from app.repositories.repository_repository import RepositoryRepository from app.schemas.repository import ( + FileTreeNode, GitHubImportRequest, RepositoryFileResponse, RepositoryListResponse, + RepositoryMeta, RepositoryResponse, RepositoryRevision, ) @@ -49,7 +50,6 @@ def __init__( storage: LocalStorage, github: GitHubClient, parser: RepositoryParser, - intelligence: RepositoryIntelligenceEngine, settings: Settings, owner_id: str, ) -> None: @@ -57,7 +57,6 @@ def __init__( self.storage = storage self.github = github self.parser = parser - self.intelligence = intelligence self.settings = settings self.owner_id = owner_id @@ -94,9 +93,8 @@ def import_github_repository(self, request: GitHubImportRequest) -> RepositoryRe "Repository has already been imported.", {"repositoryId": existing.id, "name": existing.name}, ) - 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, self.github.repository_name(url), root, tree, meta, total_size) except Exception: self.storage.delete_repository_id(repository_id) raise @@ -122,7 +120,7 @@ def import_github_repository(self, request: GitHubImportRequest) -> RepositoryRe 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)) @@ -145,9 +143,8 @@ async def import_uploaded_repository(self, file: UploadFile) -> RepositoryRespon {"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) @@ -175,7 +172,7 @@ async def import_uploaded_repository(self, file: UploadFile) -> RepositoryRespon 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)) @@ -297,17 +294,23 @@ 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 + 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, diff --git a/apps/backend/app/storage/local.py b/apps/backend/app/storage/local.py index ebf94a47..0b121990 100644 --- a/apps/backend/app/storage/local.py +++ b/apps/backend/app/storage/local.py @@ -16,6 +16,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 @@ -89,19 +94,48 @@ 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.") + 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}, + ) archive.extractall(destination) def _normalise_single_root(self, destination: Path) -> Path: diff --git a/apps/backend/app/workers/analysis_worker.py b/apps/backend/app/workers/analysis_worker.py new file mode 100644 index 00000000..9cc57b00 --- /dev/null +++ b/apps/backend/app/workers/analysis_worker.py @@ -0,0 +1,1032 @@ +"""Durable analysis-job execution (#93). + +``AnalysisWorker`` claims one queued ``analysis_jobs`` row at a time and runs the +full analysis off the request path: the legacy ``RepositoryIntelligenceEngine`` +build (which other consumers still read via ``repo_metadata['intelligence']``) +followed by the evidence-backed extraction pipeline that seals a Repository +Intelligence snapshot. + +``run_once`` is the primary unit both the background loop in ``app.main`` and the +test-suite drive; it is synchronous and deterministic. It claims at most one job +with a portable compare-and-swap (no ``SELECT ... FOR UPDATE SKIP LOCKED``), 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``. + +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, Mapping +from contextlib import contextmanager +from dataclasses import dataclass, field +from datetime import UTC, datetime, timedelta +from pathlib import Path + +from sqlalchemy import func, or_, select, update +from sqlalchemy.orm import Session + +from app.extraction.base import ExtractedEvidence +from app.extraction.pipeline import ( + DEFAULT_MAX_SOURCE_BYTES, + ExtractionPipeline, + ProducedExtraction, +) +from app.extraction.python import PythonExtractor +from app.extraction.typescript import TypeScriptExtractor +from app.intelligence import canonical +from app.intelligence.engine import RepositoryIntelligenceEngine +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, +) + +_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 + store: SnapshotStore | None = None + snapshot: RiSnapshot | None = None + reused: bool = False + produced: tuple[ProducedExtraction, ...] = field(default_factory=tuple) + 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, + clock: Callable[[], datetime] = lambda: datetime.now(UTC), + heartbeat_interval_seconds: float | None = None, + ) -> None: + self.session_factory = session_factory + self.worker_id = worker_id + self.lease_seconds = lease_seconds + self.max_source_bytes = max_source_bytes + self._clock = clock + self._heartbeat_interval_seconds = heartbeat_interval_seconds or min( + max(lease_seconds / 3, 0.1), 5.0 + ) + self._shutdown = threading.Event() + self.intelligence = RepositoryIntelligenceEngine() + + # -- 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: + job = self._claim(session) + 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): + 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 = tuple( + session.scalars( + select(AnalysisJob.id) + .where( + AnalysisJob.status == "running", + AnalysisJob.lease_expires_at.is_not(None), + AnalysisJob.lease_expires_at < now, + ) + .order_by(AnalysisJob.lease_expires_at, AnalysisJob.created_at) + ) + ) + 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 self._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: + """Atomically claim the oldest eligible queued job, or return None. + + The claim is 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 retries. + """ + + now = self._clock() + candidate_id = session.scalar( + select(AnalysisJob.id) + .where( + AnalysisJob.status == "queued", + or_(AnalysisJob.next_attempt_at.is_(None), AnalysisJob.next_attempt_at <= now), + ) + .order_by(AnalysisJob.created_at) + .limit(1) + ) + if candidate_id is None: + return None + result = session.execute( + update(AnalysisJob) + .where(AnalysisJob.id == candidate_id, AnalysisJob.status == "queued") + .values( + status="running", + worker_id=self.worker_id, + lease_expires_at=now + timedelta(seconds=self.lease_seconds), + 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 + return session.get(AnalysisJob, candidate_id) + + # -- stage pipeline ------------------------------------------------------ + + def _execute_stages(self, session: Session, job: AnalysisJob) -> Iterator[_StageContext]: + """Run the job's stages, yielding at each boundary. + + Yielding after every stage gives the background 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 before each stage so a concurrent + ``AnalysisJobService.cancel`` is observed cooperatively. + """ + + ctx = _StageContext( + session=session, + job=job, + record=session.get(RepositoryRecord, job.repository_id), + ) + stages = ( + ("reading-structure", 25, self._stage_legacy), + ("extracting-modules", 50, 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 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_legacy(self, ctx: _StageContext) -> None: + """Run the legacy intelligence build, preserved verbatim from AnalysisService. + + ``from_record`` + ``persist`` populate ``repo_metadata['intelligence']``, + which the architecture/dependencies/review read endpoints still consume + directly; this behaviour is unchanged, only moved off the request path. + """ + + record = self._require_record(ctx) + # Known limitation: these legacy repository fields use their historical + # ladder and can briefly diverge from the durable job stage/progress. + record.status = "analysing" + record.analysis_stage = "preparing-architecture" + record.analysis_progress = 80 + repository_intelligence = self.intelligence.from_record( + record, + check_cancelled=lambda: self._check_heartbeat(ctx), + ) + self._check_heartbeat(ctx) + self.intelligence.persist(record, repository_intelligence) + + 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) + 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 + + 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 + sources = self._read_sources(record, ctx) + pipeline = ExtractionPipeline( + (PythonExtractor(), TypeScriptExtractor()), + max_source_bytes=self.max_source_bytes, + ) + ctx.produced = pipeline.run( + sources, + check_cancelled=lambda: self._check_heartbeat(ctx), + ) + self._check_heartbeat(ctx) + for produced in ctx.produced: + 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) + + 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) + + # -- 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 _reconcile_stale(self, session: Session, job: AnalysisJob, now: datetime) -> bool: + """Atomically claim and reconcile one expired running job.""" + + stale_worker_id = job.worker_id + stale_lease_expires_at = job.lease_expires_at + result = session.execute( + update(AnalysisJob) + .where( + AnalysisJob.id == job.id, + AnalysisJob.status == "running", + AnalysisJob.worker_id == stale_worker_id, + AnalysisJob.lease_expires_at == stale_lease_expires_at, + AnalysisJob.lease_expires_at < now, + ) + .values( + worker_id=self.worker_id, + lease_expires_at=now + timedelta(seconds=self.lease_seconds), + updated_at=now, + ) + .execution_options(synchronize_session="fetch") + ) + if result.rowcount == 0: + session.rollback() + 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) + + 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 + ownership = [ + AnalysisJob.id == job_id, + AnalysisJob.worker_id == self.worker_id, + AnalysisJob.status == "running", + ] + if require_cancel_not_requested: + ownership.append(AnalysisJob.cancel_requested.is_(False)) + with ctx.session.no_autoflush: + result = ctx.session.execute( + update(AnalysisJob) + .where(*ownership) + .values(**values) + .execution_options(synchronize_session="fetch") + ) + if result.rowcount == 0: + 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 bool( + ctx.session.scalar( + select(AnalysisJob.cancel_requested).where(AnalysisJob.id == 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}, + ) + + @contextmanager + def _heartbeat(self, ctx: _StageContext) -> Iterator[_HeartbeatState]: + """Renew one running job from an independent session during a stage.""" + + state = _HeartbeatState() + self._heartbeat_once(ctx.job.id, state) + + def _run() -> None: + while not state.stop.wait(self._heartbeat_interval_seconds): + if self._shutdown.is_set(): + return + self._heartbeat_once(ctx.job.id, 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, job_id: str, state: _HeartbeatState) -> None: + """Atomically renew ownership and return the cancellation flag.""" + + session = self.session_factory() + sqlite_connection = None + sqlite_busy_timeout = None + + def _restore_sqlite_timeout() -> None: + nonlocal sqlite_connection + if sqlite_connection is None or sqlite_busy_timeout is None: + return + try: + cursor = sqlite_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": + connection = session.connection() + sqlite_connection = connection.connection.driver_connection + cursor = sqlite_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 heartbeat must not block stage cleanup behind that lock. + cursor.execute("PRAGMA busy_timeout = 0") + cursor.close() + now = self._clock() + row = session.execute( + update(AnalysisJob) + .where( + AnalysisJob.id == job_id, + AnalysisJob.status == "running", + AnalysisJob.worker_id == self.worker_id, + ) + .values( + lease_expires_at=now + timedelta(seconds=self.lease_seconds), + updated_at=now, + ) + .returning(AnalysisJob.cancel_requested) + .execution_options(synchronize_session=False) + ).first() + if row is None: + _restore_sqlite_timeout() + session.rollback() + state.ownership_lost.set() + return + _restore_sqlite_timeout() + session.commit() + if row[0]: + state.cancel_requested.set() + except Exception as exc: # noqa: BLE001 - surfaced to the owning worker + _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 heartbeats 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 heartbeat skipped while the stage held the write lock") + else: + state.failure = exc + finally: + _restore_sqlite_timeout() + session.close() + + @staticmethod + def _check_heartbeat(ctx: _StageContext) -> None: + state = ctx.heartbeat + if state is None: + return + 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 + + 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() + + def _read_sources( + self, record: RepositoryRecord, ctx: _StageContext | None = None + ) -> Mapping[str, bytes]: + """Read repository source bytes keyed by normalized repo-relative path. + + Paths come from ``record.file_tree`` (already computed at ingestion) rather + than a fresh filesystem walk. Each file is read to at most + ``max_source_bytes + 1`` so an oversized file stays bounded in memory and + the pipeline emits its documented ``RI-LIMIT-SKIP`` diagnostic. + """ + + root = Path(record.local_path) + sources: dict[str, bytes] = {} + for raw_path in self._iter_file_paths(record.file_tree or []): + if ctx is not None: + self._check_heartbeat(ctx) + try: + path = canonical.normalize_repo_path(raw_path.lstrip("/")) + except canonical.PathEscapeError: + continue + if not path: + continue + try: + with (root / path).open("rb") as handle: + sources[path] = handle.read(self.max_source_bytes + 1) + except OSError: + continue + return sources + + @classmethod + def _iter_file_paths(cls, nodes: list) -> 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 children: + yield from cls._iter_file_paths(children) + + 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({"decorators"}) + if node.properties and "decorators" in node.properties + else frozenset() + ) + 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 _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 + + @staticmethod + def _error_message(exc: Exception) -> str: + message = str(exc) or exc.__class__.__name__ + return message[:_MAX_ERROR_MESSAGE] 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/conftest.py b/apps/backend/tests/conftest.py index f21bb495..3ee80901 100644 --- a/apps/backend/tests/conftest.py +++ b/apps/backend/tests/conftest.py @@ -31,6 +31,9 @@ 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") + # 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 @@ -56,6 +59,7 @@ 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() diff --git a/apps/backend/tests/extraction/test_pipeline.py b/apps/backend/tests/extraction/test_pipeline.py index 176d2aed..f88b1245 100644 --- a/apps/backend/tests/extraction/test_pipeline.py +++ b/apps/backend/tests/extraction/test_pipeline.py @@ -1,3 +1,5 @@ +import pytest + from app.extraction.pipeline import ExtractionPipeline from app.extraction.manifests import DependencyManifestExtractor from app.extraction.python import PythonExtractor @@ -48,3 +50,31 @@ def test_pipeline_uses_supports_and_inventory_for_unsupported_text(): nodes = [node for run in runs for node in run.result.nodes] assert any(node.stable_key == "file:README.md" for node in nodes) 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/intelligence/test_resolution.py b/apps/backend/tests/intelligence/test_resolution.py index 294c1bab..1f552c86 100644 --- a/apps/backend/tests/intelligence/test_resolution.py +++ b/apps/backend/tests/intelligence/test_resolution.py @@ -622,3 +622,41 @@ def test_python_relative_import_still_resolves_to_the_sibling_module(session): 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/test_analysis_job_model.py b/apps/backend/tests/test_analysis_job_model.py new file mode 100644 index 00000000..921e8158 --- /dev/null +++ b/apps/backend/tests/test_analysis_job_model.py @@ -0,0 +1,294 @@ +"""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..fd4429a7 --- /dev/null +++ b/apps/backend/tests/test_analysis_job_service.py @@ -0,0 +1,571 @@ +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, + ANALYSIS_SCHEMA_VERSION, + 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.0.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_worker.py b/apps/backend/tests/test_analysis_worker.py new file mode 100644 index 00000000..4a39e365 --- /dev/null +++ b/apps/backend/tests/test_analysis_worker.py @@ -0,0 +1,898 @@ +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.0.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) + 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_legacy", _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) # legacy + 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, + ) + + def _long_stage(_ctx): + entered.set() + assert release.wait(timeout=3) + + monkeypatch.setattr(worker, "_stage_legacy", _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: + job = worker._claim(claim_session) + assert job is not None + job_id = job.id + + 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=(job_id, 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_legacy", _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_legacy_analysis(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) + + entered = threading.Event() + persisted = threading.Event() + worker = AnalysisWorker( + session_factory, + worker_id="worker-a", + lease_seconds=60, + heartbeat_interval_seconds=0.02, + ) + + class _InterruptibleIntelligence: + def from_record(self, _record, *, check_cancelled): + entered.set() + while True: + check_cancelled() + time.sleep(0.005) + + def persist(self, _record, _intelligence): + persisted.set() + + worker.intelligence = _InterruptibleIntelligence() + 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() + assert not persisted.is_set() + + 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" + assert reader.scalar(select(func.count()).select_from(RiSnapshot)) == 0 + + +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_legacy", _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_architecture_relationships.py b/apps/backend/tests/test_architecture_relationships.py index a22d856e..5b8dacef 100644 --- a/apps/backend/tests/test_architecture_relationships.py +++ b/apps/backend/tests/test_architecture_relationships.py @@ -11,6 +11,8 @@ 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() @@ -20,14 +22,20 @@ def _archive(files: dict[str, bytes]) -> bytes: return buffer.getvalue() -def _upload(auth_client, files: dict[str, bytes]) -> dict: +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 @@ -246,7 +254,12 @@ def test_architecture_reports_resolved_facts_without_module_mapping(auth_client) def test_architecture_without_snapshot_does_not_claim_isolation(auth_client): - repository = _upload(auth_client, {"src/lonely/index.ts": b"export const lonely = 1;\n"}) + # Leave the analysis job queued (worker not drained) so no snapshot is sealed: + # this exercises the genuine "no sealed snapshot" architecture response, which + # durable analysis otherwise reaches only in the window before the worker runs. + 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") diff --git a/apps/backend/tests/test_export_api.py b/apps/backend/tests/test_export_api.py index 9c4e1863..3de588d9 100644 --- a/apps/backend/tests/test_export_api.py +++ b/apps/backend/tests/test_export_api.py @@ -3,6 +3,7 @@ import json import zipfile +from tests.analysis_helpers import run_analysis_jobs from tests.api_assertions import assert_error_response @@ -14,7 +15,7 @@ def _zip_bytes(files: dict[str, str]) -> bytes: return buffer.getvalue() -def _import_sample(auth_client) -> str: +def _import_sample(auth_client, *, analyse: bool = True) -> str: response = auth_client.post( "/repositories/upload", files={ @@ -32,7 +33,11 @@ def _import_sample(auth_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(auth_client, repository_id: str, target: str, fmt: str): @@ -99,6 +104,15 @@ def test_export_review_pdf_starts_with_pdf_header(auth_client): assert base64.b64decode(body["content"])[:5] == b"%PDF-" +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, 409, "conflict_error") + assert error.message == "Engineering review is unavailable until repository analysis completes." + + def test_export_json_supported_for_every_target(auth_client): repository_id = _import_sample(auth_client) diff --git a/apps/backend/tests/test_ingestion_pipeline.py b/apps/backend/tests/test_ingestion_pipeline.py index 0c1b3c0a..d4805716 100644 --- a/apps/backend/tests/test_ingestion_pipeline.py +++ b/apps/backend/tests/test_ingestion_pipeline.py @@ -8,6 +8,9 @@ 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 @@ -66,9 +69,25 @@ def test_zip_upload_persists_repository_and_analysis_completes(auth_client): 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" in (record.repo_metadata or {}) status_response = auth_client.get(f"/analysis/{repository['id']}/status") assert status_response.status_code == 200 @@ -97,6 +116,50 @@ def test_zip_upload_persists_repository_and_analysis_completes(auth_client): assert repositories[0]["status"] == "completed" +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_response = auth_client.get(f"/analysis/{repository_id}/architecture") + assert architecture_response.status_code == 200 + architecture = architecture_response.json() + assert architecture["edges"] == [] + assert architecture["relationshipSnapshotId"] is None + assert architecture["diagnostics"][0]["code"] == "ARCH-REL-NOT-EXTRACTED" + + dependency_response = auth_client.get(f"/analysis/{repository_id}/dependencies") + assert dependency_response.status_code == 200 + dependencies = dependency_response.json() + assert dependencies["nodes"] == [] + assert dependencies["edges"] == [] + assert dependencies["totalDependencies"] == 0 + assert dependencies["manifestCount"] == 0 + + review_response = auth_client.get(f"/analysis/{repository_id}/review") + error = assert_error_response(review_response, 409, "conflict_error") + assert error.message == "Engineering review is unavailable until repository analysis completes." + assert error.details == {"repositoryId": repository_id, "status": "analysing"} + + # 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, @@ -111,6 +174,7 @@ def test_dependency_endpoint_reports_uncomputed_assessments_without_clean_claims 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") @@ -148,6 +212,7 @@ def test_empty_dependency_endpoint_still_reports_uncomputed_assessments(auth_cli 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") @@ -182,6 +247,7 @@ def test_dependency_endpoint_returns_nested_manifest_provenance_and_malformed_di 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["manifestCount"] == 4 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..4ce107a3 --- /dev/null +++ b/apps/backend/tests/test_ingestion_resource_budgets.py @@ -0,0 +1,250 @@ +"""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.main import create_app + from app.models.base import Base + + Base.metadata.create_all(bind=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] + + +# --- 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_migrations.py b/apps/backend/tests/test_migrations.py index 1d815a6f..79ef71ec 100644 --- a/apps/backend/tests/test_migrations.py +++ b/apps/backend/tests/test_migrations.py @@ -68,6 +68,24 @@ def test_migrations_upgrade_and_downgrade_run_clean(tmp_path, monkeypatch): 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", + ] command.downgrade(cfg, "base") assert "repositories" not in inspect(probe_engine).get_table_names() diff --git a/apps/backend/tests/test_openapi_contract.py b/apps/backend/tests/test_openapi_contract.py index 47fb5eea..dfc05bd4 100644 --- a/apps/backend/tests/test_openapi_contract.py +++ b/apps/backend/tests/test_openapi_contract.py @@ -46,16 +46,17 @@ ("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}/dependencies"): {200, 401, 404, 429, 500}, - ("GET", "/analysis/{repository_id}/review"): {200, 401, 404, 429, 500}, + ("GET", "/analysis/{repository_id}/review"): {200, 401, 404, 409, 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}, ("POST", "/ai/stream"): {200, 401, 404, 422, 429, 502, 500}, ("POST", "/documentation/generate"): {200, 401, 404, 422, 429, 500}, - ("POST", "/export"): {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, 500}, diff --git a/apps/backend/tests/test_rate_limit.py b/apps/backend/tests/test_rate_limit.py index 3e716cf3..46133421 100644 --- a/apps/backend/tests/test_rate_limit.py +++ b/apps/backend/tests/test_rate_limit.py @@ -265,7 +265,7 @@ def test_export_endpoint_is_charged_against_the_heavy_budget(limited_client): to the (looser) default class this would never hit 429.""" repository_id = _import_sample(limited_client) # 1st heavy hit - payload = {"repositoryId": repository_id, "target": "review", "format": "json"} + payload = {"repositoryId": repository_id, "target": "dependencies", "format": "json"} assert limited_client.post("/export", json=payload).status_code == 200 # 2nd heavy hit blocked = limited_client.post("/export", json=payload) # 3rd heavy hit diff --git a/apps/backend/tests/test_repository_intelligence.py b/apps/backend/tests/test_repository_intelligence.py index 208e8749..8b26349a 100644 --- a/apps/backend/tests/test_repository_intelligence.py +++ b/apps/backend/tests/test_repository_intelligence.py @@ -1,6 +1,8 @@ from datetime import UTC, datetime from pathlib import Path +import pytest + from app.analysis.architecture import ArchitectureAnalyzer from app.graph.dependency_graph import DependencyGraphBuilder from app.intelligence.engine import RepositoryIntelligenceEngine @@ -80,6 +82,40 @@ def test_repository_intelligence_detects_discovery_and_source_code(tmp_path: Pat assert any(relationship.type == "depends_on" for relationship in intelligence.graph.relationships) +def test_repository_intelligence_checks_cancellation_between_files( + tmp_path: Path, + monkeypatch, +): + _sample_repository(tmp_path) + tree, metadata, total_size = RepositoryParser().parse(tmp_path) + engine = RepositoryIntelligenceEngine() + processed: list[str] = [] + original_file_intelligence = engine._file_intelligence + + def _file_intelligence(root, node): + processed.append(node.path) + return original_file_intelligence(root, node) + + def _check_cancelled(): + if processed: + raise RuntimeError("cancelled") + + monkeypatch.setattr(engine, "_file_intelligence", _file_intelligence) + + with pytest.raises(RuntimeError, match="cancelled"): + engine.build( + "repo-1", + "sample", + tmp_path, + tree, + metadata, + total_size, + check_cancelled=_check_cancelled, + ) + + assert len(processed) == 1 + + def test_repository_intelligence_is_serializable_and_persisted(tmp_path: Path): _sample_repository(tmp_path) _, _, _, intelligence = _build_intelligence(tmp_path) diff --git a/apps/backend/tests/test_repository_parser.py b/apps/backend/tests/test_repository_parser.py index 8a1d483d..289192f1 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 def test_repository_parser_detects_basic_typescript_project(tmp_path: Path): @@ -17,3 +20,72 @@ 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 + + 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"] diff --git a/apps/backend/tests/test_system.py b/apps/backend/tests/test_system.py index 2977ee24..7ac3e0ca 100644 --- a/apps/backend/tests/test_system.py +++ b/apps/backend/tests/test_system.py @@ -178,6 +178,19 @@ def test_settings_rejects_invalid_log_format(): Settings(log_format="pretty") +def test_production_analysis_worker_ids_are_unique_with_the_same_pid(monkeypatch): + import app.main as main_module + + monkeypatch.setattr(main_module, "getpid", lambda: 42) + + first = main_module._analysis_worker_id() + second = main_module._analysis_worker_id() + + 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/frontend/src/app/pages/AnalysisPipelinePage.test.tsx b/apps/frontend/src/app/pages/AnalysisPipelinePage.test.tsx new file mode 100644 index 00000000..59c7012f --- /dev/null +++ b/apps/frontend/src/app/pages/AnalysisPipelinePage.test.tsx @@ -0,0 +1,81 @@ +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, + dataSource: 'real' 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, + empty: false, + success: false, + source: 'real' 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(); + }); +}); diff --git a/apps/frontend/src/app/pages/AnalysisPipelinePage.tsx b/apps/frontend/src/app/pages/AnalysisPipelinePage.tsx index d224502c..501ae9c5 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 } 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 (
@@ -140,14 +135,53 @@ export function AnalysisPipelinePage() { )} -
+ {analysis.cancelled && ( + + +
+

Analysis cancelled

+

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

+ +
+
+ )} + +
+ {analysis.canCancel && ( + + )}
); diff --git a/apps/frontend/src/app/pages/DashboardPage.tsx b/apps/frontend/src/app/pages/DashboardPage.tsx index 3b844776..84229978 100644 --- a/apps/frontend/src/app/pages/DashboardPage.tsx +++ b/apps/frontend/src/app/pages/DashboardPage.tsx @@ -60,7 +60,7 @@ 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" diff --git a/apps/frontend/src/app/pages/RepositoriesPage.tsx b/apps/frontend/src/app/pages/RepositoriesPage.tsx index b7e1e796..6bea4cbd 100644 --- a/apps/frontend/src/app/pages/RepositoriesPage.tsx +++ b/apps/frontend/src/app/pages/RepositoriesPage.tsx @@ -99,7 +99,7 @@ export function RepositoriesPage() { + +
+ setAuthPanelOpen(false)} /> ); } diff --git a/apps/frontend/src/app/pages/RepositoryDetailPage.tsx b/apps/frontend/src/app/pages/RepositoryDetailPage.tsx index 60aecb01..8cdf62c9 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, @@ -29,9 +31,12 @@ 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]); + if (!repo) { return (
@@ -182,7 +187,7 @@ export function RepositoryDetailPage() { {activeTab === 'Explorer' && ( - + )} 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 && ( + <> +