diff --git a/README.md b/README.md index b9c2cff..c8f394e 100644 --- a/README.md +++ b/README.md @@ -248,6 +248,8 @@ stall the server. `statement_timeout` bounds the database, not the rescan. | `semantic_search` | `query`, `limit=50`, `branch=None` | ranked chunks with `rrf_score` | | `list_repos` | — | indexed repos with per-branch last-indexed metadata | | `get_file` | `repo`, `path`, `branch=None` | full file content, or `found: false` | +| `find_references` | `symbol`, `limit=200`, `branch=None` | ranked candidate reference (call) sites with enclosing symbols | +| `list_imports` | `repo=None`, `target=None`, `direction=imports`, `branch=None`, `limit=200` | import edge sites; `repo` required for `imports`, `target` required for `imported_by` | Every tool returns a JSON string. `limit` is clamped server-side: a non-positive value falls back to 200, and anything above 1000 is capped there. @@ -279,6 +281,23 @@ the embedder. The target Lakebase project's managed preload including `lakebase_vector,lakebase_text` is a stated project assumption — see [`docs/runbooks/semantic-enablement.md`](docs/runbooks/semantic-enablement.md). +`find_references` and `list_imports` serve the knowledge-graph reference edges. They are +**candidate-set, not compiler-precise** (grep-not-LSP): a call site is name-resolved to the +`symbols` definitions its callee name could plausibly mean, ranked (`same_repo`/`same_file`/ +`kind_match`) but never collapsed to a single binding — `resolution` is `unique` (1 +candidate), `ambiguous` (2+), or `unresolved` (0), and the true pre-cap `candidate_count` +survives capping. `list_imports` has two directions: `imports` enumerates a repo's import +sites (`repo` required) and `imported_by` finds who imports a given dotted `target` +corpus-wide (`target` required); invalid input comes back as a structured payload +(`unsupported_direction`/`missing_repo`/`missing_target` with a `reason`), never an error, and +import edges keep the full dotted path (so most read `unresolved` = external, by design). + +**"What tests cover symbol X"** needs no dedicated tool: call `find_references(X)` and +client-side filter `sites` by your test-path convention (e.g. `file` starting with `tests/`); +each surviving site's `enclosing_symbol` names the covering test. Two follow-ups are +deliberately **deferred** past #87: repo/kind-scoped `find_references` filters, and per-file +forward imports ("what does file F import"). + Two HTTP routes sit alongside the MCP mount: `GET /health` is liveness and never touches the database, and `GET /ready` runs `SELECT 1 FROM repos LIMIT 1` so that a role holding connect-but-not-select fails as 503 instead of shipping green. @@ -300,6 +319,19 @@ open the app URL in a browser. See rebuilding the frontend (`make webui-build`), and the wheel-packaging mechanism that lets webui import `app.*` without duplicating it. +The **Graph** tab exposes the same knowledge-graph reference edges as the MCP +`find_references`/`list_imports` tools, via `GET /api/references` and `GET /api/imports` — +thin passthroughs over the SAME `app/service.py` builders the MCP tools wrap (no duplicated +graph logic; see [`docs/runbooks/webui.md`](docs/runbooks/webui.md) for the parity contract), +presented as ranked candidate sets rather than raw rows. The edge model behind both surfaces: +raw `call`/`import` edges are recorded at index time without resolving them, and each query +resolves a name against `symbols` on the fly (query-time candidate-set resolution, not a +build-time link step) — because this is grep-not-LSP name matching, a name can't always be +collapsed to one binding, so results come back as ranked candidate sets (`unique`/`ambiguous`/ +`unresolved`) instead of a single "go to definition" answer. See +[`docs/runbooks/reference-edges.md`](docs/runbooks/reference-edges.md) for the edge schema and +resolver details. + ## Deploy `make deploy` (see [Quick start](#quick-start)) runs `scripts/deploy.sh full`, which @@ -596,6 +628,9 @@ Run the server locally with `make run` (binds `DATABRICKS_APP_PORT`, else 8000). semantic search (default-on): the preload assumption, opt-out, embeddings, memory notes - [`docs/runbooks/indexing-parallelism.md`](docs/runbooks/indexing-parallelism.md) — parallel indexing: worker sizing, skip-if-unchanged, compare-and-set stamping +- [`docs/runbooks/reference-edges.md`](docs/runbooks/reference-edges.md) — the raw + call/import edge schema (`reference_edges`, migration `0005`): what it stores, the + no-symbol-FK design, and the grant-coupling this migration introduces - [`docs/runbooks/ci-lakebase.md`](docs/runbooks/ci-lakebase.md) — the integration CI gate: ephemeral Lakebase branches, prerequisites - [`docs/runbooks/webui.md`](docs/runbooks/webui.md) — the web UI app: auth, grants, diff --git a/app/alembic/AGENTS.md b/app/alembic/AGENTS.md index d15cd8c..0d6ecc5 100644 --- a/app/alembic/AGENTS.md +++ b/app/alembic/AGENTS.md @@ -4,7 +4,7 @@ # app/alembic ## Purpose -The Alembic migration environment and the single linear core revision chain (0001 → 0004) for the code-search schema on Lakebase Postgres. `env.py` resolves its connection in strict priority order: an injected connection from `scripts/migrate.py` (which owns the Lakebase OAuth engine) wins; else `LAKEBASE_ENDPOINT`/`PGHOST` builds one via `create_db_engine()` (the `make migration` autogenerate path against a disposable Lakebase branch); else it raises — there is no implicit default. Autogenerate diffs against `app.db.models.Base.metadata`, with the semantic `chunks` surface filtered out entirely. `alembic.ini` at the repo root points `script_location` here. +The Alembic migration environment and the single linear core revision chain (0001 → 0005) for the code-search schema on Lakebase Postgres. `env.py` resolves its connection in strict priority order: an injected connection from `scripts/migrate.py` (which owns the Lakebase OAuth engine) wins; else `LAKEBASE_ENDPOINT`/`PGHOST` builds one via `create_db_engine()` (the `make migration` autogenerate path against a disposable Lakebase branch); else it raises — there is no implicit default. Autogenerate diffs against `app.db.models.Base.metadata`, with the semantic `chunks` surface filtered out entirely. `alembic.ini` at the repo root points `script_location` here. ## Key Files | File | Description | @@ -20,11 +20,12 @@ The Alembic migration environment and the single linear core revision chain (000 | `0002_index_semantics_version.py` | Adds + backfills `repos.index_semantics_version`. Backfill value is FROZEN as `_BACKFILL_VERSION = 1` — deliberately never imports the live `INDEX_SEMANTICS_VERSION`. Backfills by cadence (rows touched in the last 48h); untouched rows stay NULL → re-index once | | `0003_multi_branch.py` | Multi-branch content dedup: adds `files.content_sha` (backfilled in-DB via `pgcrypto` `digest(...,'sha256')`, proven byte-identical to `indexer.hashing.content_sha`) and GIN-indexed `files.branches`; swaps `uq_files_repo_id_path` → `uq_files_repo_path_sha`; creates `repo_branches` seeded from the legacy `repos` stamp. Downgrade guards FIRST: refuses if any path has multiple content versions | | `0004_semantic_chunks.py` | The semantic surface in the core chain (supersedes the retired gated `0002sem`/`versions_semantic`): extensions `lakebase_tokenizer` → `lakebase_vector` → `lakebase_text` with `CASCADE` (load-bearing: `lakebase_vector` declares a dependency on base `vector`), the `chunks` table (embedding dim from `app.config.SEMANTIC_EMBEDDING_DIM`, generated `ts` tsvector, `uq_chunks_file_id_chunk_index` — also the write-path index for per-file DELETE and CASCADE), `ix_chunks_embedding_ann` (`lakebase_ann` with explicit non-default `vector_cosine_ops`; rejects hnsw-style WITH params) and `ix_chunks_ts_bm25`. Idempotency guard: if `to_regclass('chunks')` exists (old gated path), only add `start_line`/`end_line` and drop the orphaned `alembic_version_semantic` | +| `0005_reference_edges.py` | Adds `reference_edges` (epic #82): raw unresolved call/import edges, `edge_kind IN ('call','import')` CHECK, FKs to `repos`/`files` only (both `ON DELETE CASCADE`) — deliberately NO FK to `symbols` (query-time name-join resolution instead). Autogenerate-shaped (`op.create_table`/`op.create_index`, no raw SQL, no new extension — `pg_trgm` already exists since 0001). Four indexes: btree `target_name`, GIN trgm `target_name`, btree `file_id` (write-path/cascade), btree `(repo_id, edge_kind)`. Grant-coupled like 0003/0004 before it — see `docs/runbooks/reference-edges.md` | ## For AI Agents ### Working In This Directory -- **Chain ordering is strictly linear**: `0001 <- 0002 <- 0003 <- 0004`. A new revision sets `down_revision` to the current head; never branch the chain. +- **Chain ordering is strictly linear**: `0001 <- 0002 <- 0003 <- 0004 <- 0005`. A new revision sets `down_revision` to the current head; never branch the chain. - **Migrations are historical facts.** They must never import mutable app constants — `0002` freezes its backfill value locally, and `models.py` explicitly forbids migrations importing `INDEX_SEMANTICS_VERSION`. The one sanctioned exception is `0004`'s use of `SEMANTIC_EMBEDDING_DIM`, which exists precisely so DDL and the `app/db/semantic.py` table can never drift. - **Keep the `chunks` blindness intact.** `include_object` in `env.py` plus `chunks` living outside `Base.metadata` are two halves of one protection; removing either makes `make migration` emit destructive drops. - **Extension-before-index ordering** is load-bearing in 0001 and 0004; downgrades never drop extensions (database-wide, potentially shared, and the 0004 preload prerequisite is irreversible). @@ -33,8 +34,8 @@ The Alembic migration environment and the single linear core revision chain (000 - Run migrations via `make migrate` (`scripts/migrate.py`, injected connection, optional `ARGS=--apply-grants`); autogenerate via `make migration MSG="..."` against a disposable Lakebase branch (`scripts/ci_branch.py up`) — never against production. `env.py` deliberately raises rather than guessing a target. ### Testing Requirements -- `make test`: `tests/unit/test_migration_source.py` / `test_migration_source_semantic.py` (source-level revision-chain and no-app-import checks), `test_semantics_version_tripwire.py`. -- `make test-integration`: `tests/integration/test_migrations.py` (upgrade/downgrade against real Postgres; models ↔ chain parity), `test_content_sha_parity.py` (pgcrypto digest ≡ Python `content_sha`). +- `make test`: `tests/unit/test_migration_source.py` / `test_migration_source_semantic.py` (source-level revision-chain and no-app-import checks, including 0005's no-symbol-FK check), `test_semantics_version_tripwire.py`, `test_reference_edge_model.py` (ORM-metadata tripwires for `reference_edges`). +- `make test-integration`: `tests/integration/test_migrations.py` (upgrade/downgrade against real Postgres; models ↔ chain parity; the `reference_edges` shape/cascade/ADP/EXPLAIN tests run on stock Postgres too via `migrated_edges_capable`), `test_content_sha_parity.py` (pgcrypto digest ≡ Python `content_sha`). ### Common Patterns - Raw `op.execute()` for anything SQLAlchemy can't declare portably (extensions, generated columns, lakebase index access methods, backfill UPDATEs); `op.create_table`/`op.create_index` for the declarable rest. diff --git a/app/alembic/versions/0005_reference_edges.py b/app/alembic/versions/0005_reference_edges.py new file mode 100644 index 0000000..0d0b756 --- /dev/null +++ b/app/alembic/versions/0005_reference_edges.py @@ -0,0 +1,101 @@ +"""reference edges (raw, unresolved call/import edges) + +Revision ID: 0005 +Revises: 0004 +Create Date: 2026-07-23 00:00:00.000000 + +Adds ``reference_edges``: one row per raw (unresolved) call/import site found by +the extractor (epic #82). Deliberately NO FK to ``symbols`` -- symbol ids churn +on every per-file delete-and-reinsert, and an FK would couple the two rewrite +orders inside the indexing transaction for no query benefit. Resolution from +``target_name`` to a concrete symbol happens at query time by name-join (a +later child of #82), not here. The enclosing symbol is denormalized onto the +row instead (``enclosing_*``, all nullable -- NULL means module/top-level +scope). No ``branches`` column: branch membership rides ``files.branches`` at +query time, exactly as ``symbols`` does. + +``pg_trgm`` already exists (created by 0001, database-wide) so no +``CREATE EXTENSION`` is needed here; extension-before-index ordering is +satisfied by the chain itself. + +**Grant coupling (not schema-only for an already-deployed target):** the +schema-wide grant builders in ``app/db/grants.py`` (``GRANT ... ON ALL +TABLES IN SCHEMA`` + ``ALTER DEFAULT PRIVILEGES``) cover this new table +automatically ONLY when the same identity that ran the original grants also +runs this migration (Postgres ADP binds to the executing role). A different +identity running a schema-only ``make migrate`` needs an explicit re-grant. +See ``docs/runbooks/reference-edges.md`` for the verification query and the +re-grant command. +""" + +from __future__ import annotations + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "0005" +down_revision: str | None = "0004" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table( + "reference_edges", + sa.Column("id", sa.BigInteger(), nullable=False), + sa.Column("repo_id", sa.Integer(), nullable=False), + sa.Column("file_id", sa.Integer(), nullable=False), + sa.Column("edge_kind", sa.Text(), nullable=False), + sa.Column("target_name", sa.Text(), nullable=False), + sa.Column("line", sa.Integer(), nullable=False), + sa.Column("enclosing_name", sa.Text(), nullable=True), + sa.Column("enclosing_kind", sa.Text(), nullable=True), + sa.Column("enclosing_start_line", sa.Integer(), nullable=True), + sa.Column("enclosing_end_line", sa.Integer(), nullable=True), + sa.CheckConstraint("edge_kind IN ('call', 'import')", name="ck_reference_edges_edge_kind"), + sa.ForeignKeyConstraint(["repo_id"], ["repos.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["file_id"], ["files.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + "ix_reference_edges_target_name", + "reference_edges", + ["target_name"], + unique=False, + ) + op.create_index( + "ix_reference_edges_target_trgm", + "reference_edges", + ["target_name"], + unique=False, + postgresql_using="gin", + postgresql_ops={"target_name": "gin_trgm_ops"}, + ) + # Load-bearing for write performance, not just integrity: Postgres does NOT + # auto-index a foreign key, and file_id is the hot lookup for both the + # per-file delete-and-reinsert writer and the ON DELETE CASCADE fired by + # store.py's mark-and-sweep -- same rationale as ix_symbols' analog on + # symbols.file_id (there via the implicit FK) and uq_chunks_file_id_chunk_index. + op.create_index( + "ix_reference_edges_file_id", + "reference_edges", + ["file_id"], + unique=False, + ) + op.create_index( + "ix_reference_edges_repo_kind", + "reference_edges", + ["repo_id", "edge_kind"], + unique=False, + ) + + +def downgrade() -> None: + op.drop_index("ix_reference_edges_repo_kind", table_name="reference_edges") + op.drop_index("ix_reference_edges_file_id", table_name="reference_edges") + op.drop_index("ix_reference_edges_target_trgm", table_name="reference_edges") + op.drop_index("ix_reference_edges_target_name", table_name="reference_edges") + op.drop_table("reference_edges") diff --git a/app/db/AGENTS.md b/app/db/AGENTS.md index 9d46ebf..568278c 100644 --- a/app/db/AGENTS.md +++ b/app/db/AGENTS.md @@ -4,13 +4,13 @@ # app/db ## Purpose -Database connectivity and schema truth for the code-search corpus. `client.py` is the one and only place that knows how to connect to Lakebase (Autoscaling API, per-connection OAuth token injection) or to plain local Postgres (CI/tests via `PGHOST`). `models.py` holds the SQLAlchemy 2.0 declarative models for the durable core (`repos` / `files` / `symbols` / `repo_branches`) whose `Base.metadata` drives Alembic autogenerate; `semantic.py` declares the `chunks` table in a deliberately separate `MetaData` so autogenerate never sees it; `grants.py` builds least-privilege GRANT SQL strings executed by `scripts/migrate.py`. +Database connectivity and schema truth for the code-search corpus. `client.py` is the one and only place that knows how to connect to Lakebase (Autoscaling API, per-connection OAuth token injection) or to plain local Postgres (CI/tests via `PGHOST`). `models.py` holds the SQLAlchemy 2.0 declarative models for the durable core (`repos` / `files` / `symbols` / `repo_branches` / `reference_edges`) whose `Base.metadata` drives Alembic autogenerate; `semantic.py` declares the `chunks` table in a deliberately separate `MetaData` so autogenerate never sees it; `grants.py` builds least-privilege GRANT SQL strings executed by `scripts/migrate.py`. ## Key Files | File | Description | |------|-------------| | `client.py` | `create_db_engine()`: Lakebase-endpoint-wins-over-`PGHOST` dual-mode selection; Lakebase mode closes one `WorkspaceClient` over a `do_connect` handler that mints a fresh OAuth token as the password on every physical connect (never logged); server defaults `pool_size=5`, `pool_recycle=2700` (45 min, under the ~1h token TTL), `pool_pre_ping=True`; SDK import is lazy so the local path never touches it | -| `models.py` | ORM models + `INDEX_SEMANTICS_VERSION` (currently 2; bump on any indexing-meaning change — CI tripwires it). `File` is content-deduped per (repo_id, path, content_sha) with a `branches ARRAY(Text)` membership column; `RepoBranch` is the authoritative per-(repo, branch) CAS stamp; `repos`' own stamp columns and `files.commit` are deprecated/ambiguous — never add readers. Declares the trgm + branches GIN indexes so autogenerate can't drift | +| `models.py` | ORM models + `INDEX_SEMANTICS_VERSION` (currently 4; bump on any indexing-meaning change — CI tripwires it). `File` is content-deduped per (repo_id, path, content_sha) with a `branches ARRAY(Text)` membership column; `RepoBranch` is the authoritative per-(repo, branch) CAS stamp; `repos`' own stamp columns and `files.commit` are deprecated/ambiguous — never add readers. `ReferenceEdge` (0005, epic #82) is a raw unresolved call/import edge, deliberately with NO FK to `symbols` (resolution happens at query time by name-join in a later child); FKs to `repos`/`files` only, both `ON DELETE CASCADE`. Declares the trgm + branches GIN indexes so autogenerate can't drift | | `semantic.py` | Standalone Core `Table` for `chunks` (BigInteger PK, `Vector(SEMANTIC_EMBEDDING_DIM)` embedding, generated `ts` tsvector, nullable `start_line`/`end_line`) in its own `semantic_metadata` — a typed description only; the real DDL is owned by migration `0004` | | `grants.py` | Pure SQL-string builders `build_app_grants` (read-only) / `build_job_grants` (CRUD + sequences, no DDL); identifiers validated against `^[A-Za-z0-9_-]+$` (1..63 chars) then psycopg-quoted. Execution lives in `scripts/migrate.py` | | `__init__.py` | Re-exports `Base`, `File`, `Repo`, `Symbol`, `create_db_engine` | @@ -23,6 +23,7 @@ Database connectivity and schema truth for the code-search corpus. `client.py` i - **Do not add `chunks` to `Base.metadata`.** It lives in `semantic.py`'s own `MetaData`; pairing with the `include_object` filter in `app/alembic/env.py`, this is what stops autogenerate emitting `drop_table('chunks')` or spurious diffs of the hand-written vector/tsvector DDL. - **`INDEX_SEMANTICS_VERSION` must never be imported by a migration** (migrations freeze their own constants — see `0002`). Bump it whenever `indexer/symbols.py`, `indexer/parse.py` chunking, or `indexer/languages.py` extraction changes meaning. - `RepoBranch.last_indexed_commit` is the ONLY commit truth-source; `files.commit` is write-only and ambiguous under multi-branch dedup. +- **`reference_edges` must never gain a FK to `symbols`** (epic #82 rule) — resolution from `target_name` to a concrete symbol happens at query time by name-join, not via a stored FK. `tests/unit/test_reference_edge_model.py` and `tests/unit/test_migration_source.py::test_0005_no_symbol_fk` are standing tripwires; do not weaken them. - The server's pool default (5) is paired with `app/main.py`'s `CapacityLimiter(5)`; the indexer passes `pool_size=` explicitly from its worker count. Changing one side without the other breaks the oversubscription guarantee. - Grant changes are deploy-coupled: adding a privilege needs a `deploy.sh --apply-grants` re-run, not just a schema migrate. diff --git a/app/db/__init__.py b/app/db/__init__.py index 7bb1b80..d03f760 100644 --- a/app/db/__init__.py +++ b/app/db/__init__.py @@ -1,6 +1,6 @@ """Database connectivity and ORM models for the code search service.""" from app.db.client import create_db_engine -from app.db.models import Base, File, Repo, Symbol +from app.db.models import Base, File, ReferenceEdge, Repo, Symbol -__all__ = ["Base", "File", "Repo", "Symbol", "create_db_engine"] +__all__ = ["Base", "File", "ReferenceEdge", "Repo", "Symbol", "create_db_engine"] diff --git a/app/db/models.py b/app/db/models.py index d07910c..45c7e1e 100644 --- a/app/db/models.py +++ b/app/db/models.py @@ -1,7 +1,7 @@ """SQLAlchemy 2.0 models for the durable code search core. -Covers ``repos`` / ``files`` / ``symbols`` / ``repo_branches``. No ``chunks`` / -``VECTOR`` / ``tsvector`` (a separate version table). ``Base.metadata`` is +Covers ``repos`` / ``files`` / ``symbols`` / ``repo_branches`` / ``reference_edges``. +No ``chunks`` / ``VECTOR`` / ``tsvector`` (a separate version table). ``Base.metadata`` is the authoritative desired-state: it also declares the pg_trgm and ``files.branches`` GIN indexes so Alembic autogenerate emits them and there is no future drift. The 0001 migration owns the single ``CREATE EXTENSION IF NOT @@ -13,17 +13,34 @@ from datetime import datetime -from sqlalchemy import DateTime, ForeignKey, Index, Integer, Text, UniqueConstraint +from sqlalchemy import ( + BigInteger, + CheckConstraint, + DateTime, + ForeignKey, + Index, + Integer, + Text, + UniqueConstraint, +) from sqlalchemy.dialects.postgresql import ARRAY from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship -INDEX_SEMANTICS_VERSION = 2 +INDEX_SEMANTICS_VERSION = 4 """Version of the indexing semantics the current code produces. 2: semantic search default-on -- every already-indexed branch must re-index once so ``chunks`` backfills (the skip seam compares ``(head_sha, INDEX_SEMANTICS_VERSION)``, and without a bump a repo already at HEAD would skip forever and never get chunks). +3: reference edges -- every already-indexed branch must re-index once so +``reference_edges`` backfills; without a bump a branch at HEAD would skip forever +and never get edges. + +4: reference edges for JS/TS/TSX/Go/Java/Rust -- every already-indexed branch +must re-index once so multi-language ``reference_edges`` backfill; without a +bump a branch at HEAD would skip forever and never get non-Python edges. + Bump this whenever the *meaning* of what gets written changes: any change to ``indexer/symbols.py``, to ``indexer/parse.py``'s chunking, or to ``indexer/languages.py``'s extraction contract. A bump forces every repo to @@ -98,6 +115,9 @@ class File(Base): symbols: Mapped[list[Symbol]] = relationship( back_populates="file", cascade="all, delete-orphan" ) + reference_edges: Mapped[list[ReferenceEdge]] = relationship( + back_populates="file", cascade="all, delete-orphan" + ) class Symbol(Base): @@ -122,6 +142,45 @@ class Symbol(Base): file: Mapped[File] = relationship(back_populates="symbols") +class ReferenceEdge(Base): + """Raw (unresolved) call/import edge for one file content-version. + + Deliberately NO FK to symbols: symbol ids churn on every per-file + delete-and-reinsert, and an FK would couple the two rewrite orders inside + the indexing transaction for no query benefit (epic #82 rule: resolution + happens at query time by name-join). Enclosing symbol is denormalized. + NULL enclosing_* means module/top-level scope. Branch scoping rides + files.branches at query time, exactly as symbols does -- no branch column. + """ + + __tablename__ = "reference_edges" + __table_args__ = ( + CheckConstraint("edge_kind IN ('call', 'import')", name="ck_reference_edges_edge_kind"), + Index("ix_reference_edges_target_name", "target_name"), + Index( + "ix_reference_edges_target_trgm", + "target_name", + postgresql_using="gin", + postgresql_ops={"target_name": "gin_trgm_ops"}, + ), + Index("ix_reference_edges_file_id", "file_id"), + Index("ix_reference_edges_repo_kind", "repo_id", "edge_kind"), + ) + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + repo_id: Mapped[int] = mapped_column(ForeignKey("repos.id", ondelete="CASCADE")) + file_id: Mapped[int] = mapped_column(ForeignKey("files.id", ondelete="CASCADE")) + edge_kind: Mapped[str] = mapped_column(Text) + target_name: Mapped[str] = mapped_column(Text) + line: Mapped[int] = mapped_column(Integer) + enclosing_name: Mapped[str | None] = mapped_column(Text) + enclosing_kind: Mapped[str | None] = mapped_column(Text) + enclosing_start_line: Mapped[int | None] = mapped_column(Integer) + enclosing_end_line: Mapped[int | None] = mapped_column(Integer) + + file: Mapped[File] = relationship(back_populates="reference_edges") + + class RepoBranch(Base): """Per-(repo, branch) index registry: the authoritative CAS stamp (0003+). diff --git a/app/main.py b/app/main.py index 9d9b2a7..3396229 100644 --- a/app/main.py +++ b/app/main.py @@ -118,6 +118,14 @@ def _signals(payload: dict[str, Any]) -> dict[str, Any]: # indistinguishable in the logs from a genuine no-match. "unsupported_filter": payload.get("unsupported_filter"), "nothing_to_embed": payload.get("nothing_to_embed"), + # Reference-tool signals (list_imports/find_references). A repo typo otherwise reads + # log-identically to an empty repo, and a misdirected list_imports call returns empty + # and is otherwise indistinguishable from a genuine no-match -- the unsupported_filter + # precedent exactly. All None-safe on payloads that do not carry them. + "repo_known": payload.get("repo_known"), + "unsupported_direction": payload.get("unsupported_direction"), + "missing_repo": payload.get("missing_repo"), + "missing_target": payload.get("missing_target"), } @@ -159,6 +167,8 @@ async def _dispatch(name: str, build: Callable[[], dict[str, Any]]) -> str: _search_code_payload = service.search_code_payload _list_repos_payload = service.list_repos_payload _get_file_payload = service.get_file_payload +_find_references_payload = service.find_references_payload +_list_imports_payload = service.list_imports_payload def _append_branch_atom(query: str, branch: str) -> str: @@ -321,6 +331,92 @@ async def get_file(repo: str, path: str, ctx: Context, branch: str | None = None ) +async def find_references( + symbol: str, ctx: Context, limit: int = 200, branch: str | None = None +) -> str: + """Find candidate call sites of ``symbol`` corpus-wide, each with its ranked definitions. + + CANDIDATE-SET semantics, NOT compiler-precise references: results are name-resolved over + raw ``call`` edges (grep-not-LSP). Each site is a place that calls something NAMED + ``symbol``; its ``candidates`` are the ``symbols`` definitions that name could plausibly + mean, ranked -- never a single authoritative binding. Ambiguity is preserved in full, never + collapsed to one answer. + + ``symbol`` is matched exactly against the callee's rightmost identifier (``a.b.f()`` and + ``self.f()`` both match ``f``). ``branch`` scopes BOTH the call site's file and each + candidate's file (exact ``branches`` membership); omitted, both fall back to each repo's + default branch. ``limit`` caps the number of call sites scanned (clamped to a server + maximum). + + Payload: ``symbol``, ``branch``, ``site_count``, ``sites``, a top-level ``resolution_summary`` + histogram (``{"unique":N,"ambiguous":N,"unresolved":N}``), ``truncated``, and + ``query_too_broad``. Each entry in ``sites`` carries ``repo``, ``file``, ``line``, + ``edge_kind`` (``"call"``), ``target_name``, ``enclosing_symbol`` (``{"name","kind"}`` of the + function/class the call sits in, or ``null`` for module scope), ``resolution`` + (``"unique"``=1 candidate, ``"ambiguous"``=2+, ``"unresolved"``=0), ``candidate_count`` (the + TRUE pre-cap count -- correct even when the candidate list is capped), ``candidates_truncated``, + and a ranked ``candidates`` list. Each candidate carries ``repo``, ``file``, ``line``, + ``name``, ``kind``, and the ranking signals ``same_repo`` / ``same_file`` / ``kind_match``. + + Composition -- "what tests cover symbol X": call ``find_references(X)`` and client-side + filter ``sites`` by your test-path convention (e.g. ``file`` starts with ``"tests/"``); each + surviving site's ``enclosing_symbol`` names the covering test. No separate tool is needed. + """ + lc = ctx.request_context.lifespan_context + engine, cfg = lc["engine"], lc["config"] + limit = _clamp_limit(limit, cfg) + return await _dispatch( + "find_references", + lambda: _find_references_payload(engine, cfg, symbol, limit, branch), + ) + + +async def list_imports( + ctx: Context, + repo: str | None = None, + target: str | None = None, + direction: str = "imports", + branch: str | None = None, + limit: int = 200, +) -> str: + """Enumerate ``import`` edge sites in one of two directions (candidate-set semantics). + + ``direction="imports"`` (default): list the import sites IN a repo -- **``repo`` is + REQUIRED** (a corpus-wide import listing is not index-served and is out of scope). An + optional ``target`` narrows to sites importing that exact dotted path. + ``direction="imported_by"``: find who imports a module -- **``target`` is REQUIRED** (the + exact dotted path, e.g. ``"os.path"``), searched corpus-wide; an optional ``repo`` narrows + to importers within that one repo. + + Invalid input returns a STRUCTURED payload, never an error: an unknown ``direction`` sets + ``unsupported_direction`` (echoing the value); ``imports`` with no ``repo`` sets + ``missing_repo``; ``imported_by`` with no ``target`` sets ``missing_target``. Each also + carries a remedy ``reason`` and an empty result envelope. + + Import edges target the FULL dotted path as written (no last-segment split), so most point + at external/stdlib modules and resolve ``"unresolved"`` -- that is expected and correct, not + an error. ``repo_known=False`` is a structured "no such repo" miss (distinct from a known + repo with zero import sites: ``repo_known=True`` with empty ``sites``); it is always ``True`` + when no ``repo`` scope was requested. + + Payload: ``kind`` (``"imports"``), ``direction``, ``repo``, ``target``, ``repo_known``, + ``branch``, ``site_count``, ``sites``, ``resolution_summary``, ``truncated``, and + ``query_too_broad``. Each ``sites`` entry has the same shape as ``find_references`` (``repo``, + ``file``, ``line``, ``edge_kind`` = ``"import"``, ``target_name``, ``enclosing_symbol`` | + ``null`` for module scope, ``resolution``, ``candidate_count``, ``candidates_truncated``, + ranked ``candidates``). ``limit`` caps the sites scanned (clamped to a server maximum). + """ + lc = ctx.request_context.lifespan_context + engine, cfg = lc["engine"], lc["config"] + limit = _clamp_limit(limit, cfg) + return await _dispatch( + "list_imports", + lambda: _list_imports_payload( + engine, cfg, repo, limit, branch, target=target, direction=direction + ), + ) + + # ------------------------------------------------------------------------- health / ready @@ -369,6 +465,8 @@ def create_app() -> Starlette: mcp.tool()(semantic_search) mcp.tool()(list_repos) mcp.tool()(get_file) + mcp.tool()(find_references) + mcp.tool()(list_imports) mcp.custom_route("/health", methods=["GET"])(health) mcp.custom_route("/ready", methods=["GET"])(ready) return mcp.streamable_http_app() diff --git a/app/search/references.py b/app/search/references.py new file mode 100644 index 0000000..e1cab6d --- /dev/null +++ b/app/search/references.py @@ -0,0 +1,427 @@ +"""Reference resolution: query-time candidate-set resolver over raw ``reference_edges``. + +The serve-side companion to :mod:`app.search.symbols` for the knowledge-graph epic (#82). +``reference_edges`` (0005, #83/#84/#85) stores raw, unresolved call/import sites -- deliberately +no FK to ``symbols`` (symbol ids churn on every per-file reindex). This module resolves a raw +edge's ``target_name`` to the ``symbols`` rows it could plausibly mean, at query time, by name. + +Design -- two queries, deliberately NOT one joined query (mirrors ``symbols.py``): + +1. Edge sites: ``reference_edges JOIN files JOIN repos``, bounded by ``row_limit``. +2. Candidate symbols: ``symbols JOIN files JOIN repos``, filtered to the distinct + ``target_name``s the first query returned, bounded PER NAME by a SQL window function + (``candidate_cap``) so a hot name (``get``, ``run``, ``__init__``) never pulls its entire + corpus-wide match set. + +Why two queries and not ``reference_edges JOIN symbols ON target_name = name`` (both reaching +through ``files``/``repos``): that is exactly the self-referencing join shape that lets +SQLAlchemy auto-correlate the two ``files``/``repos`` legs against each other, silently +mis-scoping which candidate belongs to which site. Neither statement here references the +other's tables, so there is zero correlation surface -- the same rationale as +``symbols.py``'s ``Symbol.file_id.in_([concrete ints])`` split. + +Ranking (which candidate is "the" definition for a call site) runs in Python, AFTER query 2, +because every signal (``same_repo``/``same_file``/``kind_match``) is relational to the +``(site, candidate)`` pair -- computing it in SQL would require the self-join this module +avoids. Ranking is membership-preserving: a lower-ranked candidate is never dropped, only +sorted later, so genuine ambiguity (AC1) is never silently collapsed to one answer. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from sqlalchemy import Connection, Row, Select, Text, any_, func, literal, select, text +from sqlalchemy.dialects.postgresql import ARRAY +from sqlalchemy.exc import OperationalError +from sqlalchemy.orm import aliased +from sqlalchemy.sql.elements import ColumnElement + +from app.db.models import File, ReferenceEdge, Repo, Symbol +from app.query.compiler import DEFAULT_ROW_LIMIT +from app.search.errors import reraise_or_query_too_broad + +# Per-request DB-time bound; a cancellation surfaces as QueryTooBroadError (mirrors symbols.py). +DEFAULT_STATEMENT_TIMEOUT_MS = 5000 + +# Per-name candidate ceiling: bounds the SQL FETCH (query 2's window), not just the payload. +DEFAULT_CANDIDATE_CAP = 32 + +# Call sites resolve to callables/constructors (`Foo()` is a constructor call). Import edges +# never earn this boost (D3/D4): `kind_match` is False for every import candidate uniformly. +CALL_TARGET_KINDS: frozenset[str] = frozenset({"function", "method", "class"}) + + +# --------------------------------------------------------------------------- contract + + +@dataclass(frozen=True) +class CandidateSymbol: + """One ranked candidate definition for an :class:`EdgeSite`. + + ``symbol_id`` is a query-time-transient internal tiebreak ONLY -- it is never persisted + (nothing here writes a resolved id back to ``reference_edges``) and is excluded from the + service-layer wire payload (see ``app.service._site_payload``), so carrying it does not + violate the epic's "never add an extraction-time symbol FK" rule. + """ + + symbol_id: int + repo_id: int + path: str + name: str + kind: str | None + start_line: int | None + same_repo: bool + same_file: bool + kind_match: bool + + +@dataclass(frozen=True) +class EdgeSite: + """One raw ``reference_edges`` row plus its resolved (ranked, possibly capped) candidates. + + ``candidate_count`` is the TRUE pre-cap count (SQL ``COUNT(*) OVER``, see + :func:`_build_candidates_select`) -- it never shrinks just because the fetched/returned + ``candidates`` list was capped, so ``resolution`` stays correct even under truncation. + ``candidates_truncated`` is ``candidate_count > len(candidates)``. + """ + + repo_id: int + file_id: int + path: str + line: int + edge_kind: str # "call" | "import" + target_name: str + enclosing_name: str | None + enclosing_kind: str | None + resolution: str # "unique" | "ambiguous" | "unresolved" + candidate_count: int + candidates_truncated: bool + candidates: tuple[CandidateSymbol, ...] + + +@dataclass(frozen=True) +class ReferenceResult: + """Result of :func:`resolve_references`. + + ``repo_known`` is ``False`` iff a ``repo=`` scope was requested but no such repo exists -- + a structured miss (mirrors ``get_file_payload``'s ``found: False``), never a silent empty. + It is always ``True`` when no repo scope was requested (nothing to be unknown about). + """ + + sites: tuple[EdgeSite, ...] + truncated: bool # site row-cap tripped + truncation_reason: str | None # "row_cap" | None + repo_known: bool + + +# ----------------------------------------------------------------------- pure helpers + + +def classify_resolution(count: int) -> str: + """Map a true candidate count to the ``resolution`` label. Shared with the measurement + script (D10) so the offline distribution and the serve path cannot drift apart.""" + if count == 0: + return "unresolved" + if count == 1: + return "unique" + return "ambiguous" + + +def _branch_predicate( + branch: str | None, + *, + file: type[File] = File, + repo: type[Repo] = Repo, +) -> ColumnElement[bool]: + """Branch-scoping predicate, byte-identical to ``get_file_payload``'s (``app/service.py``) + and the query compiler's implicit default conjunct. ``file``/``repo`` default to the + unaliased ORM classes (query 1 / query 2 each join ``files``/``repos`` exactly once); the + measurement script's correlated subquery (D10) passes aliased entities for its inner + ``symbols``-side join, which reaches a SECOND, distinct ``files``/``repos`` join in the + same statement. + """ + if branch is not None: + return file.branches.op("@>")(literal([branch], type_=ARRAY(Text))) + return func.coalesce(repo.default_branch, "HEAD") == any_(file.branches) + + +def _rank_candidates(candidates: list[CandidateSymbol]) -> tuple[CandidateSymbol, ...]: + """Total-ordered, membership-preserving rank (D4): same-repo first, then kind-appropriate, + then same-file, tiebreaking on ``(repo_id, path, start_line, symbol_id)`` for determinism. + Never drops a candidate -- a lower-ranked one only sorts later. + """ + return tuple( + sorted( + candidates, + key=lambda c: ( + not c.same_repo, + not c.kind_match, + not c.same_file, + c.repo_id, + c.path, + c.start_line or 0, + c.symbol_id, + ), + ) + ) + + +# ------------------------------------------------------------------------- SQL builders + + +def _build_sites_select( + *, + target_name: str | None, + edge_kind: str | None, + repo_id: int | None, + branch: str | None, + row_limit: int, +) -> Select: + """Query 1: edge sites, touching ``reference_edges``/``files``/``repos`` only. + + Joins/orders through the authoritative ``File.repo_id`` (not the denormalized + ``ReferenceEdge.repo_id``), mirroring ``symbols.py``'s same rule -- the branch predicate + compares ``Repo.default_branch`` against ``File.branches``, so both must be the same repo. + ``repo_id``, when given, filters ``ReferenceEdge.repo_id`` directly (index-served by + ``ix_reference_edges_repo_kind``) rather than a post-join ``Repo.name`` predicate. + """ + stmt = ( + select( + ReferenceEdge.id, + File.repo_id, + ReferenceEdge.file_id, + File.path, + ReferenceEdge.line, + ReferenceEdge.edge_kind, + ReferenceEdge.target_name, + ReferenceEdge.enclosing_name, + ReferenceEdge.enclosing_kind, + ) + .join(File, ReferenceEdge.file_id == File.id) + .join(Repo, File.repo_id == Repo.id) + .where(_branch_predicate(branch)) + .order_by( + File.repo_id, + File.path, + ReferenceEdge.line, + ReferenceEdge.id, + ) + .limit(row_limit) + ) + if target_name is not None: + stmt = stmt.where(ReferenceEdge.target_name == target_name) + if edge_kind is not None: + stmt = stmt.where(ReferenceEdge.edge_kind == edge_kind) + if repo_id is not None: + stmt = stmt.where(ReferenceEdge.repo_id == repo_id) + return stmt + + +def _build_candidates_select(*, names: list[str], branch: str | None, candidate_cap: int) -> Select: + """Query 2: candidate symbols for ``names``, bounded IN SQL (not just in the payload). + + ``ROW_NUMBER() OVER (PARTITION BY symbols.name ORDER BY ...)`` keeps only the first + ``candidate_cap`` rows per name by a name-intrinsic order (site-relative signals like + ``same_repo``/``same_file`` can't be pushed here -- they depend on the site, which this + query never sees). ``COUNT(*) OVER`` carries the TRUE pre-cap count out alongside the + trimmed rows, so :func:`classify_resolution` stays exact even when the fetch is capped. + """ + rn = ( + func.row_number() + .over( + partition_by=Symbol.name, + order_by=(File.repo_id, File.path, Symbol.start_line, Symbol.id), + ) + .label("rn") + ) + total = func.count().over(partition_by=Symbol.name).label("candidate_count") + inner = ( + select( + Symbol.id.label("symbol_id"), + Symbol.name, + Symbol.kind, + Symbol.start_line, + File.repo_id, + Symbol.file_id, + File.path, + rn, + total, + ) + .join(File, Symbol.file_id == File.id) + .join(Repo, File.repo_id == Repo.id) + .where(Symbol.name.in_(names), _branch_predicate(branch)) + .subquery() + ) + return select(inner).where(inner.c.rn <= candidate_cap) + + +def build_candidate_count_select(*, edge_kind: str, branch: str | None) -> Select: + """Per-site TRUE candidate count, reused by ``scripts/measure_reference_resolution.py`` + (D10) so the offline resolution-distribution measurement agrees with the serve path BY + CONSTRUCTION rather than re-implementing the join. Uses a correlated scalar subquery + (acceptable here: this builder has no per-request latency/timeout budget, unlike + :func:`resolve_references`'s window-bounded query 2) over an ALIASED ``files``/``repos`` + join, since the outer ``reference_edges``/``files``/``repos`` join already occupies the + unaliased names in this single statement. + + One row per matching edge: ``(edge_id, target_name, candidate_count)``. + """ + sym_file = aliased(File) + sym_repo = aliased(Repo) + count_subq = ( + select(func.count()) + .select_from(Symbol) + .join(sym_file, Symbol.file_id == sym_file.id) + .join(sym_repo, sym_file.repo_id == sym_repo.id) + .where( + Symbol.name == ReferenceEdge.target_name, + _branch_predicate(branch, file=sym_file, repo=sym_repo), + ) + .correlate(ReferenceEdge) + .scalar_subquery() + ) + return ( + select( + ReferenceEdge.id, + ReferenceEdge.target_name, + count_subq.label("candidate_count"), + ) + .join(File, ReferenceEdge.file_id == File.id) + .join(Repo, File.repo_id == Repo.id) + .where(ReferenceEdge.edge_kind == edge_kind, _branch_predicate(branch)) + ) + + +# --------------------------------------------------------------------- row -> dataclass + + +def _to_candidate( + row: Row, *, site_repo_id: int, site_file_id: int, kind_eligible: bool +) -> CandidateSymbol: + return CandidateSymbol( + symbol_id=row.symbol_id, + repo_id=row.repo_id, + path=row.path, + name=row.name, + kind=row.kind, + start_line=row.start_line, + same_repo=row.repo_id == site_repo_id, + same_file=row.file_id == site_file_id, + kind_match=kind_eligible and row.kind in CALL_TARGET_KINDS, + ) + + +def _build_edge_site(site_row: Row, candidate_rows: list[Row]) -> EdgeSite: + # kind_match eligibility is per-SITE (this edge's own kind), not the resolver's edge_kind + # filter param -- an unfiltered corpus-wide resolve can mix call/import sites. + kind_eligible = site_row.edge_kind == "call" + candidate_count = candidate_rows[0].candidate_count if candidate_rows else 0 + candidates = _rank_candidates( + [ + _to_candidate( + row, + site_repo_id=site_row.repo_id, + site_file_id=site_row.file_id, + kind_eligible=kind_eligible, + ) + for row in candidate_rows + ] + ) + return EdgeSite( + repo_id=site_row.repo_id, + file_id=site_row.file_id, + path=site_row.path, + line=site_row.line, + edge_kind=site_row.edge_kind, + target_name=site_row.target_name, + enclosing_name=site_row.enclosing_name, + enclosing_kind=site_row.enclosing_kind, + resolution=classify_resolution(candidate_count), + candidate_count=candidate_count, + candidates_truncated=candidate_count > len(candidates), + candidates=candidates, + ) + + +# ------------------------------------------------------------------------ entry point + + +def resolve_references( + conn: Connection, + *, + target_name: str | None = None, + edge_kind: str | None = None, + repo: str | None = None, + branch: str | None = None, + row_limit: int = DEFAULT_ROW_LIMIT, + candidate_cap: int = DEFAULT_CANDIDATE_CAP, + statement_timeout_ms: int = DEFAULT_STATEMENT_TIMEOUT_MS, +) -> ReferenceResult: + """Resolve raw ``reference_edges`` sites to ranked candidate-set ``symbols`` matches. + + ``target_name``/``edge_kind``/``repo`` are all optional filters (``find_references_payload`` + passes ``target_name``; ``list_imports_payload`` passes ``edge_kind="import"`` + a required + ``repo``). ``branch`` is a PARAMETER (not a query atom), applied identically to both the + edge site's file and each candidate's file (D6), mirroring ``get_file_payload``. + + Runs both queries in ONE transaction with a per-request ``statement_timeout``; a + cancellation raises :class:`~app.search.errors.QueryTooBroadError` (uncaught here -- the + service layer maps it, mirroring ``symbol_search``). A ``repo=`` scope that resolves to no + repo short-circuits to an empty result with ``repo_known=False`` and NO further DB work. + """ + with conn.begin(): + conn.execute( + text("SELECT set_config('statement_timeout', :ms, true)"), + {"ms": str(statement_timeout_ms)}, + ) + + repo_id: int | None = None + if repo is not None: + try: + repo_id = conn.execute( + select(Repo.id).where(Repo.name == repo) + ).scalar_one_or_none() + except OperationalError as error: + reraise_or_query_too_broad(error) + if repo_id is None: + return ReferenceResult( + (), truncated=False, truncation_reason=None, repo_known=False + ) + + sites_stmt = _build_sites_select( + target_name=target_name, + edge_kind=edge_kind, + repo_id=repo_id, + branch=branch, + row_limit=row_limit, + ) + try: + site_rows = conn.execute(sites_stmt).all() + except OperationalError as error: + reraise_or_query_too_broad(error) + + truncated = len(site_rows) >= row_limit + + names = sorted({row.target_name for row in site_rows}) + candidates_by_name: dict[str, list[Row]] = {} + if names: + candidates_stmt = _build_candidates_select( + names=names, branch=branch, candidate_cap=candidate_cap + ) + try: + candidate_rows = conn.execute(candidates_stmt).all() + except OperationalError as error: + reraise_or_query_too_broad(error) + for row in candidate_rows: + candidates_by_name.setdefault(row.name, []).append(row) + + sites = tuple( + _build_edge_site(row, candidates_by_name.get(row.target_name, [])) for row in site_rows + ) + return ReferenceResult( + sites=sites, + truncated=truncated, + truncation_reason="row_cap" if truncated else None, + repo_known=True, + ) diff --git a/app/service.py b/app/service.py index 5a23c9c..f7e2389 100644 --- a/app/service.py +++ b/app/service.py @@ -26,6 +26,7 @@ from app.config import Settings from app.db.models import File, Repo, RepoBranch +from app.query.compiler import DEFAULT_ROW_LIMIT from app.query.parser import ( And, BranchFilter, @@ -44,6 +45,7 @@ ) from app.search.errors import QueryTooBroadError from app.search.grep import FileCursor, grep_search +from app.search.references import EdgeSite, ReferenceResult, resolve_references from app.search.semantic import _semantic_search_payload as semantic_search_payload # noqa: F401 from app.search.symbols import SymbolResult, symbol_search @@ -908,3 +910,291 @@ def get_file_payload( "found": found, "commit": commit, } + + +# ------------------------------------------------------------------- reference resolution + + +def _site_payload(site: EdgeSite, name_map: dict[int, str]) -> dict[str, Any]: + """Shape one resolved :class:`~app.search.references.EdgeSite` for the wire. + + ``symbol_id`` is deliberately absent from each candidate dict (D1/D4): it is a query-time + ranking tiebreak, never persisted, never a caller-facing identifier. + """ + enclosing_symbol = ( + {"name": site.enclosing_name, "kind": site.enclosing_kind} + if site.enclosing_name is not None + else None + ) + return { + "repo": name_map.get(site.repo_id, str(site.repo_id)), + "file": site.path, + "line": site.line, + "edge_kind": site.edge_kind, + "target_name": site.target_name, + "enclosing_symbol": enclosing_symbol, + "resolution": site.resolution, + "candidate_count": site.candidate_count, + "candidates_truncated": site.candidates_truncated, + "candidates": [ + { + "repo": name_map.get(candidate.repo_id, str(candidate.repo_id)), + "file": candidate.path, + "line": candidate.start_line, + "name": candidate.name, + "kind": candidate.kind, + "same_repo": candidate.same_repo, + "same_file": candidate.same_file, + "kind_match": candidate.kind_match, + } + for candidate in site.candidates + ], + } + + +def _reference_result_to_payload( + result: ReferenceResult, name_map: dict[int, str] +) -> dict[str, Any]: + """Shared envelope shape for :func:`find_references_payload` / :func:`list_imports_payload`: + ``sites`` + ``site_count`` + a ``resolution_summary`` histogram + truncation flags.""" + resolution_summary = {"unique": 0, "ambiguous": 0, "unresolved": 0} + for site in result.sites: + resolution_summary[site.resolution] += 1 + return { + "sites": [_site_payload(site, name_map) for site in result.sites], + "site_count": len(result.sites), + "resolution_summary": resolution_summary, + "truncated": result.truncated, + "truncation_reason": result.truncation_reason, + } + + +def _reference_repo_name_map(conn: Any, result: ReferenceResult, cfg: Settings) -> dict[int, str]: + """Resolve every repo id across a :class:`ReferenceResult`'s sites AND candidates to names. + + Run in a SEPARATE ``conn.begin()`` + ``SET LOCAL statement_timeout`` AFTER + :func:`resolve_references` returns -- its own ``set_config`` committed with its + transaction, so this lookup would otherwise run uncapped (mirrors ``search_code_payload``'s + post-leg ``_repo_name_map`` call). + """ + repo_ids = {site.repo_id for site in result.sites} + repo_ids |= {candidate.repo_id for site in result.sites for candidate in site.candidates} + if not repo_ids: + return {} + with conn.begin(): + conn.exec_driver_sql(f"SET LOCAL statement_timeout = {int(cfg.statement_timeout_ms)}") + return _repo_name_map(conn) + + +def find_references_payload( + engine: Engine, cfg: Settings, name: str, limit: int, branch: str | None = None +) -> dict[str, Any]: + """Resolve ``name``'s call sites to ranked candidate-set definitions. + + Corpus-wide (no ``repo`` scope) over ``edge_kind="call"`` edges. Ambiguity is never + collapsed: an ``"ambiguous"`` site's ``candidates`` list carries every ranked candidate up + to the per-name cap (AC1). ``limit`` is the caller's already-clamped row limit (mirrors + :func:`search_code_payload` -- clamping is the caller's responsibility, e.g. the future + MCP tool registration in #87). + """ + with engine.connect() as conn: + try: + result = resolve_references( + conn, + target_name=name, + edge_kind="call", + branch=branch, + row_limit=limit, + statement_timeout_ms=cfg.statement_timeout_ms, + ) + except QueryTooBroadError: + return { + "query": name, + "kind": "references", + "symbol": name, + "branch": branch, + "sites": [], + "site_count": 0, + "resolution_summary": {"unique": 0, "ambiguous": 0, "unresolved": 0}, + "truncated": True, + "truncation_reason": None, + "query_too_broad": True, + } + name_map = _reference_repo_name_map(conn, result, cfg) + + return { + "query": name, + "kind": "references", + "symbol": name, + "branch": branch, + "query_too_broad": False, + **_reference_result_to_payload(result, name_map), + } + + +_LIST_IMPORTS_DIRECTIONS = ("imports", "imported_by") + +# Deterministic ``query`` echo for the three PRE-DB validation-error payloads (Critic note 3): +# the dedicated ``direction``/``repo``/``target`` fields already carry the caller's actual +# inputs, so ``query`` is pinned to the empty string for EVERY validation error -- one +# deterministic value across all three, independent of which argument was missing/invalid. +_LIST_IMPORTS_VALIDATION_QUERY = "" + + +def _list_imports_error_payload( + *, + direction: str, + repo: str | None, + target: str | None, + branch: str | None, + error_key: str, + error_value: Any, + reason: str, +) -> dict[str, Any]: + """One deterministic PRE-DB validation-error shape for :func:`list_imports_payload`. + + Mirrors ``app.search.semantic``'s ``unsupported_filter`` precedent: the full empty envelope + (``sites``/``site_count``/``resolution_summary``/``truncated``/``truncation_reason``/ + ``query_too_broad``) plus the uniform ``kind``/``direction``/``repo``/``repo_known``/ + ``target``/``branch`` keys, plus the single structured error flag (``unsupported_direction`` + / ``missing_repo`` / ``missing_target``) and its remedy ``reason``. ``repo_known`` is + ``True``: a validation error is never a repo-existence miss (no DB lookup happened). + """ + return { + "query": _LIST_IMPORTS_VALIDATION_QUERY, + "kind": "imports", + "direction": direction, + "repo": repo, + "repo_known": True, + "target": target, + "branch": branch, + "query_too_broad": False, + "sites": [], + "site_count": 0, + "resolution_summary": {"unique": 0, "ambiguous": 0, "unresolved": 0}, + "truncated": False, + "truncation_reason": None, + error_key: error_value, + "reason": reason, + } + + +def list_imports_payload( + engine: Engine, + cfg: Settings, + repo: str | None = None, + limit: int = DEFAULT_ROW_LIMIT, + branch: str | None = None, + *, + target: str | None = None, + direction: str = "imports", +) -> dict[str, Any]: + """Enumerate ``import`` edge sites in one of two directions over ``edge_kind="import"``. + + Both directions collapse to the SAME resolver call + (``resolve_references(edge_kind="import", repo=?, target_name=?)``); ``direction`` only + decides which argument is REQUIRED: + + * ``direction="imports"`` (default): **``repo`` is REQUIRED** -- a corpus-wide listing + would filter on ``edge_kind`` alone, the trailing column of + ``ix_reference_edges_repo_kind (repo_id, edge_kind)``, which is not index-served. Lists + the repo's import sites; an optional ``target`` narrows to sites importing that exact + dotted path (index-served by ``ix_reference_edges_target_name`` either way). + * ``direction="imported_by"``: **``target`` is REQUIRED** -- "who imports X", corpus-wide + over ``ix_reference_edges_target_name`` (index-served, NOT the seq-scan case #86 D8 + rejects). An optional ``repo`` narrows to importers within that one repo. + + Deterministic PRE-DB validation returns a structured payload, NEVER an exception (mirroring + ``app.search.semantic``'s ``unsupported_filter``): an unknown ``direction`` sets + ``unsupported_direction`` (echoing the value); ``imports`` with no ``repo`` sets + ``missing_repo``; ``imported_by`` with no ``target`` sets ``missing_target``. Each carries + a remedy ``reason`` and the full empty envelope, and is proven to touch no DB. + + ``repo_known=False`` is a structured "no such repo" miss (mirrors ``get_file_payload``'s + ``found: False``) -- distinct from a known repo with zero import sites (``repo_known=True``, + empty ``sites``); it is always ``True`` when no ``repo`` scope was requested. Import edges + are largely EXTERNAL by design (#86 D3: exact dotted-path match only, no last-segment + split), so most sites are expected to resolve ``"unresolved"`` -- not itself an error. + + Uniform key set across both directions: ``query`` (the ``repo`` for ``imports``, the + ``target`` for ``imported_by``), ``kind:"imports"``, ``direction``, ``repo``, ``repo_known``, + ``target``, ``branch``, ``query_too_broad``, plus the shared reference envelope. All keys + are additive and permanent. Validation lives HERE (the service layer) so the #88 web UI + inherits it; the MCP tool is a pure wrapper. ``limit`` is the caller's already-clamped row + limit (mirrors :func:`find_references_payload`). + """ + if direction not in _LIST_IMPORTS_DIRECTIONS: + return _list_imports_error_payload( + direction=direction, + repo=repo, + target=target, + branch=branch, + error_key="unsupported_direction", + error_value=direction, + reason="direction must be one of 'imports' or 'imported_by'", + ) + if direction == "imports" and repo is None: + return _list_imports_error_payload( + direction=direction, + repo=repo, + target=target, + branch=branch, + error_key="missing_repo", + error_value=True, + reason="direction='imports' requires a repo to enumerate; pass repo=", + ) + if direction == "imported_by" and target is None: + return _list_imports_error_payload( + direction=direction, + repo=repo, + target=target, + branch=branch, + error_key="missing_target", + error_value=True, + reason="direction='imported_by' requires a target dotted path; pass target=", + ) + + # `query` echoes the direction-primary argument: the repo enumerated (imports) or the + # target searched for (imported_by). + query = repo if direction == "imports" else target + + with engine.connect() as conn: + try: + result = resolve_references( + conn, + target_name=target, + edge_kind="import", + repo=repo, + branch=branch, + row_limit=limit, + statement_timeout_ms=cfg.statement_timeout_ms, + ) + except QueryTooBroadError: + return { + "query": query, + "kind": "imports", + "direction": direction, + "repo": repo, + "repo_known": True, + "target": target, + "branch": branch, + "sites": [], + "site_count": 0, + "resolution_summary": {"unique": 0, "ambiguous": 0, "unresolved": 0}, + "truncated": True, + "truncation_reason": None, + "query_too_broad": True, + } + name_map = _reference_repo_name_map(conn, result, cfg) + + return { + "query": query, + "kind": "imports", + "direction": direction, + "repo": repo, + "repo_known": result.repo_known, + "target": target, + "branch": branch, + "query_too_broad": False, + **_reference_result_to_payload(result, name_map), + } diff --git a/docs/runbooks/reference-edges.md b/docs/runbooks/reference-edges.md new file mode 100644 index 0000000..e2ac8b1 --- /dev/null +++ b/docs/runbooks/reference-edges.md @@ -0,0 +1,234 @@ +# Runbook: reference-edge schema (0005) + +What an operator needs to know about the `reference_edges` table added in migration +`0005`: what it stores (and doesn't yet), why it's grant-coupled like `repo_branches` +(`0003`) and `chunks` (`0004`) before it, and how to verify the app/job grants actually +landed on a given deploy target. + +--- + +## 1. What this table is + +`reference_edges` is a **raw, unresolved** call/import edge extracted from one file's +content-version: `(edge_kind, target_name, line, enclosing_*)` per site tree-sitter finds, +with `edge_kind IN ('call', 'import')` enforced by a CHECK constraint. It is part of the +knowledge-graph epic (#82): `target_name` is resolved to concrete `symbols` rows at +**query time**, by name-join — shipped in #86 (see §4) — this table deliberately carries +**no foreign key to `symbols`**. Symbol ids churn on every per-file delete-and-reinsert, and +an FK would couple the two rewrite orders inside the indexing transaction for no query +benefit. + +The enclosing symbol (the function/class a call or import site sits inside, if any) is +denormalized onto the row as `enclosing_name` / `enclosing_kind` / `enclosing_start_line` +/ `enclosing_end_line`, all nullable — `NULL` means module/top-level scope, exactly the +same convention `symbols` and `chunks` use elsewhere. There is no `branches` column and no +`commit` column: branch membership rides `files.branches` at query time (the resolver +joins through `files` with the same `coalesce(default_branch,'HEAD')` conjunct used +everywhere else), and `files.commit` is documented-ambiguous under multi-branch dedup and +must gain no new readers. + +**#84 shipped the writer.** `indexer/symbols.py`'s `extract_file` walks each file's parse +tree once, emitting both `symbols` and `reference_edges` candidates from the same pass +(`indexer/languages.py`'s `EDGE_NODE_KINDS`, Python-only for now — the other six languages +land in #85). `indexer/store.py::index_repo` writes them exactly like `symbols`: an +unconditional per-file `DELETE` followed by a bulk reinsert, inside the same transaction as +the rest of that file's row, so a file whose edges all vanish still sheds its stale rows. +`indexer/store.py`'s cascade-owning functions (`index_repo`'s membership sweep, +`reconcile_retired_branches`, `reconcile_removed_repos`) already enumerate +`reference_edges` alongside `symbols`/`chunks` in their docstrings and rely on the same +FK-cascade mechanism proven in §7.2 of the design doc and in +`tests/integration/test_reconcile.py` / `test_store.py` — no behavior change was needed to +make the cascade correct, because both `repos -> reference_edges` and +`files -> reference_edges` are `ON DELETE CASCADE` foreign keys. + +**What gets extracted (Python, #84):** + +- **`call`** edges target the rightmost identifier of the callee: `f()` / `a.b.f()` / + `self.f()` all target `f`. Callees with no rightmost identifier (`xs[0]()`, the outer call + of `f()()`) are skipped — candidate-set semantics, not full resolution. +- **`import`** edges target the full dotted path as written, alias-insensitive: + `import a.b.c as d` targets `a.b.c`, not `d`. `from a.b import c, d as e` yields two edges + (`a.b.c`, `a.b.d`). Relative imports preserve source fidelity (`from . import x` -> + `.x`; `from ..p import q` -> `..p.q`). A wildcard `from a.b import *` yields one edge for + the module itself (`a.b`). +- **Enclosing attribution** is the innermost *named* definition on the walk stack when the + call/import node is visited (`None` = module/top-level scope) — a call in a class body + outside any method attributes to the class, not to `None`. +- Duplicate sites (the same target called twice on one line) are two rows by design; there + is no uniqueness constraint, and the query-time resolver (#86) ranks candidates. + +**Operational consequence of the `INDEX_SEMANTICS_VERSION` bump history:** `2 -> 3` (#84) +added Python `reference_edges`; `3 -> 4` (#85) extended typed reference edges to +JS/TS/TSX/Go/Java/Rust. The constant is now **4** (`app/db/models.py`). Each bump makes +every already-indexed branch's stored `(head_sha, index_semantics_version)` stamp mismatch +the running code's version, so the *next* run of every branch is a full re-index (not a +skip) purely to backfill the new edges — expected, one-time, and already how the `2` bump +behaved for `chunks`. + +## 2. Indexes + +| Index | Serves | +|---|---| +| `ix_reference_edges_target_name` (btree) | The resolver's name-equality join (`symbols.name = reference_edges.target_name`), shipped in #86 (§4) | +| `ix_reference_edges_target_trgm` (GIN, `gin_trgm_ops`) | Partial/substring reference lookups, parity with `ix_symbols_name_trgm` | +| `ix_reference_edges_file_id` (btree) | The per-file delete-and-reinsert writer (#84) and the `ON DELETE CASCADE` fired by the sweep/reconcile paths — Postgres does not auto-index a foreign key, and both are hot paths | +| `ix_reference_edges_repo_kind` (btree, `(repo_id, edge_kind)`) | Per-repo kind scans, e.g. `list_imports_payload`'s required `repo` scope (§4, #86) | + +## 3. Deploy coupling — this migration is NOT schema-only + +Same shape as `repo_branches` (0003) and `chunks` (0004) before it: `reference_edges` is a +new table, and the app/job grant builders in `app/db/grants.py` are schema-wide +(`GRANT ... ON ALL TABLES IN SCHEMA` + `ALTER DEFAULT PRIVILEGES`), so no code change was +needed for them to cover it. Whether a grant re-run is actually **required** after `0005` +depends on Postgres's `ALTER DEFAULT PRIVILEGES` (ADP) semantics: + +- **ADP binds to the role that executed it**, not to the schema. `scripts/deploy.sh full` + (`make deploy`) runs both the migrate step and the grants step as the same deploying + identity, so on a fresh deploy the app/job roles get `SELECT` / `INSERT,UPDATE,DELETE` + on `reference_edges` automatically the moment it's created — **no re-grant needed**. +- **A schema-only `make migrate TARGET=` run by that SAME identity** against an + already-deployed target is also covered automatically, for the same reason. +- **A schema-only migrate run by a DIFFERENT identity** than the one that originally ran + `ALTER DEFAULT PRIVILEGES` is **not** covered — ADP simply never fires for that role, so + the app/job roles get nothing on the new table. + +This is proven in CI (`tests/integration/test_migrations.py`: +`test_reference_edges_adp_same_role_covers_new_table` and +`test_reference_edges_adp_different_role_does_not_cover_new_table`), not assumed. + +**Always deploy this with `scripts/deploy.sh full` (i.e. `make deploy`) or, for an +already-deployed target migrated by a different identity, re-run the grants step +explicitly:** + +``` +APP_SP_ROLE= JOB_WRITER_ROLE= \ + make migrate TARGET= ARGS=--apply-grants +``` + +### Verifying the grant landed + +Run as the deploying identity (or any role with `SELECT` on `pg_catalog`) against the +target: + +```sql +SELECT has_table_privilege('', 'reference_edges', 'SELECT'), + has_table_privilege('', 'reference_edges', 'INSERT'); +``` + +Both must return `true`. If either is `false`, run the re-grant command above — it is +idempotent, safe to run against a target that's already current. + +## 4. Query-time resolution (#86) + +`app/search/references.py` resolves a raw `reference_edges` row's `target_name` to the +`symbols` rows it could plausibly mean, entirely at query time — nothing from this +resolution is ever written back to `reference_edges` or `symbols`, and no extraction-time +symbol FK was added. `resolve_references(conn, ...)` is the entry point; `app/service.py` +wraps it in two additive payload builders, `find_references_payload` (corpus-wide, +`edge_kind="call"`) and `list_imports_payload` (`edge_kind="import"`). + +**MCP tools shipped in #87.** Both builders are now exposed as MCP tools in `app/main.py`: +`find_references(symbol, limit, branch)` and `list_imports(repo, target, direction, branch, +limit)`. `list_imports` gained a `direction` parameter (an additive, signature-compatible +extension of the builder): `direction="imports"` enumerates a repo's import sites (`repo` +required), and `direction="imported_by"` finds who imports a given dotted `target` +corpus-wide (`target` required, index-served by `ix_reference_edges_target_name`). Invalid +input returns a structured payload (`unsupported_direction`/`missing_repo`/`missing_target` +with a `reason`), never an exception, validated PRE-DB in the service-layer builder so the #88 +Web UI inherits it. The "what tests cover symbol X" question composes from the existing +primitive — `find_references(X)` plus a client-side `sites[].file` test-path filter, each +surviving site's `enclosing_symbol` naming the covering test — so no new tool was added. +Deferred past #87: repo/kind-scoped `find_references` filters, and per-file forward imports +("what does file F import"). + +**Two-query design, deliberately not one joined query.** Query 1 selects matching edge +sites from `reference_edges`/`files`/`repos`; query 2 selects candidate `symbols` for the +distinct `target_name`s query 1 returned, from `symbols`/`files`/`repos`. Neither query +references the other's tables. A single `reference_edges JOIN symbols ON target_name = +name` (both reaching through `files`/`repos`) would let SQLAlchemy auto-correlate the two +`files`/`repos` legs against each other, silently mis-scoping which candidate belongs to +which site — the same auto-correlation hazard `app/search/symbols.py` avoids for `sym:` +lookups. Query 2 bounds its **fetch itself** (not just the returned payload) with a SQL +window function per `target_name` (`ROW_NUMBER() OVER (PARTITION BY symbols.name ORDER BY +...)`, capped at `DEFAULT_CANDIDATE_CAP = 32`), so a hot name (`get`, `run`, `__init__`) +never pulls its entire corpus-wide match set into memory. `COUNT(*) OVER` carries the TRUE +pre-cap count alongside the trimmed rows, so ambiguity is never rewritten to "unique" just +because the candidate list was capped. + +**Candidate-set contract.** Each edge site resolves to zero, one, or many ranked +candidates — `resolution` is `"unresolved"` (0), `"unique"` (1), or `"ambiguous"` (2+), +derived from the true pre-cap `candidate_count`. Ranking (same-repo before cross-repo, then +kind-appropriate, then same-file, tiebreaking on `(repo_id, path, start_line, symbol_id)` +for a deterministic total order) runs in Python after query 2, since every signal is +relational to the `(site, candidate)` pair. Ranking is **membership-preserving**: a +lower-ranked candidate is sorted later, never dropped, so genuine ambiguity is always +represented in full (up to the cap) rather than silently collapsed to one answer. + +**Import edges resolve to their full dotted path, no last-segment split.** `import` +`target_name` is the complete dotted path as written (see §1); `symbols.name` is bare, so +an import edge resolves to a candidate only in the rare case a symbol is literally named +that full dotted string. This is pinned deliberately, not a gap: (1) it keeps one +exact-equality, index-served predicate identical for both edge kinds, with no functional +index and no client-side splitting; (2) a dotted import genuinely points at an +external/stdlib module most of the time, so representing it as `"unresolved"` (= external) +is *correct*, not a miss; (3) a last-segment heuristic (`a.b.get` → every symbol named +`get`) would manufacture false ambiguity and defeat precision. `list_imports_payload`'s +value is *enumerating* import sites with their `target_name`, not resolving them to local +definitions. + +**`repo` is required for `direction=imports`; `direction=imported_by` is target-required, +index-served by `ix_reference_edges_target_name`.** A corpus-wide *bare* import listing +(`imports` with no `repo`) would filter on `edge_kind` alone — the trailing column of +`ix_reference_edges_repo_kind (repo_id, edge_kind)`, not index-served on its own — so it is +out of scope and returns a `missing_repo` validation payload. The `imported_by` direction is +the opposite case: it filters on `target_name` equality, which the btree +`ix_reference_edges_target_name` serves corpus-wide, so no `repo` is needed (one may still be +passed to narrow the result). `repo_known: False` is a structured "no such repo" miss (mirrors +`get_file_payload`'s `found: False`), distinguishable from a known repo with zero import sites +(`repo_known: True`, `sites: []`); it is always `True` when no `repo` scope was requested. + +**Branch scoping matches `search_code`/`get_file` exactly**, applied independently to BOTH +the edge site's file and each candidate's file: an explicit `branch` uses +`files.branches @> ARRAY[:branch]`; omitted, it falls back to +`coalesce(repos.default_branch, 'HEAD') = ANY(files.branches)` — the same predicate +`get_file_payload` uses, asserted byte-identical in `tests/unit/test_references.py` and +exercised end-to-end in `tests/integration/test_references.py`. + +**Quality measurement (`scripts/measure_reference_resolution.py`).** An offline script +reuses `app.search.references.build_candidate_count_select` and `classify_resolution` — the +SAME join semantics and branch predicate the live resolver's query 2 uses — so its +distribution agrees with the serve path by construction rather than re-implementing the +join. **`call` edges are the primary headline metric**, the only number compared against +the epic's deep-dive baseline (28.8% unique / 33.4% ambiguous / 37.8% external); the +`import`-edge distribution is reported separately, labeled informational (expected close to +0% resolution, validating the exact-dotted-match decision above). Run it with: + +``` +uv run python scripts/measure_reference_resolution.py --edge-kind both +``` + +Recorded distribution (self-indexed corpus: this repo's own git-tracked source tree — +206 files, 2,947 symbols, 15,412 reference edges across Python/JS/TS/TSX — default branch, +measured on 2026-07-23; see the #86 PR body for the full script output): + +``` +call edges -- HEADLINE AC4 metric (n=14226): + unique 4144 29.1% (baseline 28.8%) + ambiguous 4208 29.6% (baseline 33.4%) + unresolved 5874 41.3% (baseline 37.8%) + +import edges -- informational, expected ~0% resolution (validates D3) (n=1186): + unique 15 1.3% + ambiguous 8 0.7% + unresolved 1163 98.1% +``` + +The re-measured `call`-edge distribution tracks the baseline closely (within ~4 points on +every bucket); `import` edges resolve at ~2% total, confirming they are overwhelmingly +external/stdlib targets as D3 predicts. + +## Reference + +- [multi-branch.md §3](multi-branch.md#3-deploy-coupling--this-migration-is-not-schema-only) — + the same grant-coupling pattern for `repo_branches` (0003). +- [semantic-enablement.md](semantic-enablement.md) — the same pattern for `chunks` (0004). diff --git a/docs/runbooks/webui.md b/docs/runbooks/webui.md index dc5567a..aaf06e2 100644 --- a/docs/runbooks/webui.md +++ b/docs/runbooks/webui.md @@ -149,6 +149,83 @@ change, and a full re-index of every semantically-enabled project, so it is out and tracked as a follow-up issue (index-time `start_line`/`end_line` for exact semantic anchors). +## Graph tools — references & imports (issue #88) + +The webui exposes the same knowledge-graph reference edges the MCP `find_references`/ +`list_imports` tools serve, via two routes on the same FastAPI app, and an always-visible +**Graph** nav tab (`GraphPage.tsx`) covering both. Like the Semantic routes above, both are +thin passthrough wrappers over `app.service` payload builders -- they never reimplement graph +logic, and MCP responses are unaffected. **Candidate-set semantics throughout**: a site is a +place that names something; its `candidates` are the `symbols` definitions that name could +plausibly mean, ranked (`same_repo`/`same_file`/`kind_match`), never collapsed to a single +binding. + +### `GET /api/references` + +Params: `symbol` (required, non-empty), `limit` (default `200` -- parity with the MCP +`find_references` tool's own default, not `/api/search`'s `0 -> row_limit` convention), +`branch` (optional, scopes both the call site's file and each candidate's file). + +`symbol` requiring a non-empty value (422 on missing/empty) is a **webui-layer HTTP input +guard, not shared builder semantics** -- the MCP tool has no such gate and would run the +builder to an empty/unresolved payload instead. This is the one place the two surfaces +deliberately diverge; everything else below is byte-identical passthrough. + +The response is the builder's payload passed through unchanged (`clamp_limit` still applies +to `limit`); `query_too_broad` (the folded `QueryTooBroadError`) and every resolution state +(`unique`/`ambiguous`/`unresolved`, truncation) are 200 bodies -- recoverable conditions are +payload fields, never HTTP errors, mirroring the MCP tool's dispatch contract and the +Semantic routes' precedent above. + +### `GET /api/imports` + +Params: `repo`, `target` (both optional at this HTTP layer -- see below), `direction` +(default `"imports"`, passed through **verbatim**, unvalidated), `limit` (default `200`), +`branch` (optional). + +`repo`/`target` are optional here on purpose: which one is required depends on `direction`, +and that is the **builder's** job to decide -- an unknown `direction` (`unsupported_direction` ++ `reason`), `imports` with no `repo` (`missing_repo` + `reason`), or `imported_by` with no +`target` (`missing_target` + `reason`) all come back as structured **200** bodies, never a +422. Import edges target the full dotted path as written, so most sites are external/stdlib +and resolve `"unresolved"` -- expected, not an error. `repo_known: false` is a structured +"no such repo" miss, distinct from a known repo with zero import sites. + +### Error-status mapping (both routes) + +Identical to `/api/semantic`'s pattern, minus the 502 leg (this is a DB-only path, no +external embedding call): **400** `{"error": "invalid parameter"}` for a NUL byte in a +parameter reaching a bound SQL parameter (`sqlalchemy.exc.DataError`); **422** for +`/api/references`'s missing `symbol` only (`/api/imports` never 422s -- see above); anything +else DB-only and unexpected is a plain 500. Every recoverable condition -- ambiguity, +unresolved sites, truncation, `query_too_broad`, and all three `/api/imports` validation +states -- is a 200 body; this route never inspects the payload to decide status. + +### The Graph tab + +One `GraphPage.tsx` component covers both `/references` and `/imports` (mode from the +pathname), sharing a `SiteList.tsx` candidate-set renderer -- both routes' sites use the +identical `_site_payload` shape. Unlike the Semantic tab, the Graph nav link is **always +visible**: there is no feature flag for reference edges (`reference_edges` ships under the +same read-only SELECT grant as everything else), and an empty corpus just yields an +empty-but-valid payload, nothing to fail-closed on. `App.tsx` mounts `GraphPage` with +`key={route.mode}`, so navigating between References and Imports fully remounts the page -- +symbol/repo/target/direction input state and the mount-time auto-run guard never bleed across +modes. Entry points ship from two places: a `references` link next to each symbol match in +lexical search results (`ResultsList.tsx`), and an `imports` link on each repo row +(`ReposPage.tsx`). Deep link shapes: `/references?symbol=X&branch=Y`, +`/imports?repo=R&direction=imports&branch=Y`, `/imports?target=T&direction=imported_by`. +Site and candidate rows deep-link into the file viewer via the same `/file?repo=&path=& +branch=#L{line}` idiom results/chunk links already use. + +**Parity guarantee (AC2).** `app/main.py`'s `find_references`/`list_imports` MCP tools are +pure `clamp_limit` -> `json.dumps(builder(...))` wrappers around the exact same +`app.service.find_references_payload`/`list_imports_payload` these two routes call -- +`tests/integration/test_webui_graph_parity.py` proves the webui route's JSON is +byte-identical to a direct builder call at the same clamped limit, over the same seeded +corpus `tests/integration/test_mcp_server.py` uses for its own MCP e2e pin, so the two +surfaces are provably in lockstep. + ## Read-only role The webui app's service principal is granted the same least-privilege read-only role as the diff --git a/indexer/AGENTS.md b/indexer/AGENTS.md index cdd44a2..eb5c590 100644 --- a/indexer/AGENTS.md +++ b/indexer/AGENTS.md @@ -4,7 +4,7 @@ # indexer ## Purpose -The serverless indexing job (`code-search-index`, wired as a `python_wheel_task` entry point in `pyproject.toml`). It reads the central `config.yaml` from the Databricks workspace, resolves it into a deduped list of GitHub repos (fail-fast on empty or oversized results, before any tarball is fetched or database connection opened), then fans repos out over a bounded thread pool. Per repo it resolves the default branch's immutable HEAD SHA, resolves configured branch globs into a concrete branch list, and — sequentially per branch — downloads the tarball by SHA over plain HTTPS (no git binary), extracts it safely, parses text files, extracts tree-sitter symbols, and writes everything in one atomic per-(repo, branch) transaction with content-SHA-deduped storage and a mark-and-sweep of stale branch membership. When semantic search is enabled, files are also chunked and embedded via `app.embed` — outside the transaction — and precomputed vectors are written through a `chunk_writer` seam. After every worker has joined, a post-fan-out checkpoint reconciles desired state — retiring stale branches and purging removed repos — but ONLY on a fully clean run (no failures, conflicts, or truncated branch discovery anywhere); a large repo-purge shrink is withheld as an incident signal rather than applied. The process exits non-zero if any branch fails, if reconciliation itself fails partway, or if a purge was withheld. +The serverless indexing job (`code-search-index`, wired as a `python_wheel_task` entry point in `pyproject.toml`). It reads the central `config.yaml` from the Databricks workspace, resolves it into a deduped list of GitHub repos (fail-fast on empty or oversized results, before any tarball is fetched or database connection opened), then fans repos out over a bounded thread pool. Per repo it resolves the default branch's immutable HEAD SHA, resolves configured branch globs into a concrete branch list, and — sequentially per branch — downloads the tarball by SHA over plain HTTPS (no git binary), extracts it safely, parses text files, extracts tree-sitter symbols and reference edges (typed call/import sites, Python-only for now), and writes everything in one atomic per-(repo, branch) transaction with content-SHA-deduped storage and a mark-and-sweep of stale branch membership. When semantic search is enabled, files are also chunked and embedded via `app.embed` — outside the transaction — and precomputed vectors are written through a `chunk_writer` seam. After every worker has joined, a post-fan-out checkpoint reconciles desired state — retiring stale branches and purging removed repos — but ONLY on a fully clean run (no failures, conflicts, or truncated branch discovery anywhere); a large repo-purge shrink is withheld as an incident signal rather than applied. The process exits non-zero if any branch fails, if reconciliation itself fails partway, or if a purge was withheld. ## Key Files | File | Description | @@ -15,12 +15,12 @@ The serverless indexing job (`code-search-index`, wired as a `python_wheel_task` | `fetch.py` | All GitHub HTTP: paginated org/user repo enumeration (`RepoMeta`), branch listing, `resolve_ref`/`resolve_branch_head` (branch name -> immutable SHA), streamed tarball download capped at `MAX_TARBALL_BYTES` (500 MB), safe extraction (`filter="data"`, bomb check) capped at `MAX_EXTRACTED_BYTES` (2 GB), `assert_disk_headroom` (2.5 GB per worker, both caps alive at once). `RateLimitError` is deliberately narrow: 429 always; 403 only with `Retry-After` or `X-RateLimit-Remaining: 0` — other 403s are permission failures. | | `hashing.py` | `content_sha`: canonical SHA-256 hex of content (`None` -> empty string). Single source of truth for `files.content_sha`; must stay byte-identical to the `0003` migration's SQL backfill forever or cross-branch dedup silently breaks (`tests/integration/test_content_sha_parity.py` is the gate). | | `job.py` | Entry point and orchestration: `main()`/`run()`, `ThreadPoolExecutor` sized by `effective_workers` (unit of work = one repo, all branches sequential), batched `repo_branches` stamp read, per-branch skip-if-unchanged, per-branch `BranchOutcome` classification (`indexed`/`skipped`/`conflict`/`failed`), semantic precompute (`_precompute_chunk_writer`), `ContextVar`-based `[repo]` log attribution. Returns 1 if any branch failed; conflicts self-heal and do not fail the run. **Reconciliation checkpoint**: after every worker joins, `_decide_reconciliation` gates on zero failures/conflicts, every repo accounted for, and every repo's `discovery_complete`; on a pass, `_reconcile` reuses the pre-fan-out stamp snapshot to compute each repo's retired branches and the run's full desired repo set, then calls the injected `reconcile_retired_fn`/`reconcile_removed_fn` seams (default: the real `indexer.store` primitives) on one post-fan-out connection — retired branches first, repo purge second. `MAX_PURGE_SHRINK_FRACTION = 0.5` (hardcoded, no config knob) withholds ONLY the purge, as a logged incident signal, if it would remove a strict majority of currently stored repos; retired-branch cleanup on survivors still applies. `ReconcileProgress.committed_any` drives an honest "partially reconciled" vs "left stale" failure message — never the raw exception, only its phase/repo/type name. Never called from worker-thread code (`_index_one*`) — a source-level tripwire test enforces it. | -| `languages.py` | Single source of truth shared by `parse.py` and `symbols.py`: `EXT_TO_LANG` (8 extensions -> tree-sitter language names), `SYMBOL_KINDS` (node type -> symbol kind per language), `MAX_FILE_BYTES` (1 MB), `SEMANTIC_CHUNK_MAX_CHARS` (2000, ~4 chars/token), and the frozen dataclasses `ParsedFile`, `Chunk`, `ExtractedSymbol`, `IndexCounts`. | +| `languages.py` | Single source of truth shared by `parse.py` and `symbols.py`: `EXT_TO_LANG` (8 extensions -> tree-sitter language names), `SYMBOL_KINDS` (node type -> symbol kind per language), `EDGE_NODE_KINDS` (node type -> reference-edge kind per language, Python-only until #85), `MAX_FILE_BYTES` (1 MB), `SEMANTIC_CHUNK_MAX_CHARS` (2000, ~4 chars/token), and the frozen dataclasses `ParsedFile`, `Chunk`, `ExtractedSymbol`, `ExtractedEdge`, `FileExtraction`, `IndexCounts` (now carries `edges`). | | `parse.py` | `iter_source_files`: walk an extracted tree, yield every text file (unknown extensions kept with `lang=None`, since grep runs over all files); skips `.git/`, symlinks, files > `MAX_FILE_BYTES` (stat before read), NUL-sniffed binaries, and UTF-8 decode failures; strips surviving NULs (Postgres `text` rejects them). `iter_chunks`: deterministic line-aligned chunking, no overlap, no mid-line splits, 1-based inclusive line ranges. | | `repo_config.py` | Pydantic schema for `config.yaml` (`RepoConfig` / `GitHubConnection` / `ExcludeRules`), `normalize_repo` (URL/SSH/bare -> canonical `org/repo`, GitHub hosts only), `parse_config` (pure) vs `read_workspace_config` (SDK I/O, wraps every failure in `ConfigError` with HTTP status so 404 "never synced" stays distinguishable from 403 "no permission"), and `effective_workers` (the semantic clamp). A connection with no `orgs`/`users`/`repos` fails validation. `index_concurrency`: 1-8, default 4 — a disk bound (2.5 GB peak per worker), not a CPU one. `semantic_max_chunks_per_repo`: optional top-level `{"org/repo": N}` map overriding `app.config.Settings.semantic_max_chunks_per_repo` per repo; keys canonicalised via `normalize_repo` and rejected on collision post-casefold, values `>= 1`. Deliberately import-light: pydantic + PyYAML + stdlib only. | | `resolve.py` | `resolve_repos`: enumerate org/user selectors, apply `ExcludeRules` to enumerated repos only (explicit `repos` entries always win, unfiltered), dedup case-insensitively keeping first-seen spelling, union each connection's `branches:` globs into per-repo `RepoEntry.branch_globs`, then fail fast: `EmptyConfigError` on zero repos (indexing nothing must not exit 0), `RepoCeilingError` above `MAX_REPOS` (500, overridable via `--max_repos`). Also matches `RepoConfig.semantic_max_chunks_per_repo` case-insensitively onto each resolved repo's `RepoEntry.semantic_max_chunks` (`None` if unmatched); an override key matching no resolved repo logs a WARNING (typo guard). Enumerator params are a test seam only, not provider dispatch. | -| `store.py` | `index_repo`: the single atomic unit of work for one (repo, branch) inside `with conn.begin():` — repos upsert, `repo_branches` CAS-baseline read, per-file array-union upsert on `(repo_id, path, content_sha)` with branch-membership union, delete-and-reinsert symbols, optional `chunk_writer` call, membership sweep (strip this branch from unseen rows, delete rows with empty `branches`; skipped with a WARNING on an empty seen-set), then CAS stamp (raises `StaleIndexError` on mismatch, rolling everything back). Pure DML, no TEMP tables (job role has no TEMP privilege on Lakebase). `reconcile_retired_branches` and `reconcile_removed_repos`: the desired-state storage primitives `job.py`'s reconciliation checkpoint calls — each its own `conn.begin()`, repo-scoped membership subtraction (never delete-by-path) and an exact-match repo purge respectively; neither decides WHAT is retired/desired, only applies a caller-supplied set (see `job.py`'s row). | -| `symbols.py` | `extract_symbols`: tree-sitter parse via `tree_sitter_language_pack`, full-tree walk (nested definitions captured), named nodes only, kinds from `SYMBOL_KINDS`, 1-based lines. Parser cache is per-thread (`threading.local`) as insurance against a future GIL-releasing `parse()`. | +| `store.py` | `index_repo`: the single atomic unit of work for one (repo, branch) inside `with conn.begin():` — repos upsert, `repo_branches` CAS-baseline read, per-file array-union upsert on `(repo_id, path, content_sha)` with branch-membership union, delete-and-reinsert `symbols` AND `reference_edges` (both keyed only by `file_id`, no natural key), optional `chunk_writer` call, membership sweep (strip this branch from unseen rows, delete rows with empty `branches`; skipped with a WARNING on an empty seen-set), then CAS stamp (raises `StaleIndexError` on mismatch, rolling everything back). `items` is `Iterable[tuple[ParsedFile, FileExtraction]]`; the `reference_edges` delete runs unconditionally, even when a file's `FileExtraction.edges` is empty, so stale rows never survive a re-index. Pure DML, no TEMP tables (job role has no TEMP privilege on Lakebase). `reconcile_retired_branches` and `reconcile_removed_repos`: the desired-state storage primitives `job.py`'s reconciliation checkpoint calls — each its own `conn.begin()`, repo-scoped membership subtraction (never delete-by-path) and an exact-match repo purge respectively; neither decides WHAT is retired/desired, only applies a caller-supplied set (see `job.py`'s row). | +| `symbols.py` | `extract_file` (#84): one tree-sitter parse, one full-tree walk, emitting both `symbols` and `reference_edges` candidates (`FileExtraction`) in the same pass — nested definitions captured, named nodes only for symbols, kinds from `SYMBOL_KINDS`/`EDGE_NODE_KINDS`, 1-based lines. Stack entries carry the innermost named enclosing definition so edges attribute to it in O(1) with no second walk. `extract_symbols` is a thin wrapper (`extract_file(pf).symbols`) kept for the existing call sites. Python-only edge helpers (`_python_call_target`, `_python_import_edges`) are the seam #85 generalizes to other languages. Parser cache is per-thread (`threading.local`) as insurance against a future GIL-releasing `parse()`. | ## For AI Agents @@ -38,12 +38,12 @@ The serverless indexing job (`code-search-index`, wired as a `python_wheel_task` - **Import discipline**: `repo_config.py` stays import-light (no httpx/SQLAlchemy/SDK at module level); `normalize_repo` is imported from `repo_config`, never from `job` (import cycle); `app.embed`'s databricks-sdk dependency is only imported when semantic is on. ### Testing Requirements -- `make test` (`pytest -m "unit or observability"`, no external deps): `tests/unit/test_branches.py`, `test_chunk_store.py`, `test_chunking.py`, `test_fetch.py`, `test_job.py`, `test_job_redaction.py`, `test_languages.py`, `test_parse.py`, `test_repo_config.py`, `test_resolve.py`, `test_symbols.py`, `test_store_chunk_writer.py`, `test_semantics_version_tripwire.py`. +- `make test` (`pytest -m "unit or observability"`, no external deps): `tests/unit/test_branches.py`, `test_chunk_store.py`, `test_chunking.py`, `test_edges.py`, `test_fetch.py`, `test_job.py`, `test_job_redaction.py`, `test_languages.py`, `test_parse.py`, `test_repo_config.py`, `test_resolve.py`, `test_symbols.py`, `test_store_chunk_writer.py`, `test_semantics_version_tripwire.py`. - `make test-integration` (needs Postgres): `tests/integration/test_store.py`, `test_store_chunk_writer.py`, `test_content_sha_parity.py` (the hard gate on `hashing.py` vs the migration backfill), `test_reconcile.py` (the `reconcile_retired_branches`/`reconcile_removed_repos` storage primitives directly), and `test_job_reconcile.py` (#59: the real `run()` end to end — real primitives, real engine, only GitHub HTTP faked). - Schema tests must stay fast: changes to `repo_config.py` must not add heavy imports. ### Common Patterns -- Frozen dataclasses as data carriers (`ParsedFile`, `Chunk`, `ExtractedSymbol`, `IndexCounts`, `RepoMeta`, `RepoEntry`, `BranchOutcome`); shared vocabulary lives in `languages.py` so `parse` and `symbols` can never disagree. +- Frozen dataclasses as data carriers (`ParsedFile`, `Chunk`, `ExtractedSymbol`, `ExtractedEdge`, `FileExtraction`, `IndexCounts`, `RepoMeta`, `RepoEntry`, `BranchOutcome`); shared vocabulary lives in `languages.py` so `parse` and `symbols` can never disagree. - `pg_insert(...).on_conflict_do_update(...).returning(...)` with a no-op `SET` on conflict — `DO NOTHING ... RETURNING` returns no row on conflict and would break the id bootstrap. - Delete-and-reinsert for child rows with no natural key (symbols, chunks). - `fnmatchcase`, never `fnmatch`: plain globs must behave identically on every platform. diff --git a/indexer/job.py b/indexer/job.py index f1510fb..83853be 100644 --- a/indexer/job.py +++ b/indexer/job.py @@ -130,7 +130,7 @@ reconcile_removed_repos, reconcile_retired_branches, ) -from indexer.symbols import extract_symbols +from indexer.symbols import extract_file logger = logging.getLogger("indexer.job") @@ -467,11 +467,12 @@ def run( ok += 1 assert outcome.counts is not None logger.info( - "indexed %s@%s: files=%d symbols=%d swept=%d", + "indexed %s@%s: files=%d symbols=%d edges=%d swept=%d", entry.name, outcome.branch, outcome.counts.files, outcome.counts.symbols, + outcome.counts.edges, outcome.counts.swept, ) @@ -1017,10 +1018,10 @@ def _index_one_branch( exc_info=True, ) chunk_writer = None - items = ((pf, extract_symbols(pf)) for pf in files) + items = ((pf, extract_file(pf)) for pf in files) else: # Lazy generator: files stream through the open transaction (bounded memory). - items = ((pf, extract_symbols(pf)) for pf in iter_source_files(root)) + items = ((pf, extract_file(pf)) for pf in iter_source_files(root)) with engine.connect() as conn: counts = index_fn( diff --git a/indexer/languages.py b/indexer/languages.py index 61f67b0..5eca56a 100644 --- a/indexer/languages.py +++ b/indexer/languages.py @@ -3,7 +3,9 @@ Both :mod:`indexer.parse` (extension -> language) and :mod:`indexer.symbols` (language -> tree-sitter node-type -> symbol kind) import from here so they can never disagree on language names. Language values MUST be valid -``tree_sitter_language_pack`` parser names. +``tree_sitter_language_pack`` parser names. ``EDGE_NODE_KINDS`` maps, per +language, tree-sitter node ``.type`` -> reference-edge kind (``call``/``import``); +a language absent from the map yields zero edges. """ from __future__ import annotations @@ -73,6 +75,46 @@ }, } +# Per language: tree-sitter node ``.type`` -> reference-edge kind stored in +# ``reference_edges``. Every value MUST be within the DB CHECK set +# (``ReferenceEdge.__table__``'s ``ck_reference_edges_edge_kind``), enforced by +# a unit test. +EDGE_NODE_KINDS: dict[str, dict[str, str]] = { + "python": { + "call": "call", + "import_statement": "import", + "import_from_statement": "import", + }, + "javascript": { + "call_expression": "call", # f(x), a.b.f(), obj?.m() + "new_expression": "call", # new Foo() -> constructor reference + "import_statement": "import", # ES imports (all specifier shapes) + }, + "typescript": { + "call_expression": "call", # incl. f(x) + "new_expression": "call", + "import_statement": "import", # incl. import type / import x = require(...) + }, + "tsx": { + "call_expression": "call", + "new_expression": "call", + "import_statement": "import", + }, + "go": { + "call_expression": "call", # f(), pkg.F(), obj.Method(); go/defer wrap this + "import_spec": "import", # per-spec node -> per-import anchors, single & grouped + }, + "java": { + "method_invocation": "call", # f(), obj.m(), C.stat(), this.n(), super.s(), obj.m() + "object_creation_expression": "call", # new Foo(), new a.b.Foo(), new Foo() + "import_declaration": "import", # import a.b.C; static; a.b.* + }, + "rust": { + "call_expression": "call", # f(), a::b::g(), Foo::new(), x.method() + "use_declaration": "import", # use trees: scoped, grouped, nested, self, wildcard, as + }, +} + @dataclass(frozen=True) class ParsedFile: @@ -104,6 +146,29 @@ class ExtractedSymbol: end_line: int +@dataclass(frozen=True) +class ExtractedEdge: + """A raw (unresolved) call/import reference site extracted from a source file. + + ``target`` is a candidate name/dotted-path, not a resolved symbol id (the + epic #82 rule: resolution happens at query time by name-join). ``enclosing`` + is the innermost NAMED enclosing definition; ``None`` means module scope. + """ + + kind: str # 'call' | 'import' -- must stay within the reference_edges DB CHECK set + target: str + line: int # 1-based + enclosing: ExtractedSymbol | None + + +@dataclass(frozen=True) +class FileExtraction: + """The one-walk result of parsing a file: its symbols and its reference edges.""" + + symbols: list[ExtractedSymbol] + edges: list[ExtractedEdge] + + @dataclass(frozen=True) class IndexCounts: """Row-count summary returned by ``index_repo`` for one repo's run.""" @@ -111,3 +176,4 @@ class IndexCounts: files: int symbols: int swept: int + edges: int diff --git a/indexer/store.py b/indexer/store.py index 1646d52..f85f0df 100644 --- a/indexer/store.py +++ b/indexer/store.py @@ -26,9 +26,9 @@ from sqlalchemy import Connection, delete, func, text, update from sqlalchemy.dialects.postgresql import insert as pg_insert -from app.db.models import INDEX_SEMANTICS_VERSION, File, Repo, RepoBranch, Symbol +from app.db.models import INDEX_SEMANTICS_VERSION, File, ReferenceEdge, Repo, RepoBranch, Symbol from indexer.hashing import content_sha -from indexer.languages import ExtractedSymbol, IndexCounts, ParsedFile +from indexer.languages import FileExtraction, IndexCounts, ParsedFile logger = logging.getLogger("indexer.store") @@ -76,7 +76,7 @@ def index_repo( branch: str, is_default: bool, head_sha: str, - items: Iterable[tuple[ParsedFile, list[ExtractedSymbol]]], + items: Iterable[tuple[ParsedFile, FileExtraction]], chunk_writer: ChunkWriter | None = None, ) -> IndexCounts: """Upsert one ``(repo, branch)``'s files/symbols and sweep this branch's stale membership. @@ -98,14 +98,14 @@ def index_repo( whose content already exists under another branch gets THIS branch unioned into its ``branches`` array (one row, shared content); a file whose content differs from every existing version gets its own row. Then - delete-and-reinsert its ``symbols`` (no natural key), then call - ``chunk_writer`` (if given) so chunk writes commit/roll back with the - rest of that file's row. Each processed file's ``(path, content_sha)`` is - collected into this branch's seen-set. + delete-and-reinsert its ``symbols`` and ``reference_edges`` (neither has a + natural key), then call ``chunk_writer`` (if given) so chunk writes + commit/roll back with the rest of that file's row. Each processed file's + ``(path, content_sha)`` is collected into this branch's seen-set. 4. Membership sweep, keyed on THIS branch's seen-set (never on ``commit``, which is ambiguous under dedup): strip ``branch`` from any row's ``branches`` array that is not in the seen-set, then delete any row left - with an empty array (cascades ``symbols``/``chunks``). Pure DML, no + with an empty array (cascades ``symbols``/``chunks``/``reference_edges``). Pure DML, no ``TEMP TABLE`` (the job role has no guaranteed database-level TEMP privilege on Lakebase). **Skipped (with a WARNING) when the parsed file set is empty** -- an empty seen-set would otherwise strip ``branch`` from @@ -121,6 +121,7 @@ def index_repo( """ file_count = 0 symbol_count = 0 + edge_count = 0 seen_paths: list[str] = [] seen_shas: list[str] = [] @@ -157,7 +158,7 @@ def index_repo( ) baseline_commit, baseline_version = conn.execute(branch_stmt).one() - for pf, syms in items: + for pf, ex in items: sha = content_sha(pf.content) file_stmt = ( pg_insert(File) @@ -197,7 +198,7 @@ def index_repo( seen_shas.append(sha) conn.execute(delete(Symbol).where(Symbol.file_id == file_id)) - if syms: + if ex.symbols: conn.execute( pg_insert(Symbol), [ @@ -209,10 +210,34 @@ def index_repo( "start_line": s.start_line, "end_line": s.end_line, } - for s in syms + for s in ex.symbols ], ) - symbol_count += len(syms) + symbol_count += len(ex.symbols) + + # UNCONDITIONAL, same as the symbols delete above: a file whose edges + # all vanish (e.g. every call/import site removed) must shed its stale + # rows even when this run's ex.edges is empty. + conn.execute(delete(ReferenceEdge).where(ReferenceEdge.file_id == file_id)) + if ex.edges: + conn.execute( + pg_insert(ReferenceEdge), + [ + { + "file_id": file_id, + "repo_id": repo_id, + "edge_kind": e.kind, + "target_name": e.target, + "line": e.line, + "enclosing_name": e.enclosing.name if e.enclosing else None, + "enclosing_kind": e.enclosing.kind if e.enclosing else None, + "enclosing_start_line": e.enclosing.start_line if e.enclosing else None, + "enclosing_end_line": e.enclosing.end_line if e.enclosing else None, + } + for e in ex.edges + ], + ) + edge_count += len(ex.edges) if chunk_writer is not None: chunk_writer(conn, repo_id, file_id, pf) @@ -236,7 +261,7 @@ def index_repo( baseline_version=baseline_version, ) - return IndexCounts(files=file_count, symbols=symbol_count, swept=swept) + return IndexCounts(files=file_count, symbols=symbol_count, swept=swept, edges=edge_count) def _sweep_membership( @@ -380,9 +405,9 @@ def reconcile_retired_branches( emptied array, which would leave a zombie row the next step's cardinality check can't see. 3. Delete rows left with zero membership (``cardinality(branches) = 0``); - a strict subset of step 2's rowcount. ``symbols`` and ``chunks`` are - removed by FK cascade, the same invariant ``_sweep_membership`` relies - on (see its docstring, indexer/store.py). + a strict subset of step 2's rowcount. ``symbols``, ``chunks``, and + ``reference_edges`` are removed by FK cascade, the same invariant + ``_sweep_membership`` relies on (see its docstring, indexer/store.py). 4. Delete the matching ``repo_branches`` registry rows. Invariants: repo-scoped on every statement; membership subtraction only, @@ -467,11 +492,13 @@ def reconcile_removed_repos(conn: Connection, *, desired_repos: Collection[str]) RETURNING name`` -- one atomic statement, no prior ``SELECT``. Every victim row's cascade is proven at the database level, not the ORM: ``repos`` -> ``files`` and ``repos`` -> ``symbols`` and ``repos`` -> - ``repo_branches`` are direct ``ON DELETE CASCADE`` foreign keys - (``app/db/models.py``), ``files`` -> ``symbols`` is the same, and + ``repo_branches`` and ``repos`` -> ``reference_edges`` are direct + ``ON DELETE CASCADE`` foreign keys (``app/db/models.py``), ``files`` -> + ``symbols`` and ``files`` -> ``reference_edges`` are the same, and ``files`` -> ``chunks`` cascades via the raw DDL in ``app/alembic/versions/0004_semantic_chunks.py`` -- so a two-hop - ``repos`` -> ``files`` -> ``chunks`` delete fires as one statement. + ``repos`` -> ``files`` -> ``chunks``/``reference_edges`` delete fires as + one statement. ``RETURNING name`` reads back only ``repos`` rows, i.e. exactly the purged repo names, with no separate count query needed. The job role already holds ``DELETE`` on every table in this schema diff --git a/indexer/symbols.py b/indexer/symbols.py index e7c6ee4..ae9efe0 100644 --- a/indexer/symbols.py +++ b/indexer/symbols.py @@ -16,14 +16,45 @@ from __future__ import annotations import threading +from collections.abc import Callable from typing import Any from tree_sitter_language_pack import get_parser -from indexer.languages import SYMBOL_KINDS, ExtractedSymbol, ParsedFile +from indexer.languages import ( + EDGE_NODE_KINDS, + SYMBOL_KINDS, + ExtractedEdge, + ExtractedSymbol, + FileExtraction, + ParsedFile, +) _PARSER_CACHE = threading.local() +# Per-language SYMBOL_KINDS + EDGE_NODE_KINDS merged into one ``node.type -> (tag, +# value)`` map, built once per language and cached here. The two source maps never +# share a node type (a definition node is never also a call/import node), so this +# is a lossless merge -- and it turns the hot walk's two dict lookups per node +# (one symbol-map miss, one edge-map miss, for every ordinary node) into one. +_COMBINED_CACHE: dict[str, dict[str, tuple[str, str]]] = {} + + +def _combined_kinds(lang: str) -> dict[str, tuple[str, str]] | None: + combined = _COMBINED_CACHE.get(lang) + if combined is not None: + return combined + kind_map = SYMBOL_KINDS.get(lang) + if kind_map is None: + return None + combined = {node_type: ("symbol", kind) for node_type, kind in kind_map.items()} + combined.update( + (node_type, ("edge", edge_kind)) + for node_type, edge_kind in EDGE_NODE_KINDS.get(lang, {}).items() + ) + _COMBINED_CACHE[lang] = combined + return combined + def _parser_for(lang: str) -> Any: cache: dict[str, Any] | None = getattr(_PARSER_CACHE, "parsers", None) @@ -35,37 +66,570 @@ def _parser_for(lang: str) -> Any: return parser -def extract_symbols(pf: ParsedFile) -> list[ExtractedSymbol]: - """Return the named symbols in ``pf``; ``[]`` for files with no kind map. +def extract_file(pf: ParsedFile) -> FileExtraction: + """Return the named symbols and reference edges in ``pf``, from one parse and one walk. + + Walks the whole parse tree (so nested definitions -- a method inside a class -- + are captured) exactly once, emitting both symbols and edges as it goes; a file + whose language has no kind map short-circuits before parsing. Anonymous + definition nodes (no ``name`` field) are skipped for symbols but stay + transparent for edge attribution: their children inherit the enclosing symbol + they would otherwise have replaced. Edges attribute to the innermost NAMED + enclosing definition on the stack at the time the call/import node is visited; + ``None`` means module/top-level scope. Line numbers are 1-based. - Walks the whole parse tree (so nested definitions — a method inside a class — - are captured). Anonymous nodes (no ``name`` field) are skipped. Line numbers - are 1-based. + Two parallel stacks (node, enclosing-symbol) rather than one stack of pairs -- + pushing a same-enclosing child run via ``[enclosing] * len(children)`` is a + single C-level list replication instead of N per-child tuple allocations, + measurably cheaper for the common case (most nodes don't change the enclosing). + + Call/import extraction is dispatched per-language via ``_EDGE_EXTRACTORS``, + resolved once per file (not per node) immediately after the combined map is + found, since that lookup already guarantees ``lang in SYMBOL_KINDS``. """ - if pf.lang is None: - return [] - kind_map = SYMBOL_KINDS.get(pf.lang) - if kind_map is None: - return [] + lang = pf.lang + if lang is None: + return FileExtraction(symbols=[], edges=[]) + combined = _combined_kinds(lang) + if combined is None: + return FileExtraction(symbols=[], edges=[]) + call_edge, import_edges = _EDGE_EXTRACTORS[lang] - tree = _parser_for(pf.lang).parse(pf.content.encode("utf-8")) + tree = _parser_for(lang).parse(pf.content.encode("utf-8")) symbols: list[ExtractedSymbol] = [] + edges: list[ExtractedEdge] = [] + + node_stack: list[Any] = [tree.root_node] + enclosing_stack: list[ExtractedSymbol | None] = [None] + while node_stack: + node = node_stack.pop() + enclosing = enclosing_stack.pop() + child_enclosing = enclosing - cursor_stack = [tree.root_node] - while cursor_stack: - node = cursor_stack.pop() - kind = kind_map.get(node.type) - if kind is not None: - name_node = node.child_by_field_name("name") - if name_node is not None and name_node.text is not None: - symbols.append( - ExtractedSymbol( + tag_kind = combined.get(node.type) + if tag_kind is not None: + tag, kind = tag_kind + if tag == "symbol": + name_node = node.child_by_field_name("name") + if name_node is not None and name_node.text is not None: + symbol = ExtractedSymbol( name=name_node.text.decode("utf-8"), kind=kind, start_line=node.start_point[0] + 1, end_line=node.end_point[0] + 1, ) + symbols.append(symbol) + child_enclosing = symbol + elif kind == "call": + edge = call_edge(node, enclosing) + if edge is not None: + edges.append(edge) + else: # kind == "import" + edges.extend(import_edges(node, enclosing)) + + children = node.children + if children: + node_stack.extend(reversed(children)) + enclosing_stack.extend([child_enclosing] * len(children)) + + return FileExtraction(symbols=symbols, edges=edges) + + +def extract_symbols(pf: ParsedFile) -> list[ExtractedSymbol]: + """Return the named symbols in ``pf``; ``[]`` for files with no kind map. + + Thin wrapper over :func:`extract_file` kept for the existing unit-test + surface and any external callers that only need symbols. + """ + return extract_file(pf).symbols + + +def _python_call_target(node: Any) -> str | None: + """Rightmost identifier of a ``call`` node's callee, or ``None`` for candidates with none. + + ``f(...)`` -> ``f``; ``a.b.f(...)``/``self.f(...)`` -> ``f`` (the grammar's + ``attribute`` field on an ``attribute`` node is always the rightmost + identifier, so no manual recursion is needed). Callees with no rightmost + identifier -- ``xs[0]()``, the outer call of ``f()()`` -- are skipped. + """ + func = node.child_by_field_name("function") + if func is None: + return None + if func.type == "identifier": + return func.text.decode("utf-8") if func.text is not None else None + if func.type == "attribute": + attr = func.child_by_field_name("attribute") + if attr is not None and attr.text is not None: + return attr.text.decode("utf-8") + return None + return None + + +def _python_call_edge(node: Any, enclosing: ExtractedSymbol | None) -> ExtractedEdge | None: + target = _python_call_target(node) + if target is None: + return None + return ExtractedEdge( + kind="call", target=target, line=node.start_point[0] + 1, enclosing=enclosing + ) + + +def _python_import_name(node: Any) -> tuple[str | None, Any]: + """Dotted-path text and line-anchor node for one ``name``-field child of an import. + + ``dotted_name`` -> its own text (``import a.b.c`` -> ``a.b.c``). ``aliased_import`` + -> its inner ``name`` field's text, ignoring the alias (``import a.b.c as d`` -> + ``a.b.c``; the alias is a local binding, not the target). + """ + if node.type == "aliased_import": + inner = node.child_by_field_name("name") + if inner is not None and inner.text is not None: + return inner.text.decode("utf-8"), node + return None, node + if node.text is not None: + return node.text.decode("utf-8"), node + return None, node + + +def _python_join_module(module_prefix: str, name: str) -> str: + """Join a ``from``-import's module path to one imported name (D5's join rule). + + Pure-dots relative modules (module text ending in ``.``, e.g. ``from . import x`` + -> ``.``) concatenate directly (-> ``.x``); anything else (``a.b``, ``..p``) joins + with a literal dot (-> ``a.b.c``, ``..p.q``). + """ + if not module_prefix: + return name + if module_prefix.endswith("."): + return f"{module_prefix}{name}" + return f"{module_prefix}.{name}" + + +def _python_import_edges(node: Any, enclosing: ExtractedSymbol | None) -> list[ExtractedEdge]: + """Edges for one ``import_statement``/``import_from_statement`` node (D5). + + ``import a.b.c, d`` -> one edge per ``name``-field child, each the full dotted + path as written (alias-insensitive). ``from a.b import c, d as e`` -> the module + path joined to each *original* imported name. ``from a.b import *`` -> one edge + for the module path itself, anchored at the ``wildcard_import`` node's line. + Per-name edges take the name node's own start line (correct for multi-line + parenthesized imports). + """ + edges: list[ExtractedEdge] = [] + if node.type == "import_statement": + for name_node in node.children_by_field_name("name"): + target, anchor = _python_import_name(name_node) + if target is not None: + edges.append( + ExtractedEdge( + kind="import", + target=target, + line=anchor.start_point[0] + 1, + enclosing=enclosing, + ) + ) + return edges + + # import_from_statement + module_node = node.child_by_field_name("module_name") + module_prefix = ( + module_node.text.decode("utf-8") + if module_node is not None and module_node.text is not None + else "" + ) + name_nodes = node.children_by_field_name("name") + if name_nodes: + for name_node in name_nodes: + bare, anchor = _python_import_name(name_node) + if bare is not None: + edges.append( + ExtractedEdge( + kind="import", + target=_python_join_module(module_prefix, bare), + line=anchor.start_point[0] + 1, + enclosing=enclosing, + ) + ) + return edges + + if module_prefix: + wildcard = next((c for c in node.children if c.type == "wildcard_import"), None) + if wildcard is not None: + edges.append( + ExtractedEdge( + kind="import", + target=module_prefix, + line=wildcard.start_point[0] + 1, + enclosing=enclosing, + ) + ) + return edges + + +def _js_string_fragment_text(string_node: Any) -> str: + """Unquoted text of a JS/TS ``string`` node (its ``string_fragment`` child).""" + frag = next((c for c in string_node.children if c.type == "string_fragment"), None) + return frag.text.decode("utf-8") if frag is not None and frag.text is not None else "" + + +def _js_call_edge(node: Any, enclosing: ExtractedSymbol | None) -> ExtractedEdge | None: + """Rightmost-name target for a JS/TS/TSX ``call_expression``/``new_expression`` (#85). + + Callee field is ``function`` for calls, ``constructor`` for ``new``. A bare + ``identifier`` callee is its own target (``f()``, ``require(...)``); a + ``member_expression`` callee targets its ``property`` field, unaffected by an + optional chain (``a.b.f()``/``obj?.m()`` -> ``f``/``m``; ``new a.b.Foo()`` -> + ``Foo``). Any other callee shape -- subscript, an outer call-of-call, or the + ``import`` keyword node of a dynamic ``import(...)`` -- is skipped. + """ + field = "constructor" if node.type == "new_expression" else "function" + func = node.child_by_field_name(field) + if func is None: + return None + target: str | None = None + if func.type == "identifier": + target = func.text.decode("utf-8") if func.text is not None else None + elif func.type == "member_expression": + prop = func.child_by_field_name("property") + if prop is not None and prop.text is not None: + target = prop.text.decode("utf-8") + if target is None: + return None + return ExtractedEdge( + kind="call", target=target, line=node.start_point[0] + 1, enclosing=enclosing + ) + + +def _js_import_edges(node: Any, enclosing: ExtractedSymbol | None) -> list[ExtractedEdge]: + """Edges for one JS/TS/TSX ``import_statement`` (#85), per the A8 anchoring rule. + + TS ``import x = require('legacy')`` is handled first via its + ``import_require_clause`` child (its own ``source`` field), anchored at the + statement line. Otherwise the statement's own ``source`` field gives the + module string; an empty source or missing ``import_clause`` (side-effect + import) yields a single statement-anchored edge for the bare module (or none, + for an empty source). Within a clause: a bare ``identifier`` (default import) + or a ``namespace_import`` each yield one statement-anchored edge targeting the + module; each ``import_specifier`` in a ``named_imports`` block yields one + specifier-anchored edge (alias ignored -- D5) targeting ``module.name``. + """ + stmt_line = node.start_point[0] + 1 + require_clause = next((c for c in node.children if c.type == "import_require_clause"), None) + if require_clause is not None: + req_source = require_clause.child_by_field_name("source") + if req_source is None: + return [] + target = _js_string_fragment_text(req_source) + if not target: + return [] + return [ExtractedEdge(kind="import", target=target, line=stmt_line, enclosing=enclosing)] + + source_node = node.child_by_field_name("source") + if source_node is None: + return [] + source = _js_string_fragment_text(source_node) + if not source: + return [] + + clause = next((c for c in node.children if c.type == "import_clause"), None) + if clause is None: + return [ExtractedEdge(kind="import", target=source, line=stmt_line, enclosing=enclosing)] + + edges: list[ExtractedEdge] = [] + for child in clause.children: + if child.type in ("identifier", "namespace_import"): + edges.append( + ExtractedEdge(kind="import", target=source, line=stmt_line, enclosing=enclosing) + ) + elif child.type == "named_imports": + for spec in child.children: + if spec.type != "import_specifier": + continue + name_node = spec.child_by_field_name("name") + if name_node is None or name_node.text is None: + continue + edges.append( + ExtractedEdge( + kind="import", + target=f"{source}.{name_node.text.decode('utf-8')}", + line=spec.start_point[0] + 1, + enclosing=enclosing, + ) + ) + return edges + + +def _go_call_edge(node: Any, enclosing: ExtractedSymbol | None) -> ExtractedEdge | None: + """Rightmost-name target for a Go ``call_expression`` (#85). + + ``function`` field ``identifier`` -> its own text (``f()``); ``selector_expression`` + -> its ``field`` field text (``pkg.F()`` -> ``F``, ``obj.Method()`` -> ``Method``). + ``go``/``defer`` wrap an ordinary inner ``call_expression``, so they need no + special-casing here -- the walk visits the inner node directly. + """ + func = node.child_by_field_name("function") + if func is None: + return None + target: str | None = None + if func.type == "identifier": + target = func.text.decode("utf-8") if func.text is not None else None + elif func.type == "selector_expression": + field = func.child_by_field_name("field") + if field is not None and field.text is not None: + target = field.text.decode("utf-8") + if target is None: + return None + return ExtractedEdge( + kind="call", target=target, line=node.start_point[0] + 1, enclosing=enclosing + ) + + +def _go_import_edges(node: Any, enclosing: ExtractedSymbol | None) -> list[ExtractedEdge]: + """One edge per Go ``import_spec`` (#85), mapped instead of ``import_declaration``. + + Per-spec node -> per-import anchors for both single and grouped + (``import ( ... )``) forms; an empty group yields zero ``import_spec`` nodes and + needs no special-casing. Target is the *interior* text of the ``path`` field's + string-literal-content child (A7, binding) -- not ``node.text``, which includes + the quotes/backticks. The optional ``name`` field (alias, ``.``, ``_``) is + ignored (D5): dot/blank imports still target the package path. + """ + path_node = node.child_by_field_name("path") + if path_node is None: + return [] + content = next( + ( + c + for c in path_node.children + if c.type in ("interpreted_string_literal_content", "raw_string_literal_content") + ), + None, + ) + if content is not None and content.text is not None: + target = content.text.decode("utf-8") + elif path_node.text is not None: + target = path_node.text.decode("utf-8").strip('"`') + else: + target = "" + if not target: + return [] + return [ + ExtractedEdge( + kind="import", target=target, line=node.start_point[0] + 1, enclosing=enclosing + ) + ] + + +def _java_type_name(type_node: Any) -> str | None: + """Rightmost simple type name for a Java ``object_creation_expression`` target (A1). + + A ``generic_type`` first descends to its underlying type node (its first named + child, dropping ``type_arguments``). A ``type_identifier`` is its own text + (``Foo``). A ``scoped_type_identifier`` has no ``name`` field -- the grammar + exposes its segments as *unnamed* ``type_identifier`` children -- so the target + is the text of the **last** such child (``a.b.Foo`` -> ``Foo``). Any other shape + is skipped. + """ + if type_node.type == "generic_type": + inner = type_node.named_children[0] if type_node.named_children else None + if inner is None: + return None + type_node = inner + if type_node.type == "type_identifier": + return type_node.text.decode("utf-8") if type_node.text is not None else None + if type_node.type == "scoped_type_identifier": + last = None + for child in type_node.children: + if child.type == "type_identifier": + last = child + return last.text.decode("utf-8") if last is not None and last.text is not None else None + return None + + +def _java_call_edge(node: Any, enclosing: ExtractedSymbol | None) -> ExtractedEdge | None: + """Target for a Java ``method_invocation``/``object_creation_expression`` (#85). + + ``method_invocation`` -> its ``name`` field text, regardless of the optional + ``object`` field or generic ``type_arguments`` (``obj.m()`` -> ``m``). + ``object_creation_expression`` -> :func:`_java_type_name` of its ``type`` field + (A1). + """ + if node.type == "method_invocation": + name = node.child_by_field_name("name") + if name is None or name.text is None: + return None + target: str | None = name.text.decode("utf-8") + else: # object_creation_expression + type_node = node.child_by_field_name("type") + target = _java_type_name(type_node) if type_node is not None else None + if target is None: + return None + return ExtractedEdge( + kind="call", target=target, line=node.start_point[0] + 1, enclosing=enclosing + ) + + +def _java_import_edges(node: Any, enclosing: ExtractedSymbol | None) -> list[ExtractedEdge]: + """One edge per Java ``import_declaration`` (#85) -- no grouping in this grammar. + + Target is the text of the statement's ``scoped_identifier`` (or bare + ``identifier``) child, as written: plain (``a.b.C``), static (``a.b.C.m`` -- + the full text already includes the member), and wildcard (``a.b`` -- the + package; the sibling ``asterisk`` node carries no field and is ignored). + """ + ident = next((c for c in node.children if c.type in ("scoped_identifier", "identifier")), None) + if ident is None or ident.text is None: + return [] + return [ + ExtractedEdge( + kind="import", + target=ident.text.decode("utf-8"), + line=node.start_point[0] + 1, + enclosing=enclosing, + ) + ] + + +def _rust_call_edge(node: Any, enclosing: ExtractedSymbol | None) -> ExtractedEdge | None: + """Rightmost-name target for a Rust ``call_expression`` (#85). + + ``function`` field ``identifier`` -> its own text (``f()``); ``scoped_identifier`` + -> its ``name`` field, rightmost (``a::b::g()`` -> ``g``, ``Foo::new()`` -> ``new``); + ``field_expression`` -> its ``field`` field (``x.method()`` -> ``method``). Any + other callee -- notably ``macro_invocation`` (``println!(...)``), which is + unmapped and so never even reaches here -- is skipped. + """ + func = node.child_by_field_name("function") + if func is None: + return None + target: str | None = None + if func.type == "identifier": + target = func.text.decode("utf-8") if func.text is not None else None + elif func.type == "scoped_identifier": + name = func.child_by_field_name("name") + if name is not None and name.text is not None: + target = name.text.decode("utf-8") + elif func.type == "field_expression": + field = func.child_by_field_name("field") + if field is not None and field.text is not None: + target = field.text.decode("utf-8") + if target is None: + return None + return ExtractedEdge( + kind="call", target=target, line=node.start_point[0] + 1, enclosing=enclosing + ) + + +def _rust_join_path(prefix: str, segment: str) -> str: + """Join a Rust use-tree ``prefix`` accumulator to one more path ``segment``.""" + return segment if not prefix else f"{prefix}::{segment}" + + +def _rust_use_tree_edges( + node: Any, prefix: str, enclosing: ExtractedSymbol | None +) -> list[ExtractedEdge]: + """Recursive use-tree descent for one node of a Rust ``use_declaration`` (A2/A3). + + ``prefix`` is the accumulated path text from enclosing ``scoped_use_list`` + levels (``""`` at the top). A leaf ``identifier``/``scoped_identifier`` emits + ``join(prefix, its text)``; a ``use_as_clause`` emits ``join(prefix, path-field + text)`` (alias ignored -- D5); a ``use_wildcard`` emits ``join(prefix, inner + path text)`` if it has an inner path child, else ``prefix`` unchanged; a bare + ``self`` node (only reachable as a ``use_list`` item) emits ``prefix`` + unchanged; a ``scoped_use_list`` extends the prefix with its own ``path`` field + and recurses into each named child of its ``list``. Every edge anchors at its + own leaf node's start line. + """ + if node.type == "self": + return ( + [ + ExtractedEdge( + kind="import", target=prefix, line=node.start_point[0] + 1, enclosing=enclosing ) - cursor_stack.extend(reversed(node.children)) + ] + if prefix + else [] + ) + if node.type in ("identifier", "scoped_identifier"): + if node.text is None: + return [] + return [ + ExtractedEdge( + kind="import", + target=_rust_join_path(prefix, node.text.decode("utf-8")), + line=node.start_point[0] + 1, + enclosing=enclosing, + ) + ] + if node.type == "use_as_clause": + path_node = node.child_by_field_name("path") + if path_node is None or path_node.text is None: + return [] + return [ + ExtractedEdge( + kind="import", + target=_rust_join_path(prefix, path_node.text.decode("utf-8")), + line=node.start_point[0] + 1, + enclosing=enclosing, + ) + ] + if node.type == "use_wildcard": + inner = next( + (c for c in node.children if c.type in ("identifier", "scoped_identifier")), None + ) + target = ( + _rust_join_path(prefix, inner.text.decode("utf-8")) + if inner is not None and inner.text is not None + else prefix + ) + if not target: + return [] + return [ + ExtractedEdge( + kind="import", target=target, line=node.start_point[0] + 1, enclosing=enclosing + ) + ] + if node.type == "scoped_use_list": + path_node = node.child_by_field_name("path") + new_prefix = ( + _rust_join_path(prefix, path_node.text.decode("utf-8")) + if path_node is not None and path_node.text is not None + else prefix + ) + list_node = node.child_by_field_name("list") + edges: list[ExtractedEdge] = [] + if list_node is not None: + for child in list_node.named_children: + edges.extend(_rust_use_tree_edges(child, new_prefix, enclosing)) + return edges + if node.type == "use_list": + # A prefix-less group -- ``use {std::io, std::fmt};`` -- is a bare + # ``use_list`` with no enclosing ``scoped_use_list``; each item keeps the + # current (possibly empty) prefix unchanged. + edges = [] + for child in node.named_children: + edges.extend(_rust_use_tree_edges(child, prefix, enclosing)) + return edges + return [] + + +def _rust_import_edges(node: Any, enclosing: ExtractedSymbol | None) -> list[ExtractedEdge]: + """Edges for one Rust ``use_declaration`` (#85): recurse its ``argument`` use-tree.""" + argument = node.child_by_field_name("argument") + if argument is None: + return [] + return _rust_use_tree_edges(argument, "", enclosing) + + +CallEdgeFn = Callable[[Any, "ExtractedSymbol | None"], "ExtractedEdge | None"] +ImportEdgesFn = Callable[[Any, "ExtractedSymbol | None"], list["ExtractedEdge"]] - return symbols +_EDGE_EXTRACTORS: dict[str, tuple[CallEdgeFn, ImportEdgesFn]] = { + "python": (_python_call_edge, _python_import_edges), + "javascript": (_js_call_edge, _js_import_edges), + "typescript": (_js_call_edge, _js_import_edges), + "tsx": (_js_call_edge, _js_import_edges), + "go": (_go_call_edge, _go_import_edges), + "java": (_java_call_edge, _java_import_edges), + "rust": (_rust_call_edge, _rust_import_edges), +} diff --git a/scripts/measure_reference_resolution.py b/scripts/measure_reference_resolution.py new file mode 100644 index 0000000..6ebceb0 --- /dev/null +++ b/scripts/measure_reference_resolution.py @@ -0,0 +1,174 @@ +"""Measure the ``reference_edges`` resolution distribution (#86, AC4). + +Offline companion to :mod:`app.search.references`. Reuses :func:`build_candidate_count_select` +and :func:`classify_resolution` from that module -- the SAME join semantics and branch-scoping +predicate ``resolve_references``'s query 2 uses -- so this script's distribution agrees with the +live serve path BY CONSTRUCTION, rather than re-implementing the join and risking drift. + +**Pinned: ``call`` edges are the primary headline metric**, the only number compared against +the prior-art baseline (28.8% unique / 33.4% ambiguous / 37.8% external). The ``import``-edge +distribution is reported separately, labeled informational -- it is expected to resolve +close to 0% (import targets are largely external/stdlib modules; see D3's exact-dotted-match +decision in ``docs/runbooks/reference-edges.md``) and is NOT compared against the call-edge +baseline. + +``build_candidate_count_select`` has no per-request latency/timeout budget (unlike +``resolve_references``): it runs a correlated subquery per site, which is fine for a one-off +offline measurement but would be an unacceptable query shape to expose to a live caller. + +Usage: ``uv run python scripts/measure_reference_resolution.py [--edge-kind call|import|both] +[--branch BRANCH] [--target NAME] [--use-resolver]``. Requires the standard ``PG*``/Lakebase +connection env (see ``app.db.client.create_db_engine``). +""" + +from __future__ import annotations + +import argparse +from collections.abc import Sequence +from typing import Any + +from app.db.client import create_db_engine +from app.search.references import ( + build_candidate_count_select, + classify_resolution, + resolve_references, +) + +# Prior-art comparison target from the epic's deep-dive probe (not a repo-sourced constant) -- +# re-measured here, which is what actually satisfies AC4. +CALL_BASELINE_PCT = {"unique": 28.8, "ambiguous": 33.4, "unresolved": 37.8} + +_BUCKETS = ("unique", "ambiguous", "unresolved") + + +# --------------------------------------------------------------------------- pure helpers + + +def bucket_counts(counts: Sequence[int]) -> dict[str, int]: + """Classify each site's true candidate count into a resolution bucket via the SAME + :func:`~app.search.references.classify_resolution` the resolver itself uses.""" + buckets = {bucket: 0 for bucket in _BUCKETS} + for count in counts: + buckets[classify_resolution(count)] += 1 + return buckets + + +def ambiguous_histogram(counts: Sequence[int]) -> dict[int, int]: + """Candidate-count histogram restricted to ambiguous sites (count >= 2).""" + histogram: dict[int, int] = {} + for count in counts: + if count >= 2: + histogram[count] = histogram.get(count, 0) + 1 + return histogram + + +def format_distribution( + label: str, buckets: dict[str, int], *, baseline: dict[str, float] | None = None +) -> str: + total = sum(buckets.values()) + lines = [f"{label} (n={total}):"] + for bucket in _BUCKETS: + count = buckets[bucket] + pct = (count / total * 100) if total else 0.0 + line = f" {bucket:<11s} {count:>7d} {pct:5.1f}%" + if baseline is not None: + line += f" (baseline {baseline[bucket]:.1f}%)" + lines.append(line) + return "\n".join(lines) + + +def format_histogram(histogram: dict[int, int]) -> str: + if not histogram: + return " (no ambiguous sites)" + return "\n".join( + f" candidate_count={count:<4d} sites={histogram[count]}" for count in sorted(histogram) + ) + + +# ------------------------------------------------------------------------------ DB legs + + +def _fetch_counts( + conn: Any, *, edge_kind: str, branch: str | None, target: str | None +) -> list[int]: + """Every matching site's TRUE candidate count, via the shared count builder (D10). + + Runs in its own ``conn.begin()``/commit: a bare ``conn.execute()`` auto-begins an + implicit transaction that stays open until explicitly closed, which would otherwise + collide with ``resolve_references``'s own ``with conn.begin():`` on a later call + reusing this same connection (``--use-resolver``). + """ + stmt = build_candidate_count_select(edge_kind=edge_kind, branch=branch) + with conn.begin(): + rows = conn.execute(stmt).all() + if target is not None: + rows = [row for row in rows if row.target_name == target] + return [row.candidate_count for row in rows] + + +def _resolver_spot_check( + conn: Any, *, edge_kind: str, branch: str | None, row_limit: int +) -> dict[str, int]: + """Drive ``resolve_references`` directly over a ``row_limit``-bounded sample and bucket its + OWN ``site.resolution`` field -- a live-path sanity check, not a full-corpus comparison + (the live resolver's query 2 is window-bounded per name; this builder's isn't).""" + result = resolve_references(conn, edge_kind=edge_kind, branch=branch, row_limit=row_limit) + buckets = {bucket: 0 for bucket in _BUCKETS} + for site in result.sites: + buckets[site.resolution] += 1 + return buckets + + +# --------------------------------------------------------------------------------- CLI + + +def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--edge-kind", choices=("call", "import", "both"), default="both") + parser.add_argument( + "--branch", default=None, help="branch scope (default: each repo's default branch)" + ) + parser.add_argument("--target", default=None, help="restrict to one target_name (debugging)") + parser.add_argument( + "--use-resolver", + action="store_true", + help="also spot-check via resolve_references directly (bounded sample, not full corpus)", + ) + parser.add_argument("--resolver-row-limit", type=int, default=500) + args = parser.parse_args(argv) + + kinds = ["call", "import"] if args.edge_kind == "both" else [args.edge_kind] + + engine = create_db_engine() + with engine.connect() as conn: + for kind in kinds: + counts = _fetch_counts(conn, edge_kind=kind, branch=args.branch, target=args.target) + buckets = bucket_counts(counts) + baseline = CALL_BASELINE_PCT if kind == "call" else None + label = ( + "call edges -- HEADLINE AC4 metric" + if kind == "call" + else "import edges -- informational, expected ~0% resolution (validates D3)" + ) + print(format_distribution(label, buckets, baseline=baseline)) + print("ambiguous candidate-count histogram:") + print(format_histogram(ambiguous_histogram(counts))) + print() + + if args.use_resolver: + sample_buckets = _resolver_spot_check( + conn, edge_kind=kind, branch=args.branch, row_limit=args.resolver_row_limit + ) + print( + format_distribution( + f"{kind} edges -- resolve_references spot check " + f"(row_limit={args.resolver_row_limit}, bounded sample)", + sample_buckets, + ) + ) + print() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/smoke.py b/scripts/smoke.py index 6840c47..cb3ead9 100644 --- a/scripts/smoke.py +++ b/scripts/smoke.py @@ -101,6 +101,49 @@ def validate_search_payload(payload: dict[str, Any]) -> Result: return Result(True, f"search_code: {file_count} file(s) with well-formed matches") +def validate_references_payload(payload: dict[str, Any]) -> Result: + """Assert the reference-tool envelope SHAPE (``find_references`` / ``list_imports``). + + Deliberately shape-only and zero-sites-accepting: a live corpus's symbols are + unpredictable, so requiring a non-empty result would false-RED. A missing key or wrong + type still fails (never false-GREEN): ``kind`` is a str; ``direction`` (when present) is a + str; ``sites`` is a list with ``site_count == len(sites)``; ``resolution_summary`` is a dict + with EXACTLY the keys ``{"unique","ambiguous","unresolved"}``, all ints; ``truncated`` and + ``query_too_broad`` are present and bool; each site has a str ``file``, an int ``line``, and + a list ``candidates``. + """ + if not isinstance(payload.get("kind"), str): + return Result(False, f"references: kind = {payload.get('kind')!r}, expected a str") + if "direction" in payload and not isinstance(payload["direction"], str): + return Result(False, f"references: direction = {payload['direction']!r}, expected a str") + sites = payload.get("sites") + if not isinstance(sites, list): + return Result(False, "references: 'sites' is not a list") + if payload.get("site_count") != len(sites): + return Result( + False, + f"references: site_count = {payload.get('site_count')!r}, expected {len(sites)}", + ) + summary = payload.get("resolution_summary") + if not isinstance(summary, dict) or set(summary) != {"unique", "ambiguous", "unresolved"}: + return Result( + False, f"references: resolution_summary keys = {summary!r}, expected the 3 buckets" + ) + if not all(isinstance(v, int) for v in summary.values()): + return Result(False, "references: resolution_summary values are not all ints") + for flag in ("truncated", "query_too_broad"): + if not isinstance(payload.get(flag), bool): + return Result(False, f"references: {flag} = {payload.get(flag)!r}, expected a bool") + for si, s in enumerate(sites): + if not isinstance(s, dict) or not isinstance(s.get("file"), str): + return Result(False, f"references: sites[{si}].file is not a str") + if not isinstance(s.get("line"), int): + return Result(False, f"references: sites[{si}].line is not an int") + if not isinstance(s.get("candidates"), list): + return Result(False, f"references: sites[{si}].candidates is not a list") + return Result(True, f"references: {len(sites)} site(s) with well-formed envelope") + + # ------------------------------------------------------------------------------ live legs # # Heavy imports (httpx / mcp / sqlalchemy / databricks.sdk) are lazy so the pure predicates @@ -187,7 +230,11 @@ def _check_db(expect_indexed: bool) -> list[Result]: def _check_mcp(app_url: str, query: str, headers: dict[str, str] | None = None) -> Result: - """Live MCP leg: call ``search_code`` over authenticated streamable HTTP, validate envelope.""" + """Live MCP leg: call ``search_code`` + ``find_references`` over authenticated streamable + HTTP and validate each envelope. ``list_imports`` is NOT added here -- it needs a valid repo + argument, and deriving one via ``list_repos`` adds fragility for no additional coverage of + the shared ``_dispatch`` path both reference tools already ride. + """ import asyncio import json @@ -209,10 +256,21 @@ async def _run() -> Result: async with streamablehttp_client(f"{app_url}/mcp", headers=auth_headers) as (r, w, _): async with ClientSession(r, w) as session: await session.initialize() - res = await session.call_tool("search_code", {"query": query}) - text_parts = [c.text for c in res.content if getattr(c, "type", None) == "text"] - payload = json.loads(text_parts[0]) if text_parts else {} - return validate_search_payload(payload) + + async def _call(tool: str, args: dict[str, Any]) -> dict[str, Any]: + res = await session.call_tool(tool, args) + parts = [c.text for c in res.content if getattr(c, "type", None) == "text"] + return json.loads(parts[0]) if parts else {} + + search = validate_search_payload(await _call("search_code", {"query": query})) + if not search.ok: + return search + refs = validate_references_payload( + await _call("find_references", {"symbol": query}) + ) + if not refs.ok: + return refs + return Result(True, f"{search.detail}; {refs.detail}") return asyncio.run(_run()) diff --git a/tests/integration/test_mcp_server.py b/tests/integration/test_mcp_server.py index 4753bc4..8fc1f43 100644 --- a/tests/integration/test_mcp_server.py +++ b/tests/integration/test_mcp_server.py @@ -37,7 +37,7 @@ from app import main from app.db.client import create_db_engine -from app.db.models import Base, File, Repo, RepoBranch, Symbol +from app.db.models import Base, File, ReferenceEdge, Repo, RepoBranch, Symbol from indexer.hashing import content_sha SCHEMA_PREFIX = "test_mcp" @@ -75,6 +75,123 @@ def _restore_pgoptions(prev: str | None) -> None: _reset_engine() +def seed_reference_corpus(conn: Any, acme_id: int) -> None: + """Reference-edge corpus for find_references / list_imports (D5), inserted into an existing + ``acme/widgets`` repo. Connection-parameterized (no fixture lifecycle of its own) so + ``tests/integration/test_webui_graph_parity.py`` can reuse the exact same corpus over its + own throwaway schema (issue #88 AC2). Extracted verbatim from :func:`seeded_schema` -- a + pure, behavior-preserving move; ``test_reference_tools_streamable_http`` and the other + ``seeded_schema``-dependent cases below re-run unchanged against it. + + Binding content rule: every file's path AND content avoid "foo" and "Handler" (content + idiom ``"# \\n"``), all ``lang="python"``, no new repos, nothing on ``feature/x`` + contains "Handler" -- so none of the pre-existing pinned grep/sym/list_repos assertions + move. + """ + + def _ref_file(path: str, branch: str) -> int: + body = f"# {path}\n" + return conn.execute( + insert(File) + .values( + repo_id=acme_id, + path=path, + lang="python", + content=body, + content_sha=content_sha(body), + branches=[branch], + ) + .returning(File.id) + ).scalar_one() + + # Two ambiguous "process" definitions on main -> every call site resolves 2 candidates. + service_py = _ref_file("src/service.py", "main") + conn.execute( + insert(Symbol).values( + file_id=service_py, repo_id=acme_id, name="process", kind="function", start_line=10 + ) + ) + worker_py = _ref_file("src/worker.py", "main") + conn.execute( + insert(Symbol).values( + file_id=worker_py, repo_id=acme_id, name="process", kind="function", start_line=4 + ) + ) + + # Call sites on main: one to the ambiguous "process", one to an undefined "missing_fn". + caller_py = _ref_file("src/caller.py", "main") + conn.execute( + insert(ReferenceEdge).values( + file_id=caller_py, + repo_id=acme_id, + edge_kind="call", + target_name="process", + line=5, + enclosing_name="run", + enclosing_kind="function", + ) + ) + conn.execute( + insert(ReferenceEdge).values( + file_id=caller_py, + repo_id=acme_id, + edge_kind="call", + target_name="missing_fn", + line=6, + enclosing_name="run", + enclosing_kind="function", + ) + ) + + # AC3 composition site: a test file whose enclosing symbol names the covering test. + test_py = _ref_file("tests/test_service.py", "main") + conn.execute( + insert(ReferenceEdge).values( + file_id=test_py, + repo_id=acme_id, + edge_kind="call", + target_name="process", + line=8, + enclosing_name="test_process", + enclosing_kind="function", + ) + ) + + # Import sites on main (external, module-scope): enclosing NULL, expected "unresolved". + importer_py = _ref_file("src/importer.py", "main") + conn.execute( + insert(ReferenceEdge).values( + file_id=importer_py, + repo_id=acme_id, + edge_kind="import", + target_name="os.path", + line=1, + ) + ) + conn.execute( + insert(ReferenceEdge).values( + file_id=importer_py, + repo_id=acme_id, + edge_kind="import", + target_name="collections.abc", + line=2, + ) + ) + + # Branch parity: a call to "process" on feature/x, where NO "process" def exists -> + # candidate-side branch scoping makes it resolve "unresolved". + feature_caller = _ref_file("src/feature_caller.py", "feature/x") + conn.execute( + insert(ReferenceEdge).values( + file_id=feature_caller, + repo_id=acme_id, + edge_kind="call", + target_name="process", + line=3, + ) + ) + + @pytest.fixture def seeded_schema() -> Iterator[str]: """Throwaway schema + durable-core DDL + the deterministic grep corpus, PGOPTIONS-visible. @@ -173,6 +290,8 @@ def seeded_schema() -> Iterator[str]: ) ) conn.execute(insert(RepoBranch).values(repo_id=gamma_id, branch="HEAD")) + + seed_reference_corpus(conn, acme_id) conn.commit() # Point the server engine at this schema BEFORE it is built, and reset the singleton. @@ -416,6 +535,106 @@ async def test_streamable_http_tools_and_health(seeded_schema: str) -> None: assert ready.json()["status"] == "ready" +@pytest.mark.e2e +async def test_reference_tools_streamable_http(seeded_schema: str) -> None: + app = main.create_app() # fresh session manager per test (see _make_client_factory) + async with LifespanManager(app): + async with streamablehttp_client( + MCP_URL, httpx_client_factory=_make_client_factory(app) + ) as (r, w, _): + async with ClientSession(r, w) as session: + await session.initialize() + + # AC1 registration: both reference tools are exposed over the wire. + names = {t.name for t in (await session.list_tools()).tools} + assert {"find_references", "list_imports"} <= names + + # find_references("process"): two ambiguous call sites (caller.py, tests/), each + # with 2 ranked candidates; the feature/x call site is NOT in default-branch scope. + refs = _tool_json(await session.call_tool("find_references", {"symbol": "process"})) + assert refs["site_count"] == 2 + assert refs["resolution_summary"] == {"unique": 0, "ambiguous": 2, "unresolved": 0} + for site in refs["sites"]: + assert site["resolution"] == "ambiguous" + assert len(site["candidates"]) == 2 + assert "repo_known" not in refs # pinned absent on find_references (Critic note 5) + + # AC3: "what tests cover process" = client-side test-path filter of the sites. + test_sites = [s for s in refs["sites"] if s["file"].startswith("tests/")] + assert len(test_sites) == 1 + assert test_sites[0]["enclosing_symbol"] == { + "name": "test_process", + "kind": "function", + } + + # An undefined callee resolves to a single unresolved site, zero candidates. + missing = _tool_json( + await session.call_tool("find_references", {"symbol": "missing_fn"}) + ) + assert missing["site_count"] == 1 + (missing_site,) = missing["sites"] + assert missing_site["resolution"] == "unresolved" + assert missing_site["candidate_count"] == 0 + + # Branch parity: on feature/x the only "process" call has no candidate def there. + feature_refs = _tool_json( + await session.call_tool( + "find_references", {"symbol": "process", "branch": "feature/x"} + ) + ) + assert feature_refs["site_count"] == 1 + (feature_site,) = feature_refs["sites"] + assert feature_site["file"] == "src/feature_caller.py" + assert feature_site["resolution"] == "unresolved" + + # list_imports(imports): enumerate a repo's external import sites. + imports = _tool_json( + await session.call_tool("list_imports", {"repo": "acme/widgets"}) + ) + assert imports["repo_known"] is True + assert imports["direction"] == "imports" + assert {s["target_name"] for s in imports["sites"]} == { + "os.path", + "collections.abc", + } + for site in imports["sites"]: + assert site["resolution"] == "unresolved" + assert site["enclosing_symbol"] is None + + # list_imports(imported_by): who imports os.path, corpus-wide (no repo scope). + imported_by = _tool_json( + await session.call_tool( + "list_imports", {"target": "os.path", "direction": "imported_by"} + ) + ) + assert imported_by["repo"] is None + assert imported_by["repo_known"] is True + assert imported_by["site_count"] == 1 + (ib_site,) = imported_by["sites"] + assert ib_site["file"] == "src/importer.py" + assert ib_site["line"] == 1 + + # Structured validation over the wire (D5: >= 1 validation case at e2e level). + bad_direction = _tool_json( + await session.call_tool( + "list_imports", {"repo": "acme/widgets", "direction": "sideways"} + ) + ) + assert bad_direction["unsupported_direction"] == "sideways" + assert bad_direction["sites"] == [] + assert bad_direction["site_count"] == 0 + + no_repo = _tool_json(await session.call_tool("list_imports", {})) + assert no_repo["missing_repo"] is True + assert no_repo["sites"] == [] + + no_target = _tool_json( + await session.call_tool("list_imports", {"direction": "imported_by"}) + ) + assert no_target["missing_target"] is True + assert no_target["sites"] == [] + + @pytest.mark.e2e async def test_ready_returns_503_when_protected_table_unreadable(empty_schema: str) -> None: # The durable tables do not exist in this schema, so `SELECT 1 FROM repos LIMIT 1` raises diff --git a/tests/integration/test_migrations.py b/tests/integration/test_migrations.py index 98139b8..11a6d63 100644 --- a/tests/integration/test_migrations.py +++ b/tests/integration/test_migrations.py @@ -4,7 +4,9 @@ connection (the same path ``scripts/migrate.py`` uses), with the Alembic version table pinned to that schema. Requires a Lakebase branch whose project preloads ``lakebase_vector,lakebase_text`` (``upgrade head`` includes the 0004 semantic -revision; see docs/runbooks/ci-lakebase.md). +revision; see docs/runbooks/ci-lakebase.md). Exception: the ``reference_edges`` +(0005) tests use the ``migrated_edges_capable`` fixture, which reaches ``head`` +on a stock dev Postgres too -- see ``_upgrade_edges_capable``'s docstring. The enforcement test exercises the least-privilege grants on the *same* superuser connection via ``SET ROLE`` to a NOLOGIN role; opening a second engine would @@ -27,7 +29,7 @@ from alembic.config import Config from alembic.migration import MigrationContext from sqlalchemy import Connection, text -from sqlalchemy.exc import ProgrammingError +from sqlalchemy.exc import IntegrityError, ProgrammingError from app.db.client import create_db_engine from app.db.grants import build_app_grants, build_job_grants @@ -85,6 +87,74 @@ def migrated() -> Iterator[Migrated]: engine.dispose() +def _lakebase_extensions_available(conn: Connection) -> bool: + return bool( + conn.execute( + text("SELECT 1 FROM pg_available_extensions WHERE name = 'lakebase_tokenizer'") + ).scalar() + ) + + +_STUB_CHUNKS_DDL = ( + "CREATE TABLE chunks (" + "id bigserial PRIMARY KEY, " + "file_id integer NOT NULL REFERENCES files(id) ON DELETE CASCADE, " + "chunk_index integer NOT NULL, " + "content text NOT NULL)" +) + + +def _upgrade_edges_capable(config: Config, conn: Connection, target: str) -> None: + """Upgrade to ``target`` ("0004" or "head"), working on both a real Lakebase + branch and a stock dev Postgres. + + On real Lakebase, 0004 runs natively (the ``lakebase_*`` extensions exist). + On stock Postgres those extensions are absent, so this pre-seeds a stub + ``chunks`` table before reaching 0004 -- the exact idempotency guard + ``test_0004_guard_preserves_preexisting_chunks`` exercises -- which makes + 0004 skip its extension/index DDL and land cleanly. 0005's ``reference_edges`` + DDL is pure ``pg_trgm`` (no Lakebase dependency), so it then applies on + either environment. + """ + if _lakebase_extensions_available(conn): + command.upgrade(config, target) + return + command.upgrade(config, "0003") + conn.execute(text(_STUB_CHUNKS_DDL)) + command.upgrade(config, target) + + +@pytest.fixture +def migrated_edges_capable() -> Iterator[Migrated]: + """Like ``migrated``, but reaches ``head`` on stock Postgres too (see + ``_upgrade_edges_capable``) so the ``reference_edges`` tests run locally + without a live Lakebase branch.""" + schema = _unique("test_edges") + engine = create_db_engine() + conn = engine.connect() + try: + conn.execute(text(f"DROP SCHEMA IF EXISTS {schema} CASCADE")) + conn.execute(text(f"CREATE SCHEMA {schema}")) + conn.execute(text(f"SET search_path TO {schema}, public")) + conn.commit() + + config = Config("alembic.ini") + config.attributes["connection"] = conn + config.attributes["version_table_schema"] = schema + + _upgrade_edges_capable(config, conn, "head") + conn.commit() + + yield Migrated(conn=conn, schema=schema, config=config) + finally: + conn.rollback() + conn.execute(text("RESET ROLE")) + conn.execute(text(f"DROP SCHEMA IF EXISTS {schema} CASCADE")) + conn.commit() + conn.close() + engine.dispose() + + @pytest.mark.integration def test_schema_fidelity(migrated: Migrated) -> None: conn, schema = migrated.conn, migrated.schema @@ -97,7 +167,9 @@ def test_schema_fidelity(migrated: Migrated) -> None: .scalars() .all() ) - assert {"repos", "files", "symbols", "repo_branches", "chunks"} <= set(tables) + assert {"repos", "files", "symbols", "repo_branches", "chunks", "reference_edges"} <= set( + tables + ) rows = conn.execute( text("SELECT indexname, indexdef FROM pg_indexes WHERE schemaname = :s"), @@ -111,6 +183,17 @@ def test_schema_fidelity(migrated: Migrated) -> None: assert "ix_files_branches_gin" in by_name assert "USING gin" in by_name["ix_files_branches_gin"] + for idx in ( + "ix_reference_edges_target_name", + "ix_reference_edges_file_id", + "ix_reference_edges_repo_kind", + ): + assert idx in by_name, f"missing index {idx}" + assert "USING gin" not in by_name[idx], f"{idx} must be a plain btree index" + assert "ix_reference_edges_target_trgm" in by_name + assert "USING gin" in by_name["ix_reference_edges_target_trgm"] + assert "gin_trgm_ops" in by_name["ix_reference_edges_target_trgm"] + fk_count = conn.execute( text( "SELECT count(*) FROM information_schema.table_constraints tc " @@ -214,7 +297,8 @@ def test_downgrade_drops_tables_but_keeps_extension(migrated: Migrated) -> None: conn.execute( text( "SELECT table_name FROM information_schema.tables " - "WHERE table_schema = :s AND table_name IN ('repos', 'files', 'symbols')" + "WHERE table_schema = :s AND table_name IN " + "('repos', 'files', 'symbols', 'reference_edges')" ), {"s": schema}, ) @@ -730,3 +814,325 @@ def test_0003_downgrade_blocked_by_multi_branch_data(migrated: Migrated) -> None with pytest.raises(RuntimeError, match="multi-branch data present"): command.downgrade(migrated.config, "0002") conn.rollback() + + +def _seed_repo_and_file( + conn: Connection, *, repo_name: str, path: str, sha: str +) -> tuple[int, int]: + conn.execute(text("INSERT INTO repos (name) VALUES (:n)"), {"n": repo_name}) + repo_id = conn.execute(text("SELECT id FROM repos WHERE name = :n"), {"n": repo_name}).scalar() + conn.execute( + text( + "INSERT INTO files (repo_id, path, content_sha, branches) " + "VALUES (:r, :p, :sha, ARRAY['main'])" + ), + {"r": repo_id, "p": path, "sha": sha}, + ) + file_id = conn.execute( + text("SELECT id FROM files WHERE repo_id = :r AND path = :p"), + {"r": repo_id, "p": path}, + ).scalar() + return repo_id, file_id + + +@pytest.mark.integration +def test_reference_edges_shape_and_constraints(migrated_edges_capable: Migrated) -> None: + conn = migrated_edges_capable.conn + repo_id, file_id = _seed_repo_and_file(conn, repo_name="shape_repo", path="a.py", sha="sha1") + + conn.execute( + text( + "INSERT INTO reference_edges " + "(repo_id, file_id, edge_kind, target_name, line, enclosing_name, enclosing_kind, " + "enclosing_start_line, enclosing_end_line) " + "VALUES (:r, :f, 'call', 'target_fn', 5, 'caller_fn', 'function', 1, 20)" + ), + {"r": repo_id, "f": file_id}, + ) + conn.commit() + assert conn.execute(text("SELECT count(*) FROM reference_edges")).scalar() == 1 + + with pytest.raises(IntegrityError) as excinfo: + conn.execute( + text( + "INSERT INTO reference_edges (repo_id, file_id, edge_kind, target_name, line) " + "VALUES (:r, :f, 'definition', 'x', 1)" + ), + {"r": repo_id, "f": file_id}, + ) + assert isinstance(excinfo.value.orig, psycopg.errors.CheckViolation) + conn.rollback() + + with pytest.raises(IntegrityError) as excinfo: + conn.execute( + text( + "INSERT INTO reference_edges (repo_id, file_id, edge_kind, target_name, line) " + "VALUES (:r, :f, 'call', NULL, 1)" + ), + {"r": repo_id, "f": file_id}, + ) + assert isinstance(excinfo.value.orig, psycopg.errors.NotNullViolation) + conn.rollback() + + +@pytest.mark.integration +def test_reference_edges_cascade_on_file_and_repo_delete(migrated_edges_capable: Migrated) -> None: + conn = migrated_edges_capable.conn + repo_id, file_id = _seed_repo_and_file(conn, repo_name="cascade_repo", path="a.py", sha="sha1") + conn.execute( + text( + "INSERT INTO reference_edges (repo_id, file_id, edge_kind, target_name, line) " + "VALUES (:r, :f, 'call', 'target_fn', 5)" + ), + {"r": repo_id, "f": file_id}, + ) + conn.commit() + assert conn.execute(text("SELECT count(*) FROM reference_edges")).scalar() == 1 + + conn.execute(text("DELETE FROM files WHERE id = :f"), {"f": file_id}) + conn.commit() + assert conn.execute(text("SELECT count(*) FROM reference_edges")).scalar() == 0 + + _, file_id2 = _seed_repo_and_file(conn, repo_name="cascade_repo2", path="b.py", sha="sha2") + repo_id2 = conn.execute( + text("SELECT repo_id FROM files WHERE id = :f"), {"f": file_id2} + ).scalar() + conn.execute( + text( + "INSERT INTO reference_edges (repo_id, file_id, edge_kind, target_name, line) " + "VALUES (:r, :f, 'import', 'os', 1)" + ), + {"r": repo_id2, "f": file_id2}, + ) + conn.commit() + assert conn.execute(text("SELECT count(*) FROM reference_edges")).scalar() == 1 + + conn.execute(text("DELETE FROM repos WHERE id = :r"), {"r": repo_id2}) + conn.commit() + assert conn.execute(text("SELECT count(*) FROM reference_edges")).scalar() == 0 + + +@pytest.mark.integration +def test_reference_edges_adp_same_role_covers_new_table() -> None: + """Positive proof of the runbook's lifecycle-safe-grants claim: when the SAME + role runs ``ALTER DEFAULT PRIVILEGES`` and later creates ``reference_edges`` + via a schema-only migrate, the app/job grants apply automatically -- no + re-grant between ``upgrade head`` and the privilege assertions below.""" + schema = _unique("test_adp_same") + app_ro = _unique("app_ro") + job_rw = _unique("job_rw") + engine = create_db_engine() + conn = engine.connect() + try: + conn.execute(text(f"DROP SCHEMA IF EXISTS {schema} CASCADE")) + conn.execute(text(f"CREATE SCHEMA {schema}")) + conn.execute(text(f"SET search_path TO {schema}, public")) + conn.execute(text(f"CREATE ROLE {app_ro} NOLOGIN")) + conn.execute(text(f"CREATE ROLE {job_rw} NOLOGIN")) + conn.commit() + + config = Config("alembic.ini") + config.attributes["connection"] = conn + config.attributes["version_table_schema"] = schema + + _upgrade_edges_capable(config, conn, "0004") + conn.commit() + + for stmt in build_app_grants(schema, app_ro): + conn.execute(text(stmt)) + for stmt in build_job_grants(schema, job_rw): + conn.execute(text(stmt)) + conn.commit() + + # Same identity (this connection) now creates reference_edges via 0005 -- + # no re-grant runs between this upgrade and the assertions below. + command.upgrade(config, "head") + conn.commit() + + conn.execute(text(f"SET ROLE {app_ro}")) + conn.execute(text(f"SET search_path TO {schema}, public")) + conn.execute(text("SELECT * FROM reference_edges")).all() + with pytest.raises(ProgrammingError) as excinfo: + conn.execute( + text( + "INSERT INTO reference_edges (repo_id, file_id, edge_kind, target_name, line) " + "VALUES (1, 1, 'call', 'x', 1)" + ) + ) + assert isinstance(excinfo.value.orig, psycopg.errors.InsufficientPrivilege) + conn.rollback() + + conn.execute(text(f"SET ROLE {job_rw}")) + conn.execute(text(f"SET search_path TO {schema}, public")) + repo_id, file_id = _seed_repo_and_file(conn, repo_name="adp_repo", path="a.py", sha="sha1") + conn.execute( + text( + "INSERT INTO reference_edges (repo_id, file_id, edge_kind, target_name, line) " + "VALUES (:r, :f, 'call', 'target_fn', 10)" + ), + {"r": repo_id, "f": file_id}, + ) + conn.execute(text("DELETE FROM reference_edges")) + + # Negative proof: the job role's grants are DML-only (SELECT/INSERT/ + # UPDATE/DELETE) -- no DDL, matching build_job_grants' least-privilege + # intent. A future accidental widening of that builder should fail here. + # (The failed TRUNCATE aborts the tx; rollback below also undoes SET ROLE, + # same as test_grant_enforcement_via_set_role.) + with pytest.raises(ProgrammingError) as excinfo: + conn.execute(text("TRUNCATE reference_edges")) + assert isinstance(excinfo.value.orig, psycopg.errors.InsufficientPrivilege) + conn.rollback() + finally: + conn.rollback() + conn.execute(text("RESET ROLE")) + conn.execute(text(f"DROP SCHEMA IF EXISTS {schema} CASCADE")) + for role in (app_ro, job_rw): + conn.execute(text(f"DROP OWNED BY {role} CASCADE")) + conn.execute(text(f"DROP ROLE IF EXISTS {role}")) + conn.commit() + conn.close() + engine.dispose() + + +@pytest.mark.integration +def test_reference_edges_adp_different_role_does_not_cover_new_table() -> None: + """Negative proof, motivating the runbook's re-grant command: Postgres ADP + binds to the EXECUTING role, not the schema -- a table created by a + different identity than the one that ran ``ALTER DEFAULT PRIVILEGES`` gets + no automatic grant.""" + schema = _unique("test_adp_diff") + app_ro = _unique("app_ro") + other_creator = _unique("other_creator") + engine = create_db_engine() + conn = engine.connect() + try: + conn.execute(text(f"DROP SCHEMA IF EXISTS {schema} CASCADE")) + conn.execute(text(f"CREATE SCHEMA {schema}")) + conn.execute(text(f"CREATE ROLE {app_ro} NOLOGIN")) + conn.execute(text(f"CREATE ROLE {other_creator} NOLOGIN")) + conn.execute(text(f"GRANT CREATE, USAGE ON SCHEMA {schema} TO {other_creator}")) + conn.commit() + + for stmt in build_app_grants(schema, app_ro): + conn.execute(text(stmt)) + conn.commit() + + conn.execute(text(f"SET ROLE {other_creator}")) + conn.execute(text(f"SET search_path TO {schema}, public")) + conn.execute(text("CREATE TABLE shadow_table (id serial PRIMARY KEY)")) + conn.execute(text("RESET ROLE")) + conn.commit() + + has_select = conn.execute( + text(f"SELECT has_table_privilege('{app_ro}', '{schema}.shadow_table', 'SELECT')") + ).scalar() + assert has_select is False, ( + "a table created by a different identity than the one that ran ADP " + "must NOT automatically receive the app role's SELECT grant" + ) + finally: + conn.rollback() + conn.execute(text("RESET ROLE")) + conn.execute(text(f"DROP SCHEMA IF EXISTS {schema} CASCADE")) + for role in (app_ro, other_creator): + conn.execute(text(f"DROP OWNED BY {role} CASCADE")) + conn.execute(text(f"DROP ROLE IF EXISTS {role}")) + conn.commit() + conn.close() + engine.dispose() + + +def _seed_explain_fixture(conn: Connection) -> None: + repo_id, file_id = _seed_repo_and_file(conn, repo_name="explain_repo", path="a.py", sha="sha1") + conn.execute( + text( + "INSERT INTO symbols (file_id, repo_id, name, kind) " + "VALUES (:f, :r, 'handle_request', 'function')" + ), + {"f": file_id, "r": repo_id}, + ) + names = ["handle_request", "handle_response", "parse_args", "other_fn"] * 5 + conn.execute( + text( + "INSERT INTO reference_edges (repo_id, file_id, edge_kind, target_name, line) " + "VALUES (:r, :f, 'call', :name, :line)" + ), + [{"r": repo_id, "f": file_id, "name": name, "line": i + 1} for i, name in enumerate(names)], + ) + conn.commit() + + +@pytest.mark.integration +def test_reference_edges_explain_resolver_join_uses_target_name_index( + migrated_edges_capable: Migrated, +) -> None: + """EXPLAIN on the resolver's join shape (equality + name-join against + symbols) must reference the btree ``ix_reference_edges_target_name``.""" + conn = migrated_edges_capable.conn + _seed_explain_fixture(conn) + + with conn.begin(): + conn.execute(text("SET LOCAL enable_seqscan = off")) + plan = ( + conn.execute( + text( + "EXPLAIN SELECT re.id FROM reference_edges re " + "JOIN symbols s ON s.name = re.target_name " + "WHERE re.target_name = 'handle_request'" + ) + ) + .scalars() + .all() + ) + plan_text = "\n".join(plan) + assert "ix_reference_edges_target_name" in plan_text, plan_text + + +@pytest.mark.integration +def test_reference_edges_explain_ilike_uses_trgm_gin_index( + migrated_edges_capable: Migrated, +) -> None: + conn = migrated_edges_capable.conn + _seed_explain_fixture(conn) + + with conn.begin(): + conn.execute(text("SET LOCAL enable_seqscan = off")) + plan = ( + conn.execute( + text("EXPLAIN SELECT * FROM reference_edges WHERE target_name ILIKE '%handle%'") + ) + .scalars() + .all() + ) + plan_text = "\n".join(plan) + assert "ix_reference_edges_target_trgm" in plan_text, plan_text + + +@pytest.mark.integration +def test_reference_edges_downgrade_to_0004_removes_table_and_indexes( + migrated_edges_capable: Migrated, +) -> None: + conn, schema, config = migrated_edges_capable + + command.downgrade(config, "0004") + conn.commit() + + assert conn.execute(text("SELECT to_regclass('reference_edges')")).scalar() is None + remaining_indexes = ( + conn.execute( + text( + "SELECT indexname FROM pg_indexes " + "WHERE schemaname = :s AND indexname LIKE 'ix_reference_edges%'" + ), + {"s": schema}, + ) + .scalars() + .all() + ) + assert remaining_indexes == [] + + # The chain re-upgrades cleanly. + command.upgrade(config, "head") + conn.commit() + assert conn.execute(text("SELECT to_regclass('reference_edges')")).scalar() is not None diff --git a/tests/integration/test_reconcile.py b/tests/integration/test_reconcile.py index 6a1b85c..4e98cbd 100644 --- a/tests/integration/test_reconcile.py +++ b/tests/integration/test_reconcile.py @@ -25,7 +25,7 @@ from app.db.grants import build_job_grants from app.db.models import Base from indexer.chunk_store import write_chunks -from indexer.languages import ExtractedSymbol, ParsedFile +from indexer.languages import ExtractedSymbol, FileExtraction, ParsedFile from indexer.store import ( ReconcileCounts, index_repo, @@ -80,8 +80,11 @@ def _pf(path: str, content: str) -> ParsedFile: def _items( *specs: tuple[str, str, list[ExtractedSymbol]], -) -> list[tuple[ParsedFile, list[ExtractedSymbol]]]: - return [(_pf(path, content), syms) for path, content, syms in specs] +) -> list[tuple[ParsedFile, FileExtraction]]: + return [ + (_pf(path, content), FileExtraction(symbols=syms, edges=[])) + for path, content, syms in specs + ] MAIN = ("main.py", "def f():\n return 1\n", [ExtractedSymbol("f", "function", 1, 2)]) @@ -145,6 +148,30 @@ def _cfg() -> Settings: ) +def _seed_reference_edge( + conn: Connection, *, repo_id: int, file_id: int, target_name: str = "target_fn" +) -> None: + """Seed one raw reference edge row directly. + + These reconcile tests exercise the storage primitives (retirement/purge + cascades) in isolation from the real extractor, which landed in #84 + (``indexer.symbols.extract_file`` / ``indexer.store.index_repo``'s edge + writer) -- seeding a row by hand keeps this module focused on + ``reconcile_retired_branches``/``reconcile_removed_repos`` alone. + """ + conn.execute( + text( + "INSERT INTO reference_edges (repo_id, file_id, edge_kind, target_name, line) " + "VALUES (:r, :f, 'call', :name, 1)" + ), + {"r": repo_id, "f": file_id, "name": target_name}, + ) + + +def _repo_id(conn: Connection, name: str) -> int: + return int(conn.execute(text("SELECT id FROM repos WHERE name = :n"), {"n": name}).scalar_one()) + + def _repo_branch_names(conn: Connection, name: str) -> set[str]: rows = ( conn.execute( @@ -278,9 +305,15 @@ def test_divergent_branch_only_file_is_deleted_with_symbols_and_chunks_cascade( ) conn.rollback() + repo_id = _repo_id(conn, "acme/widgets") feature_file_id = _file_id(conn, "only_feature.py") + main_file_id = _file_id(conn, "main.py") + _seed_reference_edge(conn, repo_id=repo_id, file_id=feature_file_id, target_name="retired_fn") + _seed_reference_edge(conn, repo_id=repo_id, file_id=main_file_id, target_name="surviving_fn") + conn.commit() assert _count(conn, "symbols", f"file_id = {feature_file_id}") == 1 assert _count(conn, "chunks", f"file_id = {feature_file_id}") == 1 + assert _count(conn, "reference_edges", f"file_id = {feature_file_id}") == 1 conn.rollback() # clear the reads' autobegun txn before reconcile's own conn.begin() counts = reconcile_retired_branches(conn, name="acme/widgets", retired_branches=["feature"]) @@ -289,10 +322,12 @@ def test_divergent_branch_only_file_is_deleted_with_symbols_and_chunks_cascade( assert _count(conn, "files", "path = 'only_feature.py'") == 0 assert _count(conn, "symbols", f"file_id = {feature_file_id}") == 0 # cascade assert _count(conn, "chunks", f"file_id = {feature_file_id}") == 0 # cascade + assert _count(conn, "reference_edges", f"file_id = {feature_file_id}") == 0 # cascade - # main.py's branch ('main') was never retired -> untouched. + # main.py's branch ('main') was never retired -> untouched, including its edge row. assert _count(conn, "files", "path = 'main.py'") == 1 assert _branches_of(conn, "main.py") == ["main"] + assert _count(conn, "reference_edges", f"file_id = {main_file_id}") == 1 @pytest.mark.integration @@ -482,7 +517,7 @@ def test_reconcile_runs_under_the_actual_job_role(conn: Connection) -> None: @pytest.mark.integration -def test_reconcile_removed_repos_purges_repo_and_cascades_all_five_tables( +def test_reconcile_removed_repos_purges_repo_and_cascades_all_six_tables( conn: Connection, ) -> None: index_repo( @@ -508,9 +543,13 @@ def test_reconcile_removed_repos_purges_repo_and_cascades_all_five_tables( # Capture the victim's file_id(s) BEFORE the purge -- once the repo row is gone, # post-hoc reconstruction via a repo-name JOIN is impossible. + removed_repo_id = _repo_id(conn, "acme/removed") removed_file_id = _file_id(conn, "only_feature.py", repo="acme/removed") + _seed_reference_edge(conn, repo_id=removed_repo_id, file_id=removed_file_id) + conn.commit() assert _count(conn, "symbols", f"file_id = {removed_file_id}") == 1 assert _count(conn, "chunks", f"file_id = {removed_file_id}") == 1 + assert _count(conn, "reference_edges", f"file_id = {removed_file_id}") == 1 conn.rollback() # clear the reads' autobegun txn before reconcile's own conn.begin() deleted = reconcile_removed_repos(conn, desired_repos=["acme/kept"]) @@ -521,6 +560,7 @@ def test_reconcile_removed_repos_purges_repo_and_cascades_all_five_tables( assert _count(conn, "files", "path = 'only_feature.py'") == 0 assert _count(conn, "symbols", f"file_id = {removed_file_id}") == 0 # cascade assert _count(conn, "chunks", f"file_id = {removed_file_id}") == 0 # cascade + assert _count(conn, "reference_edges", f"file_id = {removed_file_id}") == 0 # cascade # acme/kept is completely untouched. assert _count(conn, "repos", "name = 'acme/kept'") == 1 diff --git a/tests/integration/test_references.py b/tests/integration/test_references.py new file mode 100644 index 0000000..4fe3266 --- /dev/null +++ b/tests/integration/test_references.py @@ -0,0 +1,433 @@ +"""Integration tests for the reference resolver: raw edges -> ranked candidate sets. + +Requires a running Postgres with the standard PG* env set. Mirrors the throwaway-schema idiom +of ``tests/integration/test_symbols_search.py`` (unique schema, ``SET search_path``, ``CREATE +EXTENSION pg_trgm``, ``Base.metadata.create_all`` on the same connection, ``DROP SCHEMA ... +CASCADE`` + ``engine.dispose()`` in ``finally``). In this repo that Postgres exists only as +CI's service container (or a local dev Postgres), so these tests are CI-only and were +validated locally by lint/type-check + ``--collect-only`` when no live Postgres is reachable. + +The ``seeded`` fixture is function-scoped: the timeout test inserts a large row volume and the +determinism/branch-scoping assertions rely on a clean corpus, so each test gets its own. +""" + +from __future__ import annotations + +import uuid +from collections.abc import Iterator +from typing import NamedTuple + +import pytest +from sqlalchemy import Connection, insert, text +from sqlalchemy.dialects import postgresql + +from app.db.client import create_db_engine +from app.db.models import Base, File, ReferenceEdge, Repo, Symbol +from app.search.errors import QueryTooBroadError +from app.search.references import ( + DEFAULT_CANDIDATE_CAP, + ReferenceResult, + _build_candidates_select, + _build_sites_select, + resolve_references, +) +from indexer.hashing import content_sha + +SCHEMA_PREFIX = "test_refsearch" + + +class Seeded(NamedTuple): + conn: Connection + acme_id: int + beta_id: int + files: dict[str, int] + + +def _unique(prefix: str) -> str: + return f"{prefix}_{uuid.uuid4().hex[:12]}" + + +def _insert_repo(conn: Connection, name: str, *, default_branch: str | None = "main") -> int: + return conn.execute( + insert(Repo).values(name=name, default_branch=default_branch).returning(Repo.id) + ).scalar_one() + + +def _insert_file( + conn: Connection, + repo_id: int, + path: str, + *, + lang: str | None = "python", + content: str | None = "pass\n", + branches: list[str] | None = None, +) -> int: + return conn.execute( + insert(File) + .values( + repo_id=repo_id, + path=path, + lang=lang, + content=content, + content_sha=content_sha(content), + branches=branches if branches is not None else ["main"], + ) + .returning(File.id) + ).scalar_one() + + +def _insert_symbol( + conn: Connection, + file_id: int, + repo_id: int, + name: str, + *, + kind: str | None = "function", + start_line: int | None = 1, +) -> int: + return conn.execute( + insert(Symbol) + .values(file_id=file_id, repo_id=repo_id, name=name, kind=kind, start_line=start_line) + .returning(Symbol.id) + ).scalar_one() + + +def _insert_edge( + conn: Connection, + file_id: int, + repo_id: int, + *, + edge_kind: str, + target_name: str, + line: int = 1, + enclosing_name: str | None = None, + enclosing_kind: str | None = None, +) -> int: + return conn.execute( + insert(ReferenceEdge) + .values( + file_id=file_id, + repo_id=repo_id, + edge_kind=edge_kind, + target_name=target_name, + line=line, + enclosing_name=enclosing_name, + enclosing_kind=enclosing_kind, + ) + .returning(ReferenceEdge.id) + ).scalar_one() + + +@pytest.fixture +def seeded() -> Iterator[Seeded]: + """Throwaway schema + durable-core DDL + a deterministic edge/symbol corpus.""" + schema = _unique(SCHEMA_PREFIX) + engine = create_db_engine() + conn = engine.connect() + try: + conn.execute(text(f"DROP SCHEMA IF EXISTS {schema} CASCADE")) + conn.execute(text(f"CREATE SCHEMA {schema}")) + conn.execute(text(f"SET search_path TO {schema}, public")) + conn.execute(text("CREATE EXTENSION IF NOT EXISTS pg_trgm")) + conn.commit() + + Base.metadata.create_all(bind=conn) + conn.commit() + + acme_id = _insert_repo(conn, "acme/widgets") + beta_id = _insert_repo(conn, "beta/tools") + files: dict[str, int] = {} + + # -- unique: exactly one candidate definition. + files["src/unique_target.py"] = _insert_file(conn, acme_id, "src/unique_target.py") + _insert_symbol(conn, files["src/unique_target.py"], acme_id, "unique_fn", start_line=2) + files["src/caller.py"] = _insert_file(conn, acme_id, "src/caller.py") + _insert_edge( + conn, + files["src/caller.py"], + acme_id, + edge_kind="call", + target_name="unique_fn", + line=5, + enclosing_name="handle", + enclosing_kind="function", + ) + + # -- ambiguous, same-repo duplicate definitions. + files["src/dup_a.py"] = _insert_file(conn, acme_id, "src/dup_a.py") + _insert_symbol(conn, files["src/dup_a.py"], acme_id, "ambiguous_fn", start_line=1) + files["src/dup_b.py"] = _insert_file(conn, acme_id, "src/dup_b.py") + _insert_symbol(conn, files["src/dup_b.py"], acme_id, "ambiguous_fn", start_line=1) + files["src/caller2.py"] = _insert_file(conn, acme_id, "src/caller2.py") + _insert_edge( + conn, files["src/caller2.py"], acme_id, edge_kind="call", target_name="ambiguous_fn" + ) + + # -- cross-repo ambiguous: same name defined in acme (same-repo) AND beta (cross-repo). + files["src/cross_local.py"] = _insert_file(conn, acme_id, "src/cross_local.py") + _insert_symbol(conn, files["src/cross_local.py"], acme_id, "cross_fn", start_line=1) + files["beta/cross.py"] = _insert_file(conn, beta_id, "beta/cross.py") + _insert_symbol(conn, files["beta/cross.py"], beta_id, "cross_fn", start_line=1) + files["src/caller3.py"] = _insert_file(conn, acme_id, "src/caller3.py") + _insert_edge( + conn, files["src/caller3.py"], acme_id, edge_kind="call", target_name="cross_fn" + ) + + # -- unresolved call: no matching symbol anywhere. + files["src/caller4.py"] = _insert_file(conn, acme_id, "src/caller4.py") + _insert_edge( + conn, files["src/caller4.py"], acme_id, edge_kind="call", target_name="missing_fn" + ) + + # -- unresolved import: dotted external target (D3, no last-segment split). + files["src/importer.py"] = _insert_file(conn, acme_id, "src/importer.py") + _insert_edge( + conn, files["src/importer.py"], acme_id, edge_kind="import", target_name="os.path" + ) + + # -- branch scoping: a site AND its candidate definition exist only on "feature". + files["src/feature_target.py"] = _insert_file( + conn, acme_id, "src/feature_target.py", branches=["feature"] + ) + _insert_symbol(conn, files["src/feature_target.py"], acme_id, "feature_fn", start_line=1) + files["src/feature_caller.py"] = _insert_file( + conn, acme_id, "src/feature_caller.py", branches=["feature"] + ) + _insert_edge( + conn, + files["src/feature_caller.py"], + acme_id, + edge_kind="call", + target_name="feature_fn", + ) + + conn.commit() + yield Seeded(conn=conn, acme_id=acme_id, beta_id=beta_id, files=files) + finally: + conn.rollback() + conn.execute(text(f"DROP SCHEMA IF EXISTS {schema} CASCADE")) + conn.commit() + conn.close() + engine.dispose() + + +def _resolve(conn: Connection, **kwargs: object) -> ReferenceResult: + return resolve_references(conn, **kwargs) # type: ignore[arg-type] + + +# ----------------------------------------------------------------------------- resolution + + +@pytest.mark.integration +def test_unique_call_resolves_to_single_candidate(seeded: Seeded) -> None: + result = _resolve(seeded.conn, target_name="unique_fn", edge_kind="call") + (site,) = result.sites + assert site.resolution == "unique" + assert site.candidate_count == 1 + assert site.enclosing_name == "handle" + (candidate,) = site.candidates + assert candidate.path == "src/unique_target.py" + assert candidate.same_repo is True + assert candidate.kind_match is True + + +@pytest.mark.integration +def test_ambiguous_same_repo_duplicates_never_collapsed(seeded: Seeded) -> None: + result = _resolve(seeded.conn, target_name="ambiguous_fn", edge_kind="call") + (site,) = result.sites + assert site.resolution == "ambiguous" + assert site.candidate_count == 2 + assert len(site.candidates) == 2 # AC1: ambiguity is never collapsed to one answer. + assert {c.path for c in site.candidates} == {"src/dup_a.py", "src/dup_b.py"} + + +@pytest.mark.integration +def test_cross_repo_ambiguous_ranks_same_repo_first(seeded: Seeded) -> None: + result = _resolve(seeded.conn, target_name="cross_fn", edge_kind="call") + (site,) = result.sites + assert site.resolution == "ambiguous" + assert site.candidate_count == 2 + assert len(site.candidates) == 2 + assert site.candidates[0].path == "src/cross_local.py" + assert site.candidates[0].same_repo is True + assert site.candidates[1].path == "beta/cross.py" + assert site.candidates[1].same_repo is False + + +@pytest.mark.integration +def test_unresolved_call_no_matching_symbol(seeded: Seeded) -> None: + result = _resolve(seeded.conn, target_name="missing_fn", edge_kind="call") + (site,) = result.sites + assert site.resolution == "unresolved" + assert site.candidate_count == 0 + assert site.candidates == () + + +@pytest.mark.integration +def test_unresolved_import_external_target(seeded: Seeded) -> None: + # D3: import target_name is the full dotted path; no symbol is literally named "os.path", + # so this resolves unresolved -- correctly representing an external/stdlib import. + result = _resolve(seeded.conn, target_name="os.path", edge_kind="import") + (site,) = result.sites + assert site.edge_kind == "import" + assert site.resolution == "unresolved" + + +# ------------------------------------------------------------------------- branch scoping + + +@pytest.mark.integration +def test_default_branch_excludes_feature_only_site(seeded: Seeded) -> None: + # No branch= given -> default-branch conjunct excludes the "feature"-only edge site. + result = _resolve(seeded.conn, target_name="feature_fn", edge_kind="call") + assert result.sites == () + + +@pytest.mark.integration +def test_explicit_branch_includes_feature_only_site_and_its_candidate(seeded: Seeded) -> None: + result = _resolve(seeded.conn, target_name="feature_fn", edge_kind="call", branch="feature") + (site,) = result.sites + assert site.resolution == "unique" + (candidate,) = site.candidates + assert candidate.path == "src/feature_target.py" + + +@pytest.mark.integration +def test_candidate_on_other_branch_is_excluded(seeded: Seeded) -> None: + # The candidate definition lives only on "feature"; querying "main" (feature_fn's site + # doesn't even exist there, but prove the candidate side of the predicate independently) + # via an explicit different branch must not surface it. + result = _resolve( + seeded.conn, target_name="feature_fn", edge_kind="call", branch="other-branch" + ) + assert result.sites == () + + +# ------------------------------------------------------------------------------ repo scope + + +@pytest.mark.integration +def test_repo_scope_filters_to_one_repo(seeded: Seeded) -> None: + result = _resolve(seeded.conn, edge_kind="import", repo="acme/widgets") + assert result.repo_known is True + assert all(site.repo_id == seeded.acme_id for site in result.sites) + + +@pytest.mark.integration +def test_unknown_repo_is_structured_miss_no_further_work(seeded: Seeded) -> None: + result = _resolve(seeded.conn, edge_kind="import", repo="ghost/repo") + assert result.repo_known is False + assert result.sites == () + assert result.truncated is False + + +# --------------------------------------------------------------------------- candidate cap + + +@pytest.mark.integration +def test_hot_name_bound_by_sql_window_not_just_payload(seeded: Seeded) -> None: + # More defs than DEFAULT_CANDIDATE_CAP -- the SQL window must bound the FETCH itself. + extra = DEFAULT_CANDIDATE_CAP + 8 + for i in range(extra): + fid = _insert_file(seeded.conn, seeded.acme_id, f"src/hot_{i}.py") + _insert_symbol(seeded.conn, fid, seeded.acme_id, "hot_fn", start_line=1) + caller = _insert_file(seeded.conn, seeded.acme_id, "src/hot_caller.py") + _insert_edge(seeded.conn, caller, seeded.acme_id, edge_kind="call", target_name="hot_fn") + seeded.conn.commit() + + result = _resolve(seeded.conn, target_name="hot_fn", edge_kind="call") + (site,) = result.sites + assert site.candidate_count == extra # true pre-cap total + assert len(site.candidates) == DEFAULT_CANDIDATE_CAP # fetch itself was bounded + assert site.candidates_truncated is True + assert site.resolution == "ambiguous" # never rewritten to "unique" by the cap + + +# ------------------------------------------------------------------------------ determinism + + +@pytest.mark.integration +def test_determinism_repeated_calls_identical_order(seeded: Seeded) -> None: + first = _resolve(seeded.conn, target_name="ambiguous_fn", edge_kind="call") + second = _resolve(seeded.conn, target_name="ambiguous_fn", edge_kind="call") + assert [c.path for c in first.sites[0].candidates] == [ + c.path for c in second.sites[0].candidates + ] + + +# ----------------------------------------------------------------------------- row cap + + +@pytest.mark.integration +def test_row_limit_truncates_sites(seeded: Seeded) -> None: + result = _resolve(seeded.conn, edge_kind="call", row_limit=1) + assert len(result.sites) == 1 + assert result.truncated is True + assert result.truncation_reason == "row_cap" + + +# --------------------------------------------------------------------------------- timeout + + +@pytest.mark.integration +def test_tiny_statement_timeout_raises_query_too_broad(seeded: Seeded) -> None: + # Deterministic DB-cancellation by WORK VOLUME (mirrors test_symbols_search.py / + # test_grep.py): a huge fan-in on one target_name forces a real sort of many matching + # reference_edges rows before the ORDER BY/LIMIT can short-circuit, guaranteed >> 1 ms. + caller = _insert_file(seeded.conn, seeded.acme_id, "src/blob_caller.py") + seeded.conn.execute( + insert(ReferenceEdge), + [ + { + "file_id": caller, + "repo_id": seeded.acme_id, + "edge_kind": "call", + "target_name": "hot_blob_fn", + "line": i + 1, + } + for i in range(20000) + ], + ) + seeded.conn.commit() + with pytest.raises(QueryTooBroadError): + _resolve( + seeded.conn, + target_name="hot_blob_fn", + edge_kind="call", + statement_timeout_ms=1, + ) + + +# ------------------------------------------------------------------------- EXPLAIN sanity + + +@pytest.mark.integration +def test_explain_sites_select_uses_repo_kind_index(seeded: Seeded) -> None: + stmt = _build_sites_select( + target_name=None, edge_kind="call", repo_id=seeded.acme_id, branch=None, row_limit=200 + ) + sql = str(stmt.compile(dialect=postgresql.dialect(), compile_kwargs={"literal_binds": True})) + savepoint = seeded.conn.begin_nested() + try: + seeded.conn.execute(text("SET LOCAL enable_seqscan = off")) + plan = seeded.conn.execute(text(f"EXPLAIN {sql}")).scalars().all() + finally: + savepoint.rollback() + plan_text = "\n".join(plan) + assert "ix_reference_edges_repo_kind" in plan_text, plan_text + + +@pytest.mark.integration +def test_explain_candidates_select_uses_symbols_name_trgm_index(seeded: Seeded) -> None: + stmt = _build_candidates_select( + names=["ambiguous_fn", "unique_fn"], branch=None, candidate_cap=32 + ) + sql = str(stmt.compile(dialect=postgresql.dialect(), compile_kwargs={"literal_binds": True})) + savepoint = seeded.conn.begin_nested() + try: + seeded.conn.execute(text("SET LOCAL enable_seqscan = off")) + plan = seeded.conn.execute(text(f"EXPLAIN {sql}")).scalars().all() + finally: + savepoint.rollback() + plan_text = "\n".join(plan) + assert "ix_symbols_name_trgm" in plan_text, plan_text diff --git a/tests/integration/test_service.py b/tests/integration/test_service.py index 9e9763e..ba3a20e 100644 --- a/tests/integration/test_service.py +++ b/tests/integration/test_service.py @@ -25,7 +25,7 @@ from app import service from app.config import Settings from app.db.client import create_db_engine -from app.db.models import Base, File, Repo, Symbol +from app.db.models import Base, File, ReferenceEdge, Repo, Symbol from indexer.hashing import content_sha SCHEMA_PREFIX = "test_service" @@ -521,3 +521,206 @@ def test_permalink_branch_or_multi_branch_picks_smallest_intersection_and_round_ content = file_payload["content"] or "" assert 'fmt.Println("feature")' in content assert 'fmt.Println("main")' not in content + + +# ---------------------------------------- find_references_payload / list_imports_payload + + +class RefSeeded(NamedTuple): + engine: Engine + cfg: Settings + acme_id: int + beta_id: int + + +@pytest.fixture +def ref_seeded() -> Iterator[RefSeeded]: + """Same PGOPTIONS idiom as ``seeded``, with a small call/import edge + symbol corpus.""" + schema = _unique(f"{SCHEMA_PREFIX}_ref") + admin_engine = create_db_engine() + admin_conn = admin_engine.connect() + prev_pgoptions = os.environ.get("PGOPTIONS") + engine: Engine | None = None + try: + admin_conn.execute(text(f"DROP SCHEMA IF EXISTS {schema} CASCADE")) + admin_conn.execute(text(f"CREATE SCHEMA {schema}")) + admin_conn.execute(text(f"SET search_path TO {schema}, public")) + admin_conn.execute(text("CREATE EXTENSION IF NOT EXISTS pg_trgm")) + admin_conn.commit() + + Base.metadata.create_all(bind=admin_conn) + admin_conn.commit() + + acme_id = admin_conn.execute( + insert(Repo).values(name="acme/widgets", default_branch="main").returning(Repo.id) + ).scalar_one() + beta_id = admin_conn.execute( + insert(Repo).values(name="beta/tools", default_branch="main").returning(Repo.id) + ).scalar_one() + + def _file(repo_id: int, path: str) -> int: + content = f"# {path}\n" + return admin_conn.execute( + insert(File) + .values( + repo_id=repo_id, + path=path, + lang="python", + content=content, + content_sha=content_sha(content), + branches=["main"], + ) + .returning(File.id) + ).scalar_one() + + target_id = _file(acme_id, "src/target.py") + admin_conn.execute( + insert(Symbol).values( + file_id=target_id, repo_id=acme_id, name="Handler", kind="function", start_line=2 + ) + ) + caller_id = _file(acme_id, "src/caller.py") + admin_conn.execute( + insert(ReferenceEdge).values( + file_id=caller_id, + repo_id=acme_id, + edge_kind="call", + target_name="Handler", + line=5, + enclosing_name="run", + enclosing_kind="function", + ) + ) + importer_id = _file(acme_id, "src/importer.py") + admin_conn.execute( + insert(ReferenceEdge).values( + file_id=importer_id, + repo_id=acme_id, + edge_kind="import", + target_name="os.path", + line=1, + ) + ) + admin_conn.commit() + + os.environ["PGOPTIONS"] = f"-c search_path={schema},public" + engine = create_db_engine() + yield RefSeeded(engine=engine, cfg=_cfg(), acme_id=acme_id, beta_id=beta_id) + finally: + if engine is not None: + engine.dispose() + if prev_pgoptions is None: + os.environ.pop("PGOPTIONS", None) + else: + os.environ["PGOPTIONS"] = prev_pgoptions + admin_conn.rollback() + admin_conn.execute(text(f"DROP SCHEMA IF EXISTS {schema} CASCADE")) + admin_conn.commit() + admin_conn.close() + admin_engine.dispose() + + +@pytest.mark.integration +def test_find_references_payload_end_to_end_wire_shape(ref_seeded: RefSeeded) -> None: + payload = service.find_references_payload(ref_seeded.engine, ref_seeded.cfg, "Handler", 200) + + assert payload["query"] == "Handler" + assert payload["kind"] == "references" + assert payload["site_count"] == 1 + assert payload["resolution_summary"] == {"unique": 1, "ambiguous": 0, "unresolved": 0} + (site,) = payload["sites"] + assert site["repo"] == "acme/widgets" + assert site["file"] == "src/caller.py" + assert site["enclosing_symbol"] == {"name": "run", "kind": "function"} + (candidate,) = site["candidates"] + assert candidate["repo"] == "acme/widgets" + assert candidate["file"] == "src/target.py" + assert candidate["same_repo"] is True + assert "symbol_id" not in candidate + + +@pytest.mark.integration +def test_list_imports_payload_end_to_end_wire_shape(ref_seeded: RefSeeded) -> None: + payload = service.list_imports_payload(ref_seeded.engine, ref_seeded.cfg, "acme/widgets", 200) + + assert payload["kind"] == "imports" + assert payload["repo"] == "acme/widgets" + assert payload["repo_known"] is True + assert payload["site_count"] == 1 + assert payload["resolution_summary"] == {"unique": 0, "ambiguous": 0, "unresolved": 1} + (site,) = payload["sites"] + assert site["edge_kind"] == "import" + assert site["target_name"] == "os.path" + + +@pytest.mark.integration +def test_list_imports_payload_unknown_repo_against_real_corpus(ref_seeded: RefSeeded) -> None: + payload = service.list_imports_payload(ref_seeded.engine, ref_seeded.cfg, "ghost/repo", 200) + + assert payload["repo_known"] is False + assert payload["sites"] == [] + assert payload["resolution_summary"] == {"unique": 0, "ambiguous": 0, "unresolved": 0} + + +@pytest.mark.integration +def test_list_imports_payload_imported_by_finds_importer(ref_seeded: RefSeeded) -> None: + # "who imports os.path" -- corpus-wide over ix_reference_edges_target_name; the seeded + # importer site comes back with no repo scope requested (repo_known always True). + payload = service.list_imports_payload( + ref_seeded.engine, ref_seeded.cfg, target="os.path", direction="imported_by" + ) + + assert payload["kind"] == "imports" + assert payload["direction"] == "imported_by" + assert payload["target"] == "os.path" + assert payload["repo"] is None + assert payload["repo_known"] is True + assert payload["site_count"] == 1 + (site,) = payload["sites"] + assert site["repo"] == "acme/widgets" + assert site["file"] == "src/importer.py" + assert site["line"] == 1 + assert site["edge_kind"] == "import" + + +@pytest.mark.integration +def test_list_imports_payload_imported_by_unknown_target_is_empty_not_error( + ref_seeded: RefSeeded, +) -> None: + payload = service.list_imports_payload( + ref_seeded.engine, ref_seeded.cfg, target="nonexistent.module", direction="imported_by" + ) + + assert payload["query_too_broad"] is False + assert payload["repo_known"] is True + assert payload["sites"] == [] + assert payload["site_count"] == 0 + assert payload["resolution_summary"] == {"unique": 0, "ambiguous": 0, "unresolved": 0} + + +@pytest.mark.integration +def test_list_imports_payload_imported_by_repo_narrowing(ref_seeded: RefSeeded) -> None: + # The seeded importer is in acme/widgets, so narrowing to it keeps the site; narrowing to + # beta/tools (which has no such import) drops it -- a known repo with zero matching sites. + acme = service.list_imports_payload( + ref_seeded.engine, + ref_seeded.cfg, + "acme/widgets", + 200, + target="os.path", + direction="imported_by", + ) + assert acme["repo"] == "acme/widgets" + assert acme["repo_known"] is True + assert acme["site_count"] == 1 + + beta = service.list_imports_payload( + ref_seeded.engine, + ref_seeded.cfg, + "beta/tools", + 200, + target="os.path", + direction="imported_by", + ) + assert beta["repo_known"] is True + assert beta["sites"] == [] diff --git a/tests/integration/test_store.py b/tests/integration/test_store.py index c0db450..194fef5 100644 --- a/tests/integration/test_store.py +++ b/tests/integration/test_store.py @@ -24,7 +24,13 @@ from app.db.client import create_db_engine from app.db.grants import build_job_grants from app.db.models import INDEX_SEMANTICS_VERSION, Base -from indexer.languages import ExtractedSymbol, IndexCounts, ParsedFile +from indexer.languages import ( + ExtractedEdge, + ExtractedSymbol, + FileExtraction, + IndexCounts, + ParsedFile, +) from indexer.store import StaleIndexError, _stamp_repo_branch, index_repo SCHEMA = "test_store" @@ -58,9 +64,20 @@ def _pf(path: str, content: str) -> ParsedFile: def _items( - *specs: tuple[str, str, list[ExtractedSymbol]], -) -> list[tuple[ParsedFile, list[ExtractedSymbol]]]: - return [(_pf(path, content), syms) for path, content, syms in specs] + *specs: ( + tuple[str, str, list[ExtractedSymbol]] + | tuple[str, str, list[ExtractedSymbol], list[ExtractedEdge]] + ), +) -> list[tuple[ParsedFile, FileExtraction]]: + result: list[tuple[ParsedFile, FileExtraction]] = [] + for spec in specs: + if len(spec) == 3: + path, content, syms = spec + edges: list[ExtractedEdge] = [] + else: + path, content, syms, edges = spec + result.append((_pf(path, content), FileExtraction(symbols=syms, edges=edges))) + return result MAIN = ("main.py", "def f():\n return 1\n", [ExtractedSymbol("f", "function", 1, 2)]) @@ -79,7 +96,7 @@ def _index_default( *, name: str, head_sha: str, - items: Iterable[tuple[ParsedFile, list[ExtractedSymbol]]], + items: Iterable[tuple[ParsedFile, FileExtraction]], ) -> IndexCounts: """Shorthand for the pre-multi-branch call shape: one default branch, "main".""" return index_repo( @@ -92,7 +109,7 @@ def test_first_run_populates_and_stamps_commit(conn: Connection) -> None: counts = _index_default( conn, name="acme/widgets", head_sha="sha_first", items=_items(MAIN, UTIL) ) - assert counts == IndexCounts(files=2, symbols=2, swept=0) + assert counts == IndexCounts(files=2, symbols=2, swept=0, edges=0) assert _count(conn, "repos") == 1 assert _count(conn, "files") == 2 @@ -122,7 +139,7 @@ def test_rerun_is_idempotent(conn: Connection) -> None: counts = _index_default( conn, name="acme/widgets", head_sha="sha_first", items=_items(MAIN, UTIL) ) - assert counts == IndexCounts(files=2, symbols=2, swept=0) + assert counts == IndexCounts(files=2, symbols=2, swept=0, edges=0) assert _count(conn, "repos") == 1 assert _count(conn, "files") == 2 assert _count(conn, "symbols") == 2 @@ -130,9 +147,21 @@ def test_rerun_is_idempotent(conn: Connection) -> None: @pytest.mark.integration def test_mark_and_sweep_removes_deleted_file(conn: Connection) -> None: - _index_default(conn, name="acme/widgets", head_sha="sha_first", items=_items(MAIN, UTIL)) + # util.py has a real call site so the writer produces a reference_edges row + # -- proves the sweep's FK cascade reaches this table too. + util_symbol = ExtractedSymbol("g", "function", 1, 3) + util_with_edge = ( + "util.py", + "def g():\n helper()\n return 2\n", + [util_symbol], + [ExtractedEdge(kind="call", target="helper", line=2, enclosing=util_symbol)], + ) + _index_default( + conn, name="acme/widgets", head_sha="sha_first", items=_items(MAIN, util_with_edge) + ) removed_file_id = conn.execute(text("SELECT id FROM files WHERE path = 'util.py'")).scalar_one() assert _count(conn, "symbols", f"file_id = {removed_file_id}") == 1 + assert _count(conn, "reference_edges", f"file_id = {removed_file_id}") == 1 # Reads above autobegan a txn; clear it so index_repo gets a clean connection # (production hands a fresh engine.connect() per repo). @@ -140,10 +169,11 @@ def test_mark_and_sweep_removes_deleted_file(conn: Connection) -> None: # Re-run without util.py and with a new head SHA -> util.py is swept. counts = _index_default(conn, name="acme/widgets", head_sha="sha_second", items=_items(MAIN)) - assert counts == IndexCounts(files=1, symbols=1, swept=1) + assert counts == IndexCounts(files=1, symbols=1, swept=1, edges=0) assert _count(conn, "files", "path = 'util.py'") == 0 assert _count(conn, "symbols", f"file_id = {removed_file_id}") == 0 # cascade + assert _count(conn, "reference_edges", f"file_id = {removed_file_id}") == 0 # cascade assert _count(conn, "files") == 1 assert _count(conn, "files", "commit = 'sha_second'") == 1 @@ -162,7 +192,7 @@ def test_sweep_is_repo_scoped(conn: Connection) -> None: # Re-index A without util.py at a new SHA -> A's util.py swept, B untouched. counts = _index_default(conn, name="acme/a", head_sha="a_second", items=_items(MAIN)) - assert counts == IndexCounts(files=1, symbols=1, swept=1) + assert counts == IndexCounts(files=1, symbols=1, swept=1, edges=0) assert _count(conn, "files", "repo_id = (SELECT id FROM repos WHERE name = 'acme/b')") == ( b_files_before @@ -170,6 +200,126 @@ def test_sweep_is_repo_scoped(conn: Connection) -> None: assert _count(conn, "files", "commit = 'b_first'") == 1 # B's row unchanged +# --- Reference edges: writer, stale replacement, idempotency, zero-edge shed --- + + +@pytest.mark.integration +def test_indexing_writes_correct_reference_edge_rows(conn: Connection) -> None: + symbol = ExtractedSymbol("f", "function", 1, 3) + item = ( + "main.py", + "def f():\n helper()\n return 1\n", + [symbol], + [ExtractedEdge(kind="call", target="helper", line=2, enclosing=symbol)], + ) + counts = _index_default(conn, name="acme/widgets", head_sha="sha_first", items=_items(item)) + assert counts == IndexCounts(files=1, symbols=1, swept=0, edges=1) + + repo_id = conn.execute(text("SELECT id FROM repos WHERE name = 'acme/widgets'")).scalar_one() + file_id = conn.execute(text("SELECT id FROM files WHERE path = 'main.py'")).scalar_one() + row = conn.execute( + text( + "SELECT repo_id, file_id, edge_kind, target_name, line, " + "enclosing_name, enclosing_kind, enclosing_start_line, enclosing_end_line " + "FROM reference_edges WHERE file_id = :f" + ), + {"f": file_id}, + ).one() + assert row == (repo_id, file_id, "call", "helper", 2, "f", "function", 1, 3) + + +@pytest.mark.integration +def test_reindex_replaces_stale_edges_for_the_same_file(conn: Connection) -> None: + """Same file identity (content/content_sha unchanged) across two runs: edges are + deleted and reinserted, not accumulated -- proven by driving the two runs' + ``ex.edges`` directly rather than depending on the real extractor to disagree + with itself on unchanged content. + """ + symbol = ExtractedSymbol("f", "function", 1, 3) + content = "def f():\n target()\n return 1\n" + first_edge = ExtractedEdge(kind="call", target="old_target", line=2, enclosing=symbol) + _index_default( + conn, + name="acme/widgets", + head_sha="sha_first", + items=_items(("main.py", content, [symbol], [first_edge])), + ) + file_id = conn.execute(text("SELECT id FROM files WHERE path = 'main.py'")).scalar_one() + assert _count(conn, "reference_edges", f"file_id = {file_id}") == 1 + conn.rollback() + + second_edge = ExtractedEdge(kind="call", target="new_target", line=2, enclosing=symbol) + _index_default( + conn, + name="acme/widgets", + head_sha="sha_first", + items=_items(("main.py", content, [symbol], [second_edge])), + ) + + same_file_id = conn.execute(text("SELECT id FROM files WHERE path = 'main.py'")).scalar_one() + assert same_file_id == file_id + targets = ( + conn.execute( + text("SELECT target_name FROM reference_edges WHERE file_id = :f"), {"f": file_id} + ) + .scalars() + .all() + ) + assert targets == ["new_target"] + + +@pytest.mark.integration +def test_reindex_with_identical_items_does_not_duplicate_edges(conn: Connection) -> None: + symbol = ExtractedSymbol("f", "function", 1, 3) + item = ( + "main.py", + "def f():\n helper()\n return 1\n", + [symbol], + [ExtractedEdge(kind="call", target="helper", line=2, enclosing=symbol)], + ) + _index_default(conn, name="acme/widgets", head_sha="sha_first", items=_items(item)) + conn.rollback() + _index_default(conn, name="acme/widgets", head_sha="sha_first", items=_items(item)) + + file_id = conn.execute(text("SELECT id FROM files WHERE path = 'main.py'")).scalar_one() + assert _count(conn, "reference_edges", f"file_id = {file_id}") == 1 + + +@pytest.mark.integration +def test_reindex_to_zero_edges_sheds_all_rows(conn: Connection) -> None: + """The unconditional-delete guard: same file identity, edges vanish on re-index. + + Content (and thus ``content_sha``) is held IDENTICAL across both runs so the + upsert resolves to the SAME ``file_id`` -- isolating the write-side guard + (``ex.edges`` empty must still run the delete) from the unrelated + delete-and-reinsert-under-a-new-file-id path already covered above. + """ + symbol = ExtractedSymbol("f", "function", 1, 3) + content = "def f():\n helper()\n return 1\n" + edge = ExtractedEdge(kind="call", target="helper", line=2, enclosing=symbol) + _index_default( + conn, + name="acme/widgets", + head_sha="sha_first", + items=_items(("main.py", content, [symbol], [edge])), + ) + file_id = conn.execute(text("SELECT id FROM files WHERE path = 'main.py'")).scalar_one() + assert _count(conn, "reference_edges", f"file_id = {file_id}") == 1 + conn.rollback() + + # Same content (same file_id) but this run's extraction yields zero edges. + counts = _index_default( + conn, + name="acme/widgets", + head_sha="sha_second", + items=_items(("main.py", content, [symbol], [])), + ) + assert counts == IndexCounts(files=1, symbols=1, swept=0, edges=0) + same_file_id = conn.execute(text("SELECT id FROM files WHERE path = 'main.py'")).scalar_one() + assert same_file_id == file_id + assert _count(conn, "reference_edges", f"file_id = {file_id}") == 0 + + @pytest.mark.integration def test_stamp_writes_semantics_version(conn: Connection) -> None: _index_default(conn, name="acme/widgets", head_sha="sha_first", items=_items(MAIN, UTIL)) @@ -258,10 +408,13 @@ def test_midrun_failure_rolls_back_entirely(conn: Connection) -> None: symbols_before = _count(conn, "symbols") conn.rollback() # clear the read txn before the next index_repo (see note above) - def poison() -> Iterator[tuple[ParsedFile, list[ExtractedSymbol]]]: + def poison() -> Iterator[tuple[ParsedFile, FileExtraction]]: # First item is a NEW file that would be inserted; then blow up mid-stream # so the exception propagates out of index_repo's conn.begin(). - yield _pf("new.py", "def h():\n return 3\n"), [ExtractedSymbol("h", "function", 1, 2)] + yield ( + _pf("new.py", "def h():\n return 3\n"), + FileExtraction(symbols=[ExtractedSymbol("h", "function", 1, 2)], edges=[]), + ) raise RuntimeError("poison item") with pytest.raises(RuntimeError, match="poison item"): @@ -421,7 +574,7 @@ def test_per_branch_cas_resume_is_independent_per_branch(conn: Connection) -> No head_sha="sha_a2", items=_items(MAIN), ) - assert counts == IndexCounts(files=1, symbols=1, swept=0) + assert counts == IndexCounts(files=1, symbols=1, swept=0, edges=0) stamps = dict(conn.execute(text("SELECT branch, last_indexed_commit FROM repo_branches")).all()) assert stamps == {"a": "sha_a2", "b": "sha_b"} @@ -465,7 +618,7 @@ def test_empty_seen_set_skips_sweep_and_preserves_membership(conn: Connection) - counts = index_repo( conn, name="acme/widgets", branch="a", is_default=True, head_sha="sha_a2", items=[] ) - assert counts == IndexCounts(files=0, symbols=0, swept=0) + assert counts == IndexCounts(files=0, symbols=0, swept=0, edges=0) # main.py's membership in 'a' is untouched -- the sweep was skipped, not run. assert _count(conn, "files", "path = 'main.py'") == 1 diff --git a/tests/integration/test_store_chunk_writer.py b/tests/integration/test_store_chunk_writer.py index e32c45a..0dd67f5 100644 --- a/tests/integration/test_store_chunk_writer.py +++ b/tests/integration/test_store_chunk_writer.py @@ -25,7 +25,7 @@ from app.db.client import create_db_engine from app.db.models import Base from indexer.chunk_store import write_chunks -from indexer.languages import ExtractedSymbol, IndexCounts, ParsedFile +from indexer.languages import ExtractedSymbol, FileExtraction, IndexCounts, ParsedFile from indexer.store import index_repo SCHEMA = "test_store_chunk_writer" @@ -94,13 +94,22 @@ def _count(conn: Connection, table: str, where: str = "") -> int: UTIL = ("util.py", "def g():\n return 2\n", [ExtractedSymbol("g", "function", 1, 2)]) +def _items( + *specs: tuple[str, str, list[ExtractedSymbol]], +) -> list[tuple[ParsedFile, FileExtraction]]: + return [ + (_pf(path, content), FileExtraction(symbols=syms, edges=[])) + for path, content, syms in specs + ] + + @pytest.mark.integration def test_chunk_writer_none_is_byte_identical_to_the_core_path(conn: Connection) -> None: - items = [(_pf(path, content), syms) for path, content, syms in (MAIN, UTIL)] + items = _items(MAIN, UTIL) counts = index_repo( conn, name="acme/widgets", branch="main", is_default=True, head_sha="sha_first", items=items ) - assert counts == IndexCounts(files=2, symbols=2, swept=0) + assert counts == IndexCounts(files=2, symbols=2, swept=0, edges=0) assert _count(conn, "files") == 2 assert _count(conn, "symbols") == 2 assert _count(conn, "chunks") == 0 # no chunk_writer -> chunks untouched @@ -108,7 +117,7 @@ def test_chunk_writer_none_is_byte_identical_to_the_core_path(conn: Connection) @pytest.mark.integration def test_chunk_writer_writes_inside_the_transaction(conn: Connection) -> None: - items = [(_pf(path, content), syms) for path, content, syms in (MAIN, UTIL)] + items = _items(MAIN, UTIL) counts = index_repo( conn, name="acme/widgets", @@ -118,7 +127,7 @@ def test_chunk_writer_writes_inside_the_transaction(conn: Connection) -> None: items=items, chunk_writer=_stub_chunk_writer, ) - assert counts == IndexCounts(files=2, symbols=2, swept=0) + assert counts == IndexCounts(files=2, symbols=2, swept=0, edges=0) assert _count(conn, "chunks") == 2 main_file_id = conn.execute(text("SELECT id FROM files WHERE path = 'main.py'")).scalar_one() @@ -127,7 +136,7 @@ def test_chunk_writer_writes_inside_the_transaction(conn: Connection) -> None: @pytest.mark.integration def test_reindex_is_idempotent_for_chunks(conn: Connection) -> None: - items = [(_pf(path, content), syms) for path, content, syms in (MAIN, UTIL)] + items = _items(MAIN, UTIL) index_repo( conn, name="acme/widgets", @@ -147,13 +156,13 @@ def test_reindex_is_idempotent_for_chunks(conn: Connection) -> None: items=items, chunk_writer=_stub_chunk_writer, ) - assert counts == IndexCounts(files=2, symbols=2, swept=0) + assert counts == IndexCounts(files=2, symbols=2, swept=0, edges=0) assert _count(conn, "chunks") == 2 # delete-and-reinsert, not duplicated @pytest.mark.integration def test_chunks_cascade_delete_when_file_is_swept(conn: Connection) -> None: - items = [(_pf(path, content), syms) for path, content, syms in (MAIN, UTIL)] + items = _items(MAIN, UTIL) index_repo( conn, name="acme/widgets", @@ -168,7 +177,7 @@ def test_chunks_cascade_delete_when_file_is_swept(conn: Connection) -> None: conn.rollback() # Re-index without util.py at a new SHA -> util.py (and its chunks) swept. - main_only = [(_pf(*MAIN[:2]), MAIN[2])] + main_only = _items(MAIN) counts = index_repo( conn, name="acme/widgets", @@ -178,7 +187,7 @@ def test_chunks_cascade_delete_when_file_is_swept(conn: Connection) -> None: items=main_only, chunk_writer=_stub_chunk_writer, ) - assert counts == IndexCounts(files=1, symbols=1, swept=1) + assert counts == IndexCounts(files=1, symbols=1, swept=1, edges=0) assert _count(conn, "files", "path = 'util.py'") == 0 assert _count(conn, "chunks", f"file_id = {util_file_id}") == 0 # cascade assert _count(conn, "chunks") == 1 diff --git a/tests/integration/test_webui_graph_parity.py b/tests/integration/test_webui_graph_parity.py new file mode 100644 index 0000000..449c9a3 --- /dev/null +++ b/tests/integration/test_webui_graph_parity.py @@ -0,0 +1,257 @@ +"""Integration test proving AC2 for issue #88: the webui ``/api/references``/``/api/imports`` +routes serve payloads byte-identical to ``app.service``'s builders -- the SAME builders the MCP +``find_references``/``list_imports`` tools wrap -- over a real seeded reference-edge corpus. + +**Transitivity, not a second live harness.** ``app/main.py``'s ``find_references``/ +``list_imports`` MCP tools are pure ``clamp_limit`` -> ``json.dumps(builder(...))`` wrappers +around these SAME ``app.service`` builder functions (see ``app/main.py``'s module docstring), +independently pinned wire-shape-identical to the builder output by +``tests/integration/test_mcp_server.py::test_reference_tools_streamable_http``. So proving +``webui route JSON == builder output`` here (over the identical corpus, at the identical +clamped limit) transitively proves ``webui route JSON == MCP wire payload`` without a second +live MCP client/server round trip in this test (rejected in the binding plan: wiring +cost/flakiness, no added guarantee over the existing MCP e2e pin). + +**Seed reuse.** ``seed_reference_corpus`` is the SAME connection-parameterized helper +``tests/integration/test_mcp_server.py::seeded_schema`` uses (extracted there for reuse here); +this fixture builds its own throwaway schema/repo and calls it directly, rather than duplicating +the corpus. + +**Clamp discipline (Critic note 4).** Every direct-builder comparison call below applies +``service.clamp_limit(request_limit, cfg)`` to its ``limit`` argument, exactly mirroring what +the route does -- an un-clamped direct call would make a "byte-identical" assertion pass +vacuously on this small corpus even if the route's own clamping regressed. +""" + +from __future__ import annotations + +import os +import uuid +from collections.abc import Iterator +from typing import Any + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import insert, text +from sqlalchemy.engine import Engine + +from app import service +from app.config import Settings +from app.db.client import create_db_engine +from app.db.models import Base, Repo +from tests.integration.test_mcp_server import seed_reference_corpus +from webui.main import app, get_engine, get_settings + +SCHEMA_PREFIX = "test_webui_graph_parity" + + +def _unique(prefix: str) -> str: + return f"{prefix}_{uuid.uuid4().hex[:12]}" + + +def _cfg() -> Settings: + return Settings( + lakebase_endpoint=None, + statement_timeout_ms=5000, + row_limit=200, + max_row_limit=1000, + semantic_enabled=False, + ) + + +def _set_pgoptions(schema: str) -> str | None: + """Point every connection THIS engine opens at ``schema`` via libpq PGOPTIONS. + + Mirrors ``tests/integration/test_webui_semantic.py``: set BEFORE building the engine so + every pooled connection is born under the right ``search_path`` (a bare + ``SET search_path`` on one connection would not follow the pool). + """ + prev = os.environ.get("PGOPTIONS") + os.environ["PGOPTIONS"] = f"-c search_path={schema},public" + return prev + + +def _restore_pgoptions(prev: str | None) -> None: + if prev is None: + os.environ.pop("PGOPTIONS", None) + else: + os.environ["PGOPTIONS"] = prev + + +@pytest.fixture +def reference_engine() -> Iterator[Engine]: + """A throwaway schema + durable-core DDL + the shared reference-edge corpus, on its own + engine (webui routes take their engine via ``dependency_overrides``, not a process-scoped + singleton built from env -- so this fixture owns its engine directly, unlike + ``test_mcp_server.py``'s ``seeded_schema``).""" + schema = _unique(SCHEMA_PREFIX) + prev_pgoptions = _set_pgoptions(schema) + engine = create_db_engine() + try: + with engine.connect() as conn: + conn.execute(text(f"DROP SCHEMA IF EXISTS {schema} CASCADE")) + conn.execute(text(f"CREATE SCHEMA {schema}")) + conn.execute(text(f"SET search_path TO {schema}, public")) + conn.execute(text("CREATE EXTENSION IF NOT EXISTS pg_trgm")) + conn.commit() + + Base.metadata.create_all(bind=conn) + conn.commit() + + acme_id = conn.execute( + insert(Repo) + .values(name="acme/widgets", default_branch="main", last_indexed_commit="abc123") + .returning(Repo.id) + ).scalar_one() + seed_reference_corpus(conn, acme_id) + conn.commit() + yield engine + finally: + with engine.connect() as conn: + conn.execute(text(f"DROP SCHEMA IF EXISTS {schema} CASCADE")) + conn.commit() + engine.dispose() + _restore_pgoptions(prev_pgoptions) + + +@pytest.fixture +def client(reference_engine: Engine) -> Iterator[TestClient]: + app.dependency_overrides[get_engine] = lambda: reference_engine + app.dependency_overrides[get_settings] = _cfg + try: + with TestClient(app) as test_client: + yield test_client + finally: + app.dependency_overrides.clear() + + +def _direct_references( + engine: Engine, name: str, limit: int, branch: str | None = None +) -> dict[str, Any]: + cfg = _cfg() + clamped = service.clamp_limit(limit, cfg) + return service.find_references_payload(engine, cfg, name, clamped, branch) + + +def _direct_imports( + engine: Engine, + repo: str | None = None, + limit: int = 200, + branch: str | None = None, + *, + target: str | None = None, + direction: str = "imports", +) -> dict[str, Any]: + cfg = _cfg() + clamped = service.clamp_limit(limit, cfg) + return service.list_imports_payload( + engine, cfg, repo, clamped, branch, target=target, direction=direction + ) + + +@pytest.mark.integration +def test_find_references_ambiguous_process_parity( + client: TestClient, reference_engine: Engine +) -> None: + resp = client.get("/api/references", params={"symbol": "process"}) + assert resp.status_code == 200 + body = resp.json() + + expected = _direct_references(reference_engine, "process", 200) + assert body == expected + + # Corpus shape sanity (D5 seed): two ambiguous 2-candidate call sites (src/caller.py, + # tests/test_service.py), never collapsed to one answer. + assert body["resolution_summary"] == {"unique": 0, "ambiguous": 2, "unresolved": 0} + assert body["site_count"] == 2 + for site in body["sites"]: + assert site["resolution"] == "ambiguous" + assert site["candidate_count"] == 2 + + +@pytest.mark.integration +def test_find_references_branch_scoped_unresolved_caller_parity( + client: TestClient, reference_engine: Engine +) -> None: + resp = client.get("/api/references", params={"symbol": "process", "branch": "feature/x"}) + assert resp.status_code == 200 + body = resp.json() + + expected = _direct_references(reference_engine, "process", 200, branch="feature/x") + assert body == expected + + # feature/x has a caller of "process" but no "process" definition on that branch -> + # candidate-side branch scoping resolves it unresolved. + assert body["site_count"] == 1 + assert body["sites"][0]["resolution"] == "unresolved" + assert body["sites"][0]["candidate_count"] == 0 + + +@pytest.mark.integration +def test_find_references_undefined_symbol_parity( + client: TestClient, reference_engine: Engine +) -> None: + resp = client.get("/api/references", params={"symbol": "missing_fn"}) + assert resp.status_code == 200 + body = resp.json() + + expected = _direct_references(reference_engine, "missing_fn", 200) + assert body == expected + + assert body["site_count"] == 1 + assert body["sites"][0]["resolution"] == "unresolved" + assert body["sites"][0]["candidate_count"] == 0 + + +@pytest.mark.integration +def test_list_imports_by_repo_parity(client: TestClient, reference_engine: Engine) -> None: + resp = client.get("/api/imports", params={"repo": "acme/widgets"}) + assert resp.status_code == 200 + body = resp.json() + + expected = _direct_imports(reference_engine, repo="acme/widgets") + assert body == expected + + assert body["repo_known"] is True + assert body["direction"] == "imports" + targets = {site["target_name"] for site in body["sites"]} + assert targets == {"os.path", "collections.abc"} + # Import edges are module-scope (enclosing_symbol None) and external-by-design -> + # "unresolved" is expected, not an error. + for site in body["sites"]: + assert site["enclosing_symbol"] is None + assert site["resolution"] == "unresolved" + + +@pytest.mark.integration +def test_list_imports_imported_by_target_parity( + client: TestClient, reference_engine: Engine +) -> None: + resp = client.get("/api/imports", params={"target": "os.path", "direction": "imported_by"}) + assert resp.status_code == 200 + body = resp.json() + + expected = _direct_imports(reference_engine, target="os.path", direction="imported_by") + assert body == expected + + assert body["repo"] is None + assert body["direction"] == "imported_by" + assert body["site_count"] == 1 + assert body["sites"][0]["target_name"] == "os.path" + + +@pytest.mark.integration +def test_list_imports_unsupported_direction_is_200_structured( + client: TestClient, reference_engine: Engine +) -> None: + resp = client.get("/api/imports", params={"direction": "sideways"}) + assert resp.status_code == 200 + body = resp.json() + + expected = _direct_imports(reference_engine, direction="sideways") + assert body == expected + + assert body["unsupported_direction"] == "sideways" + assert "reason" in body + assert body["sites"] == [] + assert body["site_count"] == 0 diff --git a/tests/unit/AGENTS.md b/tests/unit/AGENTS.md index e627ba0..c12bc22 100644 --- a/tests/unit/AGENTS.md +++ b/tests/unit/AGENTS.md @@ -15,13 +15,14 @@ Hermetic unit tests: no network, no database, no Databricks SDK instantiation. E | `test_chunking.py` | `indexer.parse.iter_chunks` chunking behavior. | | `test_ci_branch.py` | `scripts/ci_branch.py` lifecycle with the SDK fully faked; pins that teardown NEVER raises and every create carries a TTL (leak protection for cancelled CI runs). | | `test_db_client.py` | Engine factory local (`PGHOST`) mode builds without instantiating the SDK; ORM models expose exactly the durable-core columns, constraints, and GIN indexes. | +| `test_edges.py` | `indexer.symbols.extract_file`'s reference-edge extraction (#84, Python): call-target resolution (rightmost identifier), import-target resolution (dotted paths, aliases, relative imports, wildcards), enclosing-symbol attribution, non-Python languages yield no edges, `extract_symbols` wrapper equivalence, determinism. | | `test_embed.py` | `app.embed`: batching, retry, dim-mismatch, lazy SDK import — every test injects a fake `client`, `databricks.sdk` is never imported. | | `test_fetch.py` | `indexer.fetch` via `httpx.MockTransport` + in-memory tarballs. | | `test_grants.py` | Least-privilege grant builders: presence AND absence of privileges per role; hostile identifiers rejected before SQL is produced. | | `test_grep.py` | Grep line extraction + matcher building (`extract_line_matches`, `_build_matchers`); byte-offset invariant `line_text.encode("utf-8")[s:e] == matched`. | | `test_job.py` | `indexer.job`: `read_github_token` + orchestration with every I/O boundary faked (fake `WorkspaceClient`, injected `config_loader`, etc.). | | `test_job_redaction.py` | GitHub-token redaction proof + source-level tripwire (two independent guards). | -| `test_languages.py` | Language/symbol source-of-truth maps: `SYMBOL_KINDS` ⊆ `EXT_TO_LANG` values, no orphans. | +| `test_languages.py` | Language/symbol source-of-truth maps: `SYMBOL_KINDS` ⊆ `EXT_TO_LANG` values, no orphans; `EDGE_NODE_KINDS` ⊆ `EXT_TO_LANG` values, no orphans; every `EDGE_NODE_KINDS` kind is within `reference_edges`' DB CHECK set. | | `test_main.py` | MCP server payload builders (`_search_code_payload` / `_list_repos_payload` / `_get_file_payload`), error mapping, and the `observability`-marked logging choke-point tests; fake engine/connection + fake `GrepResult`. | | `test_migration_source.py` | Static source assertions on the linear migrations: fixed revision ids, `down_revision` chain, `pg_trgm` invariants. | | `test_migration_source_semantic.py` | Static source assertions on the semantic `0004` revision (its DDL only runs on a Lakebase branch, so source reads are the unit-tier guard). | diff --git a/tests/unit/test_db_client.py b/tests/unit/test_db_client.py index d25acd4..7c5ed35 100644 --- a/tests/unit/test_db_client.py +++ b/tests/unit/test_db_client.py @@ -58,7 +58,13 @@ def _boom(*args: object, **kwargs: object) -> object: @pytest.mark.unit def test_metadata_has_exactly_durable_core_tables() -> None: - assert set(Base.metadata.tables) == {"repos", "files", "symbols", "repo_branches"} + assert set(Base.metadata.tables) == { + "repos", + "files", + "symbols", + "repo_branches", + "reference_edges", + } @pytest.mark.unit diff --git a/tests/unit/test_edges.py b/tests/unit/test_edges.py new file mode 100644 index 0000000..6be8723 --- /dev/null +++ b/tests/unit/test_edges.py @@ -0,0 +1,578 @@ +"""Unit tests for indexer.symbols.extract_file's reference-edge extraction (Python, #84). + +Mirrors test_symbols.py's style: pure tree-sitter parsing, no DB. Covers call-target +resolution (D4), import-target resolution (D5), and enclosing attribution (D6). +""" + +from __future__ import annotations + +import pytest + +from indexer.languages import ExtractedEdge, ParsedFile +from indexer.symbols import extract_file, extract_symbols + + +def _pf(content: str, lang: str | None = "python") -> ParsedFile: + return ParsedFile(path="x.py", lang=lang, size=len(content), content=content) + + +def _edges(content: str, lang: str | None = "python") -> list[ExtractedEdge]: + return extract_file(_pf(content, lang=lang)).edges + + +@pytest.mark.unit +def test_bare_call_at_top_level() -> None: + edges = _edges("f(x)\n") + assert edges == [ExtractedEdge(kind="call", target="f", line=1, enclosing=None)] + + +@pytest.mark.unit +def test_nested_calls_two_edges_correct_lines() -> None: + edges = _edges("f(g(x))\n") + assert [(e.target, e.line) for e in edges] == [("f", 1), ("g", 1)] + + +@pytest.mark.unit +def test_method_and_bare_calls_both_use_rightmost_identifier() -> None: + edges = _edges("self.helper()\nhelper()\n") + assert [e.target for e in edges] == ["helper", "helper"] + + +@pytest.mark.unit +def test_dotted_callee_uses_rightmost_identifier() -> None: + edges = _edges("a.b.f()\n") + assert [e.target for e in edges] == ["f"] + + +@pytest.mark.unit +def test_enclosing_attribution_function_method_class_and_module() -> None: + content = ( + "def top():\n" + " call_in_fn()\n" + "\n" + "class C:\n" + " def m(self):\n" + " call_in_method()\n" + " call_in_class_body()\n" + "\n" + "call_at_module_scope()\n" + ) + fx = extract_file(_pf(content)) + by_target = {e.target: e for e in fx.edges} + + fn_edge = by_target["call_in_fn"] + assert fn_edge.enclosing is not None + assert fn_edge.enclosing.name == "top" + assert fn_edge.enclosing.kind == "function" + + method_edge = by_target["call_in_method"] + assert method_edge.enclosing is not None + assert method_edge.enclosing.name == "m" + assert method_edge.enclosing.kind == "function" + + class_edge = by_target["call_in_class_body"] + assert class_edge.enclosing is not None + assert class_edge.enclosing.name == "C" + assert class_edge.enclosing.kind == "class" + + module_edge = by_target["call_at_module_scope"] + assert module_edge.enclosing is None + + by_name = {s.name: s for s in fx.symbols} + assert method_edge.enclosing.start_line == by_name["m"].start_line + assert method_edge.enclosing.end_line == by_name["m"].end_line + assert class_edge.enclosing.start_line == by_name["C"].start_line + assert class_edge.enclosing.end_line == by_name["C"].end_line + + +@pytest.mark.unit +def test_decorator_with_args_emits_edge_bare_decorator_does_not() -> None: + content = "@deco(x)\ndef foo(): pass\n@bare\ndef bar(): pass\n" + edges = _edges(content) + assert [e.target for e in edges] == ["deco"] + + +@pytest.mark.unit +def test_non_identifier_callees_are_skipped() -> None: + edges = _edges("xs[0]()\nf()()\n") + # xs[0]() has no rightmost identifier -> skipped. + # f()()'s outer call target is itself a `call` node -> skipped; the inner f() is counted once. + assert [e.target for e in edges] == ["f"] + + +@pytest.mark.unit +def test_import_plain_dotted_path() -> None: + edges = _edges("import a.b.c\n") + assert edges == [ExtractedEdge(kind="import", target="a.b.c", line=1, enclosing=None)] + + +@pytest.mark.unit +def test_import_alias_is_insensitive_to_binding_name() -> None: + edges = _edges("import a.b.c as d\n") + assert [e.target for e in edges] == ["a.b.c"] + + +@pytest.mark.unit +def test_import_multiple_names_two_edges() -> None: + edges = _edges("import a, b\n") + assert [e.target for e in edges] == ["a", "b"] + + +@pytest.mark.unit +def test_from_import_names_and_alias() -> None: + edges = _edges("from a.b import c, d as e\n") + assert [e.target for e in edges] == ["a.b.c", "a.b.d"] + + +@pytest.mark.unit +def test_relative_import_single_dot() -> None: + edges = _edges("from . import x\n") + assert [e.target for e in edges] == [".x"] + + +@pytest.mark.unit +def test_relative_import_double_dot_with_module() -> None: + edges = _edges("from ..p import q\n") + assert [e.target for e in edges] == ["..p.q"] + + +@pytest.mark.unit +def test_wildcard_import_targets_the_module() -> None: + edges = _edges("from a.b import *\n") + assert edges == [ExtractedEdge(kind="import", target="a.b", line=1, enclosing=None)] + + +@pytest.mark.unit +def test_multiline_parenthesized_from_import_per_name_lines() -> None: + content = "from a.b import (\n c,\n d,\n)\n" + edges = _edges(content) + assert [(e.target, e.line) for e in edges] == [("a.b.c", 2), ("a.b.d", 3)] + + +@pytest.mark.unit +def test_function_local_import_attributes_to_enclosing_function() -> None: + content = "def outer():\n import os\n" + fx = extract_file(_pf(content)) + assert len(fx.edges) == 1 + edge = fx.edges[0] + assert edge.target == "os" + assert edge.enclosing is not None + assert edge.enclosing.name == "outer" + + +@pytest.mark.unit +def test_none_lang_yields_no_edges() -> None: + assert extract_file(_pf("f(x)\n", lang=None)).edges == [] + + +@pytest.mark.unit +def test_javascript_call_and_import_edges_on_separate_lines() -> None: + content = "f(x);\nimport { a } from 'b';\n" + edges = _edges(content, lang="javascript") + assert [(e.kind, e.target, e.line) for e in edges] == [ + ("call", "f", 1), + ("import", "b.a", 2), + ] + + +@pytest.mark.unit +def test_extract_symbols_is_a_thin_wrapper_over_extract_file() -> None: + content = "class C:\n def m(self):\n helper()\n" + pf = _pf(content) + assert extract_symbols(pf) == extract_file(pf).symbols + + +@pytest.mark.unit +def test_extraction_is_deterministic() -> None: + content = "import a.b\nclass C:\n def m(self):\n helper()\n other.call()\n" + pf = _pf(content) + first = extract_file(pf).edges + for _ in range(5): + assert extract_file(pf).edges == first + + +@pytest.mark.unit +def test_extraction_is_deterministic_for_a_non_python_language() -> None: + content = "use a::b::{c, d};\nfn f() { a::b::c(); }\n" + pf = _pf(content, lang="rust") + first = extract_file(pf).edges + for _ in range(5): + assert extract_file(pf).edges == first + + +# --- #85: JavaScript / TypeScript / TSX -------------------------------------- + + +@pytest.mark.unit +@pytest.mark.parametrize( + ("content", "expected"), + [ + ("f(x);", [("call", "f", 1)]), + ("a.b.f();", [("call", "f", 1)]), + ("obj?.m();", [("call", "m", 1)]), + ("new Foo();", [("call", "Foo", 1)]), + ("new a.b.Foo();", [("call", "Foo", 1)]), + ("require('y');", [("call", "require", 1)]), + ("import d from 'm';", [("import", "m", 1)]), + ( + "import { a, b as c } from 'mod';", + [("import", "mod.a", 1), ("import", "mod.b", 1)], + ), + ("import * as ns from 'ns';", [("import", "ns", 1)]), + ("import 'side-effect';", [("import", "side-effect", 1)]), + ( + "import d, { a } from 'm';", + [("import", "m", 1), ("import", "m.a", 1)], + ), + ("export { z } from 'w';", []), + ("import('m').then(f);", [("call", "then", 1)]), + ("import('dyn');", []), + ], +) +def test_javascript_shape_fixtures(content: str, expected: list[tuple[str, str, int]]) -> None: + edges = _edges(content, lang="javascript") + assert [(e.kind, e.target, e.line) for e in edges] == expected + + +@pytest.mark.unit +def test_javascript_multiline_named_import_per_specifier_lines() -> None: + content = "import {\n a,\n b,\n} from 'mod';\n" + edges = _edges(content, lang="javascript") + assert [(e.kind, e.target, e.line) for e in edges] == [ + ("import", "mod.a", 2), + ("import", "mod.b", 3), + ] + + +@pytest.mark.unit +def test_typescript_generic_call_and_import_type_and_require() -> None: + assert [(e.kind, e.target, e.line) for e in _edges("f(x);", lang="typescript")] == [ + ("call", "f", 1) + ] + assert [ + (e.kind, e.target, e.line) for e in _edges("import type { T } from 'm';", lang="typescript") + ] == [("import", "m.T", 1)] + assert [ + (e.kind, e.target, e.line) + for e in _edges("import x = require('legacy');", lang="typescript") + ] == [("import", "legacy", 1)] + + +@pytest.mark.unit +def test_tsx_jsx_component_ignored_inner_call_captured() -> None: + content = "const e = ;\n" + edges = _edges(content, lang="tsx") + assert [(e.kind, e.target, e.line) for e in edges] == [("call", "g", 1)] + + +@pytest.mark.unit +def test_tsx_plain_named_import() -> None: + content = "import { Comp } from './comp';\n" + edges = _edges(content, lang="tsx") + assert [(e.kind, e.target, e.line) for e in edges] == [("import", "./comp.Comp", 1)] + + +@pytest.mark.unit +def test_javascript_and_typescript_share_the_same_call_and_import_extractors() -> None: + content = "f(x);\nimport { a } from 'm';\n" + assert _edges(content, lang="javascript") == _edges(content, lang="typescript") + + +# --- #85: Go ------------------------------------------------------------------ + + +def _go(body: str) -> str: + return f"package main\nfunc f() {{\n{body}}}\n" + + +@pytest.mark.unit +@pytest.mark.parametrize( + ("body", "expected_target"), + [ + (" f(x)\n", "f"), + (" pkg.F()\n", "F"), + (" obj.Method()\n", "Method"), + (" go h()\n", "h"), + (" defer cleanup()\n", "cleanup"), + ], +) +def test_go_call_shape_fixtures(body: str, expected_target: str) -> None: + edges = _edges(_go(body), lang="go") + assert [e.target for e in edges if e.kind == "call"] == [expected_target] + + +@pytest.mark.unit +def test_go_single_imports() -> None: + assert [(e.kind, e.target) for e in _edges('package main\nimport "fmt"\n', lang="go")] == [ + ("import", "fmt") + ] + assert [ + (e.kind, e.target) for e in _edges('package main\nimport "github.com/x/y"\n', lang="go") + ] == [("import", "github.com/x/y")] + + +@pytest.mark.unit +def test_go_grouped_import_per_spec_lines_and_aliases_ignored() -> None: + content = 'package main\nimport (\n "fmt"\n m "math"\n . "strings"\n _ "driver"\n)\n' + edges = _edges(content, lang="go") + assert [(e.kind, e.target, e.line) for e in edges] == [ + ("import", "fmt", 3), + ("import", "math", 4), + ("import", "strings", 5), + ("import", "driver", 6), + ] + + +@pytest.mark.unit +def test_go_empty_import_group_yields_no_edges() -> None: + content = "package main\nimport (\n)\n" + assert _edges(content, lang="go") == [] + + +# --- #85: Java ------------------------------------------------------------------ + + +def _java(body: str) -> str: + return f"class C {{\n void f() {{\n{body} }}\n}}\n" + + +@pytest.mark.unit +@pytest.mark.parametrize( + ("body", "expected_target"), + [ + (" f();\n", "f"), + (" obj.m();\n", "m"), + (" C.stat();\n", "stat"), + (" this.n();\n", "n"), + (" super.s();\n", "s"), + (" obj.m();\n", "m"), + (" new Foo();\n", "Foo"), + (" new a.b.Foo();\n", "Foo"), + (" new Foo();\n", "Foo"), + (" new java.util.ArrayList();\n", "ArrayList"), + ], +) +def test_java_call_shape_fixtures(body: str, expected_target: str) -> None: + edges = _edges(_java(body), lang="java") + assert [e.target for e in edges if e.kind == "call"] == [expected_target] + + +@pytest.mark.unit +def test_java_import_shapes() -> None: + assert [(e.kind, e.target) for e in _edges("import a.b.C;", lang="java")] == [ + ("import", "a.b.C") + ] + assert [(e.kind, e.target) for e in _edges("import static a.b.C.m;", lang="java")] == [ + ("import", "a.b.C.m") + ] + assert [(e.kind, e.target) for e in _edges("import a.b.*;", lang="java")] == [("import", "a.b")] + + +# --- #85: Rust ------------------------------------------------------------------ + + +def _rust(body: str) -> str: + return f"fn f() {{\n{body}}}\n" + + +@pytest.mark.unit +@pytest.mark.parametrize( + ("body", "expected_target"), + [ + (" f();\n", "f"), + (" a::b::g();\n", "g"), + (" Foo::new();\n", "new"), + (" x.method();\n", "method"), + ], +) +def test_rust_call_shape_fixtures(body: str, expected_target: str) -> None: + edges = _edges(_rust(body), lang="rust") + assert [e.target for e in edges if e.kind == "call"] == [expected_target] + + +@pytest.mark.unit +def test_rust_macro_invocation_is_not_an_edge() -> None: + edges = _edges(_rust(' println!("hi");\n'), lang="rust") + assert edges == [] + + +@pytest.mark.unit +@pytest.mark.parametrize( + ("content", "expected"), + [ + ("use a::b::c;", [("import", "a::b::c")]), + ("use g as h;", [("import", "g")]), + ("use a::*;", [("import", "a")]), + ("use crate::x;", [("import", "crate::x")]), + ("use super::y;", [("import", "super::y")]), + ("use self::z;", [("import", "self::z")]), + ], +) +def test_rust_use_shape_fixtures(content: str, expected: list[tuple[str, str]]) -> None: + edges = _edges(content, lang="rust") + assert [(e.kind, e.target) for e in edges] == expected + + +@pytest.mark.unit +def test_rust_use_grouped_renamed_per_item() -> None: + edges = _edges("use a::b::{d, e as f};", lang="rust") + assert [(e.kind, e.target) for e in edges] == [ + ("import", "a::b::d"), + ("import", "a::b::e"), + ] + + +@pytest.mark.unit +def test_rust_use_self_in_group_and_sibling() -> None: + edges = _edges("use a::b::{self, d};", lang="rust") + assert [(e.kind, e.target) for e in edges] == [ + ("import", "a::b"), + ("import", "a::b::d"), + ] + + +@pytest.mark.unit +def test_rust_use_nested_scoped_lists() -> None: + edges = _edges("use a::{b::{c, d}};", lang="rust") + assert [(e.kind, e.target) for e in edges] == [ + ("import", "a::b::c"), + ("import", "a::b::d"), + ] + + +@pytest.mark.unit +def test_rust_use_bare_prefix_less_group() -> None: + """``use {a::b, c::d};`` (no leading path) is a bare ``use_list`` argument -- + distinct from ``scoped_use_list``, which always has a ``path`` field.""" + edges = _edges("use {a::b, c::d};", lang="rust") + assert [(e.kind, e.target) for e in edges] == [ + ("import", "a::b"), + ("import", "c::d"), + ] + + +@pytest.mark.unit +def test_rust_import_attributes_to_enclosing_function() -> None: + content = "fn outer() {\n use a::b;\n c();\n}\n" + fx = extract_file(_pf(content, lang="rust")) + import_edge = next(e for e in fx.edges if e.kind == "import") + assert import_edge.target == "a::b" + assert import_edge.enclosing is not None + assert import_edge.enclosing.name == "outer" + + +@pytest.mark.unit +def test_javascript_default_and_namespace_import_both_target_the_module() -> None: + """Documents current behavior: a default+namespace combo binds two local + names to the same module, so both clauses independently emit a + statement-anchored edge targeting that module (duplicate kind/target/line is + expected here, not a bug -- there is no name-class target to disambiguate + module-class default/namespace imports).""" + edges = _edges("import d, * as ns from 'm';", lang="javascript") + assert [(e.kind, e.target, e.line) for e in edges] == [ + ("import", "m", 1), + ("import", "m", 1), + ] + + +# --- #85: enclosing attribution per language ----------------------------------- + + +@pytest.mark.unit +def test_javascript_enclosing_attribution_function_method_and_module() -> None: + content = ( + "function top() {\n" + " callInFn();\n" + "}\n" + "class C {\n" + " m() {\n" + " callInMethod();\n" + " }\n" + "}\n" + "callAtModuleScope();\n" + ) + fx = extract_file(_pf(content, lang="javascript")) + by_target = {e.target: e for e in fx.edges} + + fn_edge = by_target["callInFn"] + assert fn_edge.enclosing is not None + assert fn_edge.enclosing.name == "top" + assert fn_edge.enclosing.kind == "function" + + method_edge = by_target["callInMethod"] + assert method_edge.enclosing is not None + assert method_edge.enclosing.name == "m" + assert method_edge.enclosing.kind == "method" + + assert by_target["callAtModuleScope"].enclosing is None + + +@pytest.mark.unit +def test_go_enclosing_attribution_function_and_method() -> None: + content = ( + "package main\nfunc top() {\n callInFn()\n}\nfunc (r *R) m() {\n callInMethod()\n}\n" + ) + fx = extract_file(_pf(content, lang="go")) + by_target = {e.target: e for e in fx.edges} + + fn_edge = by_target["callInFn"] + assert fn_edge.enclosing is not None + assert fn_edge.enclosing.name == "top" + assert fn_edge.enclosing.kind == "function" + + method_edge = by_target["callInMethod"] + assert method_edge.enclosing is not None + assert method_edge.enclosing.name == "m" + assert method_edge.enclosing.kind == "method" + + +@pytest.mark.unit +def test_java_enclosing_attribution_method_inside_class() -> None: + content = "class C {\n void m() {\n callInMethod();\n }\n}\n" + fx = extract_file(_pf(content, lang="java")) + edge = fx.edges[0] + assert edge.target == "callInMethod" + assert edge.enclosing is not None + assert edge.enclosing.name == "m" + assert edge.enclosing.kind == "method" + + +@pytest.mark.unit +def test_java_enclosing_attribution_interface_default_method() -> None: + content = "interface I {\n default void m() {\n callInMethod();\n }\n}\n" + fx = extract_file(_pf(content, lang="java")) + edge = fx.edges[0] + assert edge.target == "callInMethod" + assert edge.enclosing is not None + assert edge.enclosing.name == "m" + assert edge.enclosing.kind == "method" + + +@pytest.mark.unit +def test_rust_enclosing_attribution_function_and_impl_method() -> None: + content = ( + "fn top() {\n" + " callInFn();\n" + "}\n" + "struct S;\n" + "impl S {\n" + " fn m(&self) {\n" + " callInMethod();\n" + " }\n" + "}\n" + "const X: i32 = { callAtModuleScope(); 1 };\n" + ) + fx = extract_file(_pf(content, lang="rust")) + by_target = {e.target: e for e in fx.edges} + + fn_edge = by_target["callInFn"] + assert fn_edge.enclosing is not None + assert fn_edge.enclosing.name == "top" + assert fn_edge.enclosing.kind == "function" + + method_edge = by_target["callInMethod"] + assert method_edge.enclosing is not None + assert method_edge.enclosing.name == "m" + assert method_edge.enclosing.kind == "function" + + assert by_target["callAtModuleScope"].enclosing is None diff --git a/tests/unit/test_job.py b/tests/unit/test_job.py index 28f326a..85c1b30 100644 --- a/tests/unit/test_job.py +++ b/tests/unit/test_job.py @@ -309,8 +309,9 @@ def __call__( self.calls.append(name) self.chunk_writer = chunk_writer files = len(materialized) - symbols = sum(len(syms) for _pf, syms in materialized) - counts = IndexCounts(files=files, symbols=symbols, swept=0) + symbols = sum(len(ex.symbols) for _pf, ex in materialized) + edges = sum(len(ex.edges) for _pf, ex in materialized) + counts = IndexCounts(files=files, symbols=symbols, swept=0, edges=edges) self.counts.append(counts) return counts @@ -428,7 +429,7 @@ def test_run_parses_files_and_symbols() -> None: assert code == 0 # main.py + README.md both stored; main.py yields one function symbol. assert idx.calls == ["acme/widgets"] - assert idx.counts == [IndexCounts(files=2, symbols=1, swept=0)] + assert idx.counts == [IndexCounts(files=2, symbols=1, swept=0, edges=0)] # --- import health (the circular-import regression guard) ------------------- @@ -666,7 +667,7 @@ def test_semantic_ceiling_exceeded_degrades_but_still_indexes_the_core() -> None assert idx.chunk_writer is None # ...with chunks skipped # Proves the core index got the real work, not an empty items generator: "not skipped" # and "correctly indexed" are different claims, and only the latter is the contract. - assert idx.counts[0] == IndexCounts(files=2, symbols=1, swept=0) + assert idx.counts[0] == IndexCounts(files=2, symbols=1, swept=0, edges=0) @pytest.mark.unit @@ -708,7 +709,7 @@ def test_semantic_cap_override_exceeded_still_degrades_to_core_index() -> None: assert code == 0 # a semantic-only breach never fails the repo assert idx.calls == ["acme/widgets"] assert idx.chunk_writer is None # the override (1), not the global cap (5), fired - assert idx.counts[0] == IndexCounts(files=2, symbols=1, swept=0) + assert idx.counts[0] == IndexCounts(files=2, symbols=1, swept=0, edges=0) @pytest.mark.unit @@ -1173,7 +1174,7 @@ def _index(conn: Any, *, name: str, branch: str, **_: Any) -> IndexCounts: seen.append(branch) if branch == "main": raise StaleIndexError(f"repo_branches row for {name}@{branch} changed") - return IndexCounts(files=1, symbols=0, swept=0) + return IndexCounts(files=1, symbols=0, swept=0, edges=0) with caplog.at_level(logging.INFO, logger="indexer.job"): code = _run(_config(repos=["acme/widgets"], branches=["feature"]), _index, github=github) @@ -1195,7 +1196,7 @@ def _index(conn: Any, *, name: str, branch: str, **_: Any) -> IndexCounts: seen.append(branch) if branch == "main": raise RuntimeError("boom") - return IndexCounts(files=1, symbols=0, swept=0) + return IndexCounts(files=1, symbols=0, swept=0, edges=0) code = _run(_config(repos=["acme/widgets"], branches=["feature"]), _index, github=github) @@ -1343,7 +1344,7 @@ def __call__(self, conn: Any, *, name: str, items: Any, **_: Any) -> IndexCounts list(items) self.barrier.wait() self.calls.append(name) - return IndexCounts(files=0, symbols=0, swept=0) + return IndexCounts(files=0, symbols=0, swept=0, edges=0) @pytest.mark.unit @@ -1424,7 +1425,7 @@ def _mixed(conn: Any, *, name: str, items: Any, **_: Any) -> IndexCounts: list(items) if name == "acme/conflicted": raise StaleIndexError(f"repos row for {name} changed mid-transaction") - return IndexCounts(files=1, symbols=0, swept=0) + return IndexCounts(files=1, symbols=0, swept=0, edges=0) engine = _FakeEngine( stamps={("acme/skipped", "main"): ("sha_skipped", INDEX_SEMANTICS_VERSION)} @@ -1462,7 +1463,7 @@ def _slow(conn: Any, *, name: str, items: Any, **_: Any) -> IndexCounts: list(items) time.sleep(0.05) finished.append(time.monotonic()) - return IndexCounts(files=0, symbols=0, swept=0) + return IndexCounts(files=0, symbols=0, swept=0, edges=0) engine = _FakeEngine() monkeypatch.setattr(job, "create_db_engine", lambda **_kw: engine) @@ -1535,7 +1536,7 @@ def test_records_from_other_modules_inherit_the_repo_context( def _logs_elsewhere(conn: Any, *, name: str, items: Any, **_: Any) -> IndexCounts: list(items) logging.getLogger("indexer.store").warning("a record from another module") - return IndexCounts(files=0, symbols=0, swept=0) + return IndexCounts(files=0, symbols=0, swept=0, edges=0) log_filter = job.RepoLogFilter() caplog.handler.addFilter(log_filter) @@ -1611,12 +1612,12 @@ def __init__(self) -> None: def __call__(self, conn: Any, *, name: str, items: Any, **_: Any) -> IndexCounts: collected: list[tuple[str, str, str, str, int, int]] = [] - for pf, syms in items: - for sym in syms: + for pf, ex in items: + for sym in ex.symbols: assert isinstance(sym, ExtractedSymbol) collected.append((name, pf.path, sym.name, sym.kind, sym.start_line, sym.end_line)) self.rows.extend(collected) - return IndexCounts(files=0, symbols=len(collected), swept=0) + return IndexCounts(files=0, symbols=len(collected), swept=0, edges=0) @property def sorted_rows(self) -> list[tuple[str, str, str, str, int, int]]: @@ -1833,7 +1834,7 @@ def _ok_outcome( discovery_complete=discovery_complete, outcomes=[ BranchOutcome( - branch=b, status="indexed", counts=IndexCounts(files=1, symbols=0, swept=0) + branch=b, status="indexed", counts=IndexCounts(files=1, symbols=0, swept=0, edges=0) ) for b in branches ], @@ -2023,7 +2024,7 @@ def test_reconciliation_runs_after_fanout_drains() -> None: def _index_fn(conn: Any, *, name: str, items: Any, **_: Any) -> IndexCounts: list(items) order.append(f"index:{name}") - return IndexCounts(files=1, symbols=0, swept=0) + return IndexCounts(files=1, symbols=0, swept=0, edges=0) class _OrderedReconcile(_RecordingReconcile): def removed_fn(self, conn: Any, *, desired_repos: Any) -> list[str]: diff --git a/tests/unit/test_job_redaction.py b/tests/unit/test_job_redaction.py index 7f78f57..8a2dfda 100644 --- a/tests/unit/test_job_redaction.py +++ b/tests/unit/test_job_redaction.py @@ -129,7 +129,7 @@ def _index_fn( chunk_writer: Any = None, ) -> IndexCounts: files = len(list(items)) - return IndexCounts(files=files, symbols=0, swept=0) + return IndexCounts(files=files, symbols=0, swept=0, edges=0) # No-op reconcile fns: a clean run in these tests always passes diff --git a/tests/unit/test_languages.py b/tests/unit/test_languages.py index b876f16..8476a01 100644 --- a/tests/unit/test_languages.py +++ b/tests/unit/test_languages.py @@ -3,9 +3,12 @@ from __future__ import annotations import pytest +from sqlalchemy import CheckConstraint from tree_sitter_language_pack import get_parser -from indexer.languages import EXT_TO_LANG, MAX_FILE_BYTES, SYMBOL_KINDS +from app.db.models import ReferenceEdge +from indexer.languages import EDGE_NODE_KINDS, EXT_TO_LANG, MAX_FILE_BYTES, SYMBOL_KINDS +from indexer.symbols import _EDGE_EXTRACTORS @pytest.mark.unit @@ -15,6 +18,67 @@ def test_symbol_kind_languages_have_no_orphans() -> None: assert set(SYMBOL_KINDS) <= ext_langs, "SYMBOL_KINDS has a language with no extension mapping" +@pytest.mark.unit +def test_edge_node_kind_languages_have_no_orphans() -> None: + """Every language in EDGE_NODE_KINDS must be a value in EXT_TO_LANG.""" + ext_langs = set(EXT_TO_LANG.values()) + assert set(EDGE_NODE_KINDS) <= ext_langs, ( + "EDGE_NODE_KINDS has a language with no extension mapping" + ) + + +@pytest.mark.unit +def test_edge_node_kinds_are_within_the_db_check_set() -> None: + """Every EDGE_NODE_KINDS value must satisfy reference_edges' edge_kind CHECK constraint. + + Cross-checked against the constraint's actual SQL text -- the + languages-map<->schema tripwire -- rather than a hardcoded duplicate set, so + a schema change that narrows/renames the allowed kinds is caught here too. + """ + check = next( + c + for c in ReferenceEdge.__table__.constraints + if isinstance(c, CheckConstraint) and c.name == "ck_reference_edges_edge_kind" + ) + sql = str(check.sqltext) + allowed = {kind.strip().strip("'") for kind in sql.split("IN")[1].strip(" ()").split(",")} + + mapped_kinds = {kind for kinds in EDGE_NODE_KINDS.values() for kind in kinds.values()} + assert mapped_kinds <= allowed, f"EDGE_NODE_KINDS has a kind outside the DB CHECK set: {sql!r}" + + +@pytest.mark.unit +def test_every_symbol_language_has_an_edge_map() -> None: + """Coverage guard (issue #85): any language that extracts symbols must also + declare an edge node-map, so a newly-added language can't silently ship + symbols with no reference edges.""" + missing = set(SYMBOL_KINDS) - set(EDGE_NODE_KINDS) + assert not missing, ( + f"languages in SYMBOL_KINDS with no EDGE_NODE_KINDS entry: {sorted(missing)}" + ) + + +@pytest.mark.unit +def test_symbol_and_edge_node_types_are_disjoint_per_language() -> None: + """_combined_kinds merges the two maps with dict.update, which silently + clobbers a symbol entry on collision; the merge's losslessness is enforced + here, not assumed.""" + for lang, kinds in SYMBOL_KINDS.items(): + overlap = set(kinds) & set(EDGE_NODE_KINDS.get(lang, {})) + assert not overlap, ( + f"{lang}: node types in both SYMBOL_KINDS and EDGE_NODE_KINDS: {sorted(overlap)}" + ) + + +@pytest.mark.unit +def test_every_symbol_language_has_an_edge_extractor() -> None: + """extract_file indexes _EDGE_EXTRACTORS[lang] unconditionally for any + parsed language; a missing entry is a runtime KeyError for every file of + that language, so this guard is mandatory.""" + missing = set(SYMBOL_KINDS) - set(_EDGE_EXTRACTORS) + assert not missing, f"languages with symbols but no edge extractor: {sorted(missing)}" + + @pytest.mark.unit @pytest.mark.parametrize("lang", sorted(set(EXT_TO_LANG.values()))) def test_get_parser_succeeds_for_each_language(lang: str) -> None: diff --git a/tests/unit/test_main.py b/tests/unit/test_main.py index f6906a7..fa0c1c9 100644 --- a/tests/unit/test_main.py +++ b/tests/unit/test_main.py @@ -1025,6 +1025,100 @@ def _fake_payload( assert captured["branch"] == "feature/x" +# ------------------------------------------------------ reference tools: wiring / clamp + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_find_references_tool_threads_symbol_branch_and_clamps_limit( + monkeypatch: pytest.MonkeyPatch, +) -> None: + captured: dict[str, Any] = {} + + def _fake_payload( + engine: Any, cfg: Settings, name: str, limit: int, branch: str | None = None + ) -> dict[str, Any]: + captured["name"] = name + captured["limit"] = limit + captured["branch"] = branch + return {"kind": "references"} + + monkeypatch.setattr(main, "_find_references_payload", _fake_payload) + ctx = _FakeLifespanContext(_FakeEngine([]), _cfg()) + + # 0 clamps to the default row_limit (200); the symbol/branch thread straight through. + await main.find_references("Handler", ctx, limit=0, branch="feature/x") # type: ignore[arg-type] + + assert captured["name"] == "Handler" + assert captured["branch"] == "feature/x" + assert captured["limit"] == 200 + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_list_imports_tool_threads_all_params_imports_direction( + monkeypatch: pytest.MonkeyPatch, +) -> None: + captured: dict[str, Any] = {} + + def _fake_payload( + engine: Any, + cfg: Settings, + repo: str | None = None, + limit: int = 0, + branch: str | None = None, + *, + target: str | None = None, + direction: str = "imports", + ) -> dict[str, Any]: + captured.update(repo=repo, limit=limit, branch=branch, target=target, direction=direction) + return {"kind": "imports"} + + monkeypatch.setattr(main, "_list_imports_payload", _fake_payload) + ctx = _FakeLifespanContext(_FakeEngine([]), _cfg()) + + # 10_000 clamps to max_row_limit (1000). + await main.list_imports( # type: ignore[arg-type] + ctx, repo="acme/widgets", target="os.path", branch="feature/x", limit=10_000 + ) + + assert captured["repo"] == "acme/widgets" + assert captured["target"] == "os.path" + assert captured["direction"] == "imports" + assert captured["branch"] == "feature/x" + assert captured["limit"] == 1000 + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_list_imports_tool_threads_imported_by_direction( + monkeypatch: pytest.MonkeyPatch, +) -> None: + captured: dict[str, Any] = {} + + def _fake_payload( + engine: Any, + cfg: Settings, + repo: str | None = None, + limit: int = 0, + branch: str | None = None, + *, + target: str | None = None, + direction: str = "imports", + ) -> dict[str, Any]: + captured.update(repo=repo, target=target, direction=direction) + return {"kind": "imports"} + + monkeypatch.setattr(main, "_list_imports_payload", _fake_payload) + ctx = _FakeLifespanContext(_FakeEngine([]), _cfg()) + + await main.list_imports(ctx, target="os.path", direction="imported_by") # type: ignore[arg-type] + + assert captured["direction"] == "imported_by" + assert captured["target"] == "os.path" + assert captured["repo"] is None + + # ------------------------------------------------- search_code: divergent content_sha merge @@ -1141,3 +1235,30 @@ def test_signals_log_includes_both_flags() -> None: signals = main._signals({"no_content_atom": True, "zero_width_only_atoms": False}) assert signals["no_content_atom"] is True assert signals["zero_width_only_atoms"] is False + + +@pytest.mark.observability +def test_signals_log_includes_reference_tool_keys() -> None: + # A list_imports validation miss / repo typo must be diagnosable from the log line alone -- + # otherwise it reads identically to a genuine empty result. + signals = main._signals( + { + "repo_known": False, + "unsupported_direction": "sideways", + "missing_repo": True, + "missing_target": None, + } + ) + assert signals["repo_known"] is False + assert signals["unsupported_direction"] == "sideways" + assert signals["missing_repo"] is True + assert signals["missing_target"] is None + + +@pytest.mark.observability +def test_signals_are_none_safe_on_payloads_without_reference_keys() -> None: + # The four additive keys are None-safe .get() extractions: a search_code payload that never + # carries them still produces the keys (as None), never a KeyError. + signals = main._signals({"truncated": False}) + for key in ("repo_known", "unsupported_direction", "missing_repo", "missing_target"): + assert signals[key] is None diff --git a/tests/unit/test_measure_reference_resolution.py b/tests/unit/test_measure_reference_resolution.py new file mode 100644 index 0000000..0a62e64 --- /dev/null +++ b/tests/unit/test_measure_reference_resolution.py @@ -0,0 +1,78 @@ +"""Unit tests for the offline resolution-distribution measurement script (AC4). + +Pure-Python bucketing/formatting helpers only -- no DB. ``build_candidate_count_select``'s +rendered SQL shape (branch-scoped, edge_kind-filtered, correlated-subquery join) is covered by +``tests/unit/test_references.py``; this file only proves the script's own helpers stay +faithful to the shared ``classify_resolution`` the resolver uses (no independent re-derivation +that could drift). +""" + +from __future__ import annotations + +import pytest + +from scripts.measure_reference_resolution import ( + CALL_BASELINE_PCT, + ambiguous_histogram, + bucket_counts, + format_distribution, + format_histogram, +) + + +@pytest.mark.unit +def test_bucket_counts_empty() -> None: + assert bucket_counts([]) == {"unique": 0, "ambiguous": 0, "unresolved": 0} + + +@pytest.mark.unit +def test_bucket_counts_classifies_via_shared_helper() -> None: + # 0 -> unresolved, 1 -> unique, >=2 -> ambiguous (mirrors classify_resolution exactly). + assert bucket_counts([0, 0, 1, 2, 31]) == {"unique": 1, "ambiguous": 2, "unresolved": 2} + + +@pytest.mark.unit +def test_ambiguous_histogram_ignores_unique_and_unresolved() -> None: + assert ambiguous_histogram([0, 1, 2, 2, 3]) == {2: 2, 3: 1} + + +@pytest.mark.unit +def test_ambiguous_histogram_empty_when_no_ambiguous_sites() -> None: + assert ambiguous_histogram([0, 0, 1, 1]) == {} + + +@pytest.mark.unit +def test_format_distribution_includes_counts_and_percentages() -> None: + text = format_distribution("call edges", {"unique": 1, "ambiguous": 1, "unresolved": 2}) + assert "call edges (n=4):" in text + assert "unique" in text + assert "50.0%" in text # unresolved: 2/4 + + +@pytest.mark.unit +def test_format_distribution_zero_total_does_not_divide_by_zero() -> None: + text = format_distribution("empty", {"unique": 0, "ambiguous": 0, "unresolved": 0}) + assert "(n=0):" in text + assert "0.0%" in text + + +@pytest.mark.unit +def test_format_distribution_with_baseline_renders_comparison() -> None: + text = format_distribution( + "call edges", {"unique": 1, "ambiguous": 1, "unresolved": 0}, baseline=CALL_BASELINE_PCT + ) + assert "baseline 28.8%" in text + assert "baseline 33.4%" in text + assert "baseline 37.8%" in text + + +@pytest.mark.unit +def test_format_histogram_empty() -> None: + assert "no ambiguous" in format_histogram({}) + + +@pytest.mark.unit +def test_format_histogram_sorted_by_candidate_count() -> None: + text = format_histogram({3: 1, 2: 5}) + # candidate_count=2 must render before candidate_count=3 (sorted, not insertion order). + assert text.index("candidate_count=2") < text.index("candidate_count=3") diff --git a/tests/unit/test_migration_source.py b/tests/unit/test_migration_source.py index 751cd7b..cf9fd82 100644 --- a/tests/unit/test_migration_source.py +++ b/tests/unit/test_migration_source.py @@ -13,12 +13,13 @@ from __future__ import annotations +import re from pathlib import Path import pytest _VERSIONS_DIR = Path(__file__).resolve().parents[2] / "app" / "alembic" / "versions" -_EXPECTED_REVISIONS = {"0001", "0002", "0003", "0004"} +_EXPECTED_REVISIONS = {"0001", "0002", "0003", "0004", "0005"} @pytest.fixture @@ -48,6 +49,11 @@ def source_0003(sources: dict[str, str]) -> str: return sources["0003"] +@pytest.fixture +def source_0005(sources: dict[str, str]) -> str: + return sources["0005"] + + @pytest.mark.unit def test_revision_identifiers(source: str) -> None: assert 'revision: str = "0001"' in source @@ -145,3 +151,77 @@ def test_0003_does_not_import_app_constant(source_0003: str) -> None: assert not line.startswith(("import app", "from app")), ( f"0003 must not import from the app package: {line!r}" ) + + +@pytest.mark.unit +def test_0005_revision_identifiers(source_0005: str) -> None: + assert 'revision: str = "0005"' in source_0005 + assert 'down_revision: str | None = "0004"' in source_0005 + + +@pytest.mark.unit +def test_0005_does_not_import_app_constant(source_0005: str) -> None: + """A migration is a historical fact; it must not depend on a mutable app constant.""" + code_lines = [ + line + for line in source_0005.splitlines() + if line.strip() and not line.lstrip().startswith("#") + ] + for line in code_lines: + if line.startswith(("import ", "from ")): + assert not line.startswith(("import app", "from app")), ( + f"0005 must not import from the app package: {line!r}" + ) + + +@pytest.mark.unit +def test_0005_downgrade_drops_indexes_then_table(source_0005: str) -> None: + downgrade_pos = source_0005.find("def downgrade()") + assert downgrade_pos != -1 + downgrade_body = source_0005[downgrade_pos:] + for index_name in ( + "ix_reference_edges_target_name", + "ix_reference_edges_target_trgm", + "ix_reference_edges_file_id", + "ix_reference_edges_repo_kind", + ): + assert f'op.drop_index("{index_name}"' in downgrade_body, ( + f"0005 downgrade must drop {index_name!r}" + ) + table_pos = downgrade_body.find('op.drop_table("reference_edges")') + assert table_pos != -1, "0005 downgrade must drop the reference_edges table" + last_index_pos = max( + downgrade_body.find(f'op.drop_index("{index_name}"') + for index_name in ( + "ix_reference_edges_target_name", + "ix_reference_edges_target_trgm", + "ix_reference_edges_file_id", + "ix_reference_edges_repo_kind", + ) + ) + assert last_index_pos < table_pos, "all indexes must drop before the table" + + +@pytest.mark.unit +def test_0005_no_symbol_fk(source_0005: str) -> None: + """Epic #82 rule: reference_edges must never gain a symbols FK at the source level. + + Quote-agnostic (matches ``symbols.id`` under either quoting style) so this + tripwire survives formatter drift, unlike a literal-substring check. + """ + assert re.search(r"symbols\.id", source_0005) is None, ( + "0005 migration must not reference a symbols.id FK target" + ) + assert "REFERENCES symbols" not in source_0005, ( + "0005 migration must not reference symbols via raw SQL REFERENCES" + ) + + +@pytest.mark.unit +def test_0005_does_not_create_extension(source_0005: str) -> None: + """pg_trgm already exists since 0001 (database-wide); 0005 must not re-create it.""" + upgrade_pos = source_0005.find("def upgrade()") + downgrade_pos = source_0005.find("def downgrade()") + assert upgrade_pos != -1 and downgrade_pos != -1 + body = source_0005[upgrade_pos:downgrade_pos] + assert "CREATE EXTENSION" not in body diff --git a/tests/unit/test_reference_edge_model.py b/tests/unit/test_reference_edge_model.py new file mode 100644 index 0000000..d77b4ba --- /dev/null +++ b/tests/unit/test_reference_edge_model.py @@ -0,0 +1,89 @@ +"""Model-level tripwires for ``reference_edges`` (no database required). + +Introspects ``Base.metadata`` directly, mirroring ``test_migration_source.py``'s +source-level checks but at the ORM-metadata layer: a future edit to +``app/db/models.py`` that accidentally introduces a ``symbols`` FK, drops the +CHECK constraint, or loosens a NOT NULL column fails here, not in production. +""" + +from __future__ import annotations + +import pytest +from sqlalchemy import BigInteger, CheckConstraint, ForeignKeyConstraint + +from app.db.models import Base, File + +_TABLE = Base.metadata.tables["reference_edges"] + + +@pytest.mark.unit +def test_reference_edges_fk_targets_only_repos_and_files() -> None: + fk_targets = {fk.target_fullname for fk in _TABLE.foreign_keys} + assert fk_targets == {"repos.id", "files.id"}, ( + "reference_edges must never gain a symbols FK (epic #82 rule): " + f"found FK targets {fk_targets}" + ) + + +@pytest.mark.unit +def test_reference_edges_fks_cascade_on_delete() -> None: + for constraint in _TABLE.constraints: + if isinstance(constraint, ForeignKeyConstraint): + for element in constraint.elements: + assert element.ondelete == "CASCADE", ( + f"FK to {element.target_fullname!r} must be ON DELETE CASCADE" + ) + + +@pytest.mark.unit +def test_reference_edges_id_is_bigint() -> None: + assert isinstance(_TABLE.c.id.type, BigInteger) + + +@pytest.mark.unit +def test_reference_edges_not_null_columns() -> None: + required = {"repo_id", "file_id", "edge_kind", "target_name", "line"} + nullable = {"enclosing_name", "enclosing_kind", "enclosing_start_line", "enclosing_end_line"} + for name in required: + assert not _TABLE.c[name].nullable, f"{name} must be NOT NULL" + for name in nullable: + assert _TABLE.c[name].nullable, f"{name} must be nullable (module/top-level scope)" + + +@pytest.mark.unit +def test_reference_edges_edge_kind_check_constraint() -> None: + checks = [c for c in _TABLE.constraints if isinstance(c, CheckConstraint)] + assert len(checks) == 1 + check = checks[0] + assert check.name == "ck_reference_edges_edge_kind" + assert "call" in str(check.sqltext) and "import" in str(check.sqltext) + + +@pytest.mark.unit +def test_reference_edges_indexes() -> None: + indexes = {ix.name: ix for ix in _TABLE.indexes} + expected_names = { + "ix_reference_edges_target_name", + "ix_reference_edges_target_trgm", + "ix_reference_edges_file_id", + "ix_reference_edges_repo_kind", + } + assert set(indexes) == expected_names + + assert [c.name for c in indexes["ix_reference_edges_target_name"].columns] == ["target_name"] + assert [c.name for c in indexes["ix_reference_edges_file_id"].columns] == ["file_id"] + assert [c.name for c in indexes["ix_reference_edges_repo_kind"].columns] == [ + "repo_id", + "edge_kind", + ] + + trgm = indexes["ix_reference_edges_target_trgm"] + assert [c.name for c in trgm.columns] == ["target_name"] + assert trgm.dialect_options["postgresql"]["using"] == "gin" + assert trgm.dialect_options["postgresql"]["ops"] == {"target_name": "gin_trgm_ops"} + + +@pytest.mark.unit +def test_file_reference_edges_relationship_cascades() -> None: + rel = File.reference_edges + assert rel.property.cascade.delete_orphan diff --git a/tests/unit/test_references.py b/tests/unit/test_references.py new file mode 100644 index 0000000..bf0182e --- /dev/null +++ b/tests/unit/test_references.py @@ -0,0 +1,340 @@ +"""Unit tests for the reference resolver: pure helpers + rendered SQL. + +No DB: SQL shapes are asserted via ``stmt.compile(dialect=postgresql.dialect())`` (mirrors +``test_symbols_search.py``'s style), and the row -> dataclass assembly (``_build_edge_site``) +is exercised with fake row objects so the candidate-cap/ambiguity-preservation invariant is +covered without a live Postgres. The full two-query ``resolve_references`` end-to-end (branch +scoping, timeout, real window-function bounding) is exercised in the CI-only integration suite. +""" + +from __future__ import annotations + +from typing import Any + +import pytest +from sqlalchemy.dialects import postgresql + +from app.search.references import ( + CALL_TARGET_KINDS, + CandidateSymbol, + _build_candidates_select, + _build_edge_site, + _build_sites_select, + _rank_candidates, + build_candidate_count_select, + classify_resolution, +) + + +class _Row: + def __init__(self, **kw: Any) -> None: + self.__dict__.update(kw) + + +def _sql(stmt: Any) -> str: + return str(stmt.compile(dialect=postgresql.dialect())) + + +# --------------------------------------------------------------------- classify_resolution + + +@pytest.mark.unit +@pytest.mark.parametrize( + ("count", "expected"), + [(0, "unresolved"), (1, "unique"), (2, "ambiguous"), (31, "ambiguous")], +) +def test_classify_resolution(count: int, expected: str) -> None: + assert classify_resolution(count) == expected + + +# ------------------------------------------------------------------------- _rank_candidates + + +def _candidate( + *, + symbol_id: int, + repo_id: int = 1, + path: str = "a.py", + name: str = "f", + kind: str | None = "function", + start_line: int | None = 1, + same_repo: bool = True, + same_file: bool = True, + kind_match: bool = True, +) -> CandidateSymbol: + return CandidateSymbol( + symbol_id=symbol_id, + repo_id=repo_id, + path=path, + name=name, + kind=kind, + start_line=start_line, + same_repo=same_repo, + same_file=same_file, + kind_match=kind_match, + ) + + +@pytest.mark.unit +def test_rank_same_repo_before_cross_repo() -> None: + cross = _candidate(symbol_id=1, same_repo=False, repo_id=2) + same = _candidate(symbol_id=2, same_repo=True, repo_id=1) + ranked = _rank_candidates([cross, same]) + assert ranked == (same, cross) + + +@pytest.mark.unit +def test_rank_kind_match_before_same_file() -> None: + # Both same-repo; one is same-file but kind-mismatched, the other is kind-matched but a + # different file -- kind_match outranks same_file per the pinned D4 signal order. + same_file_wrong_kind = _candidate(symbol_id=1, same_file=True, kind_match=False) + other_file_right_kind = _candidate(symbol_id=2, same_file=False, kind_match=True) + ranked = _rank_candidates([same_file_wrong_kind, other_file_right_kind]) + assert ranked == (other_file_right_kind, same_file_wrong_kind) + + +@pytest.mark.unit +def test_rank_tiebreak_ends_in_symbol_id() -> None: + # Identical repo/path/start_line -- symbol_id is the only thing left to break the tie, so + # the order must be deterministic across repeated calls. + a = _candidate(symbol_id=5, path="same.py", start_line=10) + b = _candidate(symbol_id=2, path="same.py", start_line=10) + assert _rank_candidates([a, b]) == (b, a) + assert _rank_candidates([b, a]) == (b, a) + + +@pytest.mark.unit +def test_rank_unknown_kind_still_present_not_dropped() -> None: + # Membership-preserving: an unmatched kind earns no boost but is never removed. + unknown_kind = _candidate(symbol_id=1, kind="unknown_future_kind", kind_match=False) + ranked = _rank_candidates([unknown_kind]) + assert ranked == (unknown_kind,) + assert unknown_kind.kind not in CALL_TARGET_KINDS + + +# ------------------------------------------------------------------------ query 1 SQL shape + + +@pytest.mark.unit +def test_sites_select_orders_through_file_repo_id_ends_in_edge_id() -> None: + sql = _sql( + _build_sites_select( + target_name=None, edge_kind=None, repo_id=None, branch=None, row_limit=200 + ) + ) + order = sql.split("ORDER BY", 1)[1].split("LIMIT", 1)[0] + assert "files.repo_id" in order + assert "files.path" in order + assert "reference_edges.line" in order + assert order.strip().endswith("reference_edges.id") + assert "content_sha" not in sql + + +@pytest.mark.unit +def test_sites_select_default_branch_predicate_byte_identical_to_get_file_payload() -> None: + sql = _sql( + _build_sites_select( + target_name=None, edge_kind=None, repo_id=None, branch=None, row_limit=200 + ) + ) + assert "coalesce(repos.default_branch, %(coalesce_1)s) = ANY (files.branches)" in sql + + +@pytest.mark.unit +def test_sites_select_explicit_branch_predicate_uses_array_contains() -> None: + sql = _sql( + _build_sites_select( + target_name=None, edge_kind=None, repo_id=None, branch="feature", row_limit=200 + ) + ) + assert "files.branches @>" in sql + + +@pytest.mark.unit +def test_sites_select_filters_compose() -> None: + sql = _sql( + _build_sites_select( + target_name="Handler", edge_kind="call", repo_id=None, branch=None, row_limit=200 + ) + ) + assert "reference_edges.target_name = " in sql + assert "reference_edges.edge_kind = " in sql + + +@pytest.mark.unit +def test_sites_select_repo_id_filter_renders_edge_repo_id_not_repo_name() -> None: + sql = _sql( + _build_sites_select(target_name=None, edge_kind=None, repo_id=7, branch=None, row_limit=200) + ) + assert "reference_edges.repo_id = " in sql + assert "repos.name = " not in sql + + +@pytest.mark.unit +def test_sites_select_applies_limit() -> None: + sql = _sql( + _build_sites_select( + target_name=None, edge_kind=None, repo_id=None, branch=None, row_limit=50 + ) + ) + assert "LIMIT" in sql + + +# ------------------------------------------------------------------------ query 2 SQL shape + + +@pytest.mark.unit +def test_candidates_select_row_number_partitioned_by_name_bounded_by_cap() -> None: + sql = _sql(_build_candidates_select(names=["f"], branch=None, candidate_cap=32)) + assert "row_number() OVER (PARTITION BY symbols.name ORDER BY " in sql + assert "files.repo_id, files.path, symbols.start_line, symbols.id)" in sql + assert "rn <=" in sql or "rn <= " in sql + + +@pytest.mark.unit +def test_candidates_select_count_over_partitioned_by_name() -> None: + sql = _sql(_build_candidates_select(names=["f"], branch=None, candidate_cap=32)) + assert "count(*) OVER (PARTITION BY symbols.name)" in sql + + +@pytest.mark.unit +def test_candidates_select_exact_name_in_no_last_segment_split() -> None: + stmt = _build_candidates_select(names=["a.b.c", "f"], branch=None, candidate_cap=32) + # literal_binds so the bound names are visible in the rendered text: the full dotted + # "a.b.c" must appear verbatim -- no split into "c" (the last segment) anywhere. + sql = str(stmt.compile(dialect=postgresql.dialect(), compile_kwargs={"literal_binds": True})) + assert "symbols.name IN" in sql + assert "'a.b.c'" in sql + assert "'c'" not in sql + + +@pytest.mark.unit +def test_candidates_select_applies_branch_predicate() -> None: + sql = _sql(_build_candidates_select(names=["f"], branch="feature", candidate_cap=32)) + assert "files.branches @>" in sql + + +# --------------------------------------------------------------------- build_candidate_count_select + + +@pytest.mark.unit +def test_candidate_count_select_renders_correlated_subquery_and_branch_scope() -> None: + sql = _sql(build_candidate_count_select(edge_kind="call", branch=None)) + assert "reference_edges.edge_kind = " in sql + # Two DISTINCT joins to `files` in one statement (outer sites leg + inner correlated + # symbols leg) -> the inner leg must be aliased, never the same unaliased `files`. + assert sql.count("JOIN files AS files_1") == 1 + assert sql.count("JOIN files ON") == 1 + assert "coalesce(" in sql.lower() + + +@pytest.mark.unit +def test_candidate_count_select_explicit_branch() -> None: + sql = _sql(build_candidate_count_select(edge_kind="import", branch="feature")) + assert "@>" in sql + assert "reference_edges.edge_kind = " in sql + + +# ------------------------------------------------------------------- _build_edge_site (D2/D5) + + +@pytest.mark.unit +def test_build_edge_site_candidate_cap_preserves_true_count_and_ambiguous_resolution() -> None: + site_row = _Row( + id=1, + repo_id=1, + file_id=10, + path="a.py", + line=5, + edge_kind="call", + target_name="get", + enclosing_name=None, + enclosing_kind=None, + ) + # Fetched/returned rows are already SQL-bounded to candidate_cap (here: 2), but the + # `candidate_count` column carries the TRUE pre-cap total (here: 40) on every row. + candidate_rows = [ + _Row( + symbol_id=i, + name="get", + kind="function", + start_line=i, + repo_id=1, + file_id=10 + i, + path=f"c{i}.py", + candidate_count=40, + ) + for i in range(2) + ] + site = _build_edge_site(site_row, candidate_rows) # type: ignore[arg-type] + assert site.candidate_count == 40 + assert len(site.candidates) == 2 + assert site.candidates_truncated is True + assert site.resolution == "ambiguous" + + +@pytest.mark.unit +def test_build_edge_site_no_candidates_is_unresolved() -> None: + site_row = _Row( + id=1, + repo_id=1, + file_id=10, + path="a.py", + line=5, + edge_kind="import", + target_name="os.path", + enclosing_name=None, + enclosing_kind=None, + ) + site = _build_edge_site(site_row, []) # type: ignore[arg-type] + assert site.resolution == "unresolved" + assert site.candidate_count == 0 + assert site.candidates == () + assert site.candidates_truncated is False + + +@pytest.mark.unit +def test_build_edge_site_import_kind_match_always_false() -> None: + site_row = _Row( + id=1, + repo_id=1, + file_id=10, + path="a.py", + line=5, + edge_kind="import", + target_name="f", + enclosing_name=None, + enclosing_kind=None, + ) + candidate_rows = [ + _Row( + symbol_id=1, + name="f", + kind="function", + start_line=1, + repo_id=1, + file_id=10, + path="a.py", + candidate_count=1, + ) + ] + site = _build_edge_site(site_row, candidate_rows) # type: ignore[arg-type] + assert site.candidates[0].kind_match is False + + +@pytest.mark.unit +def test_build_edge_site_enclosing_symbol_none_when_module_scope() -> None: + site_row = _Row( + id=1, + repo_id=1, + file_id=10, + path="a.py", + line=5, + edge_kind="call", + target_name="f", + enclosing_name=None, + enclosing_kind=None, + ) + site = _build_edge_site(site_row, []) # type: ignore[arg-type] + assert site.enclosing_name is None + assert site.enclosing_kind is None diff --git a/tests/unit/test_service.py b/tests/unit/test_service.py index 99d45ab..7f6b27d 100644 --- a/tests/unit/test_service.py +++ b/tests/unit/test_service.py @@ -18,6 +18,7 @@ from app.query.parser import parse from app.search.errors import QueryTooBroadError from app.search.grep import FileCursor, FileMatches, GrepResult, LineMatch +from app.search.references import CandidateSymbol, EdgeSite, ReferenceResult from app.search.symbols import SymbolMatch, SymbolResult # --------------------------------------------------------------------------- fixtures @@ -490,3 +491,348 @@ def test_query_has_symbol_atom_excludes_negated_symbol() -> None: assert service._query_has_symbol_atom(parse("-sym:foo")) is False # A positive sym: alongside a negated one still counts. assert service._query_has_symbol_atom(parse("-sym:foo sym:bar")) is True + + +# ---------------------------------------- find_references_payload / list_imports_payload + + +def _candidate( + *, + symbol_id: int = 1, + repo_id: int = 7, + path: str = "src/handler.go", + name: str = "Handler", + kind: str | None = "function", + start_line: int | None = 3, + same_repo: bool = True, + same_file: bool = False, + kind_match: bool = True, +) -> CandidateSymbol: + return CandidateSymbol( + symbol_id=symbol_id, + repo_id=repo_id, + path=path, + name=name, + kind=kind, + start_line=start_line, + same_repo=same_repo, + same_file=same_file, + kind_match=kind_match, + ) + + +def _site( + *, + repo_id: int = 7, + file_id: int = 1, + path: str = "src/caller.go", + line: int = 10, + edge_kind: str = "call", + target_name: str = "Handler", + enclosing_name: str | None = None, + enclosing_kind: str | None = None, + resolution: str = "unique", + candidate_count: int = 1, + candidates_truncated: bool = False, + candidates: tuple[CandidateSymbol, ...] = (), +) -> EdgeSite: + return EdgeSite( + repo_id=repo_id, + file_id=file_id, + path=path, + line=line, + edge_kind=edge_kind, + target_name=target_name, + enclosing_name=enclosing_name, + enclosing_kind=enclosing_kind, + resolution=resolution, + candidate_count=candidate_count, + candidates_truncated=candidates_truncated, + candidates=candidates, + ) + + +def _result( + *, + sites: tuple[EdgeSite, ...] = (), + truncated: bool = False, + truncation_reason: str | None = None, + repo_known: bool = True, +) -> ReferenceResult: + return ReferenceResult( + sites=sites, truncated=truncated, truncation_reason=truncation_reason, repo_known=repo_known + ) + + +@pytest.mark.unit +def test_find_references_payload_key_set_and_nested_shapes(monkeypatch: pytest.MonkeyPatch) -> None: + site = _site( + resolution="ambiguous", + candidate_count=2, + candidates=(_candidate(symbol_id=1), _candidate(symbol_id=2, same_repo=False, repo_id=8)), + ) + monkeypatch.setattr(service, "resolve_references", lambda *a, **k: _result(sites=(site,))) + monkeypatch.setattr( + service, "_repo_name_map", lambda conn: {7: "acme/widgets", 8: "beta/tools"} + ) + + payload = service.find_references_payload(_FakeEngine([]), _cfg(), "Handler", 200) + + assert payload["query"] == "Handler" + assert payload["kind"] == "references" + assert payload["symbol"] == "Handler" + assert payload["branch"] is None + assert payload["query_too_broad"] is False + assert payload["site_count"] == 1 + assert payload["resolution_summary"] == {"unique": 0, "ambiguous": 1, "unresolved": 0} + assert "repo_known" not in payload # only list_imports_payload carries this key + + [site_payload] = payload["sites"] + assert site_payload["repo"] == "acme/widgets" + assert site_payload["file"] == "src/caller.go" + assert site_payload["edge_kind"] == "call" + assert site_payload["enclosing_symbol"] is None + # AC1: ambiguity is never collapsed -- both ranked candidates survive to the wire. + assert len(site_payload["candidates"]) == 2 + candidate_payload = site_payload["candidates"][0] + assert "symbol_id" not in candidate_payload + assert candidate_payload["repo"] == "acme/widgets" + assert candidate_payload["same_repo"] is True + assert candidate_payload["kind_match"] is True + + +@pytest.mark.unit +def test_find_references_payload_empty_result_shape(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(service, "resolve_references", lambda *a, **k: _result()) + monkeypatch.setattr(service, "_repo_name_map", lambda conn: {}) + + payload = service.find_references_payload(_FakeEngine([]), _cfg(), "Missing", 200) + + assert payload["sites"] == [] + assert payload["site_count"] == 0 + assert payload["resolution_summary"] == {"unique": 0, "ambiguous": 0, "unresolved": 0} + assert payload["truncated"] is False + assert payload["query_too_broad"] is False + + +@pytest.mark.unit +def test_find_references_payload_query_too_broad(monkeypatch: pytest.MonkeyPatch) -> None: + def _raise(*_a: object, **_k: object) -> ReferenceResult: + raise QueryTooBroadError("too broad") + + monkeypatch.setattr(service, "resolve_references", _raise) + + payload = service.find_references_payload(_FakeEngine([]), _cfg(), "Handler", 200) + + assert payload["query_too_broad"] is True + assert payload["truncated"] is True + assert payload["sites"] == [] + assert payload["site_count"] == 0 + + +@pytest.mark.unit +def test_find_references_payload_branch_echo(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(service, "resolve_references", lambda *a, **k: _result()) + monkeypatch.setattr(service, "_repo_name_map", lambda conn: {}) + + payload = service.find_references_payload( + _FakeEngine([]), _cfg(), "Handler", 200, branch="feature/x" + ) + + assert payload["branch"] == "feature/x" + + +@pytest.mark.unit +def test_list_imports_payload_key_set_and_repo_scope(monkeypatch: pytest.MonkeyPatch) -> None: + site = _site( + edge_kind="import", target_name="a.b.c", resolution="unresolved", candidate_count=0 + ) + monkeypatch.setattr(service, "resolve_references", lambda *a, **k: _result(sites=(site,))) + monkeypatch.setattr(service, "_repo_name_map", lambda conn: {7: "acme/widgets"}) + + payload = service.list_imports_payload(_FakeEngine([]), _cfg(), "acme/widgets", 200) + + assert payload["kind"] == "imports" + assert payload["repo"] == "acme/widgets" + assert payload["repo_known"] is True + # Additive uniform keys carried by BOTH directions (hardening, not repair -- the pre-existing + # individual-key assertions above still pass unchanged under these additions). + assert payload["direction"] == "imports" + assert payload["target"] is None + assert payload["query"] == "acme/widgets" # query echoes repo for the imports direction + assert payload["resolution_summary"] == {"unique": 0, "ambiguous": 0, "unresolved": 1} + [site_payload] = payload["sites"] + assert site_payload["edge_kind"] == "import" + assert site_payload["target_name"] == "a.b.c" + + +@pytest.mark.unit +def test_list_imports_payload_unknown_repo_is_structured_miss( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(service, "resolve_references", lambda *a, **k: _result(repo_known=False)) + # No repos to resolve -- `_repo_name_map` must not even be reached, but stub it defensively. + monkeypatch.setattr(service, "_repo_name_map", lambda conn: {}) + + payload = service.list_imports_payload(_FakeEngine([]), _cfg(), "ghost/repo", 200) + + assert payload["repo_known"] is False + assert payload["direction"] == "imports" + assert payload["sites"] == [] + assert payload["resolution_summary"] == {"unique": 0, "ambiguous": 0, "unresolved": 0} + + +@pytest.mark.unit +def test_list_imports_payload_query_too_broad(monkeypatch: pytest.MonkeyPatch) -> None: + def _raise(*_a: object, **_k: object) -> ReferenceResult: + raise QueryTooBroadError("too broad") + + monkeypatch.setattr(service, "resolve_references", _raise) + + payload = service.list_imports_payload(_FakeEngine([]), _cfg(), "acme/widgets", 200) + + assert payload["query_too_broad"] is True + assert payload["truncated"] is True + assert payload["repo_known"] is True # unknown vs. timeout are distinct outcomes + assert payload["direction"] == "imports" + assert payload["target"] is None + assert payload["sites"] == [] + + +class _FailingEngine: + """An engine that raises the instant a builder tries to open a connection. + + Proves the PRE-DB validation branches return WITHOUT any DB round trip (mirrors the + grep-never-runs fake in the pagination tests above). + """ + + def connect(self) -> Any: + raise AssertionError("list_imports_payload validation must not touch the DB") + + +@pytest.mark.unit +def test_list_imports_payload_imported_by_routing_and_key_set( + monkeypatch: pytest.MonkeyPatch, +) -> None: + captured: dict[str, Any] = {} + + def _capture(conn: Any, **kwargs: Any) -> ReferenceResult: + captured.update(kwargs) + site = _site( + edge_kind="import", + path="src/importer.py", + target_name="os.path", + resolution="unresolved", + candidate_count=0, + ) + return _result(sites=(site,)) + + monkeypatch.setattr(service, "resolve_references", _capture) + monkeypatch.setattr(service, "_repo_name_map", lambda conn: {7: "acme/widgets"}) + + payload = service.list_imports_payload( + _FakeEngine([]), _cfg(), target="os.path", direction="imported_by" + ) + + # Routing: corpus-wide over target_name, no repo scope. + assert captured["target_name"] == "os.path" + assert captured["edge_kind"] == "import" + assert captured["repo"] is None + # Uniform key set on the imported_by path. + assert payload["kind"] == "imports" + assert payload["direction"] == "imported_by" + assert payload["target"] == "os.path" + assert payload["repo"] is None + assert payload["repo_known"] is True + assert payload["query"] == "os.path" # query echoes target for imported_by + assert payload["query_too_broad"] is False + assert payload["site_count"] == 1 + + +@pytest.mark.unit +def test_list_imports_payload_imported_by_repo_narrowing_and_branch_echo( + monkeypatch: pytest.MonkeyPatch, +) -> None: + captured: dict[str, Any] = {} + + def _capture(conn: Any, **kwargs: Any) -> ReferenceResult: + captured.update(kwargs) + return _result() + + monkeypatch.setattr(service, "resolve_references", _capture) + monkeypatch.setattr(service, "_repo_name_map", lambda conn: {}) + + payload = service.list_imports_payload( + _FakeEngine([]), + _cfg(), + "acme/widgets", + 200, + branch="feature/x", + target="os.path", + direction="imported_by", + ) + + # An optional repo narrows the corpus-wide "who imports X" to one repo; branch threads through. + assert captured["repo"] == "acme/widgets" + assert captured["target_name"] == "os.path" + assert captured["branch"] == "feature/x" + assert payload["repo"] == "acme/widgets" + assert payload["branch"] == "feature/x" + + +@pytest.mark.unit +def test_list_imports_payload_unsupported_direction_is_structured_pre_db() -> None: + payload = service.list_imports_payload( + _FailingEngine(), _cfg(), "acme/widgets", 200, direction="sideways" + ) + + assert payload["unsupported_direction"] == "sideways" + assert "reason" in payload + assert payload["direction"] == "sideways" # echoed unchanged + assert payload["query"] == "" # pinned deterministic value (Critic note 3) + # Full empty envelope. + assert payload["kind"] == "imports" + assert payload["repo_known"] is True + assert payload["sites"] == [] + assert payload["site_count"] == 0 + assert payload["resolution_summary"] == {"unique": 0, "ambiguous": 0, "unresolved": 0} + assert payload["truncated"] is False + assert payload["truncation_reason"] is None + assert payload["query_too_broad"] is False + + +@pytest.mark.unit +def test_list_imports_payload_missing_repo_is_structured_pre_db() -> None: + # direction="imports" (default) with no repo: a structured miss, no DB touch. + payload = service.list_imports_payload(_FailingEngine(), _cfg()) + + assert payload["missing_repo"] is True + assert "reason" in payload + assert payload["direction"] == "imports" + assert payload["repo"] is None + assert payload["query"] == "" # pinned deterministic value (Critic note 3) + assert payload["sites"] == [] + assert payload["query_too_broad"] is False + + +@pytest.mark.unit +def test_list_imports_payload_missing_target_is_structured_pre_db() -> None: + # direction="imported_by" with no target: a structured miss, no DB touch. + payload = service.list_imports_payload(_FailingEngine(), _cfg(), direction="imported_by") + + assert payload["missing_target"] is True + assert "reason" in payload + assert payload["direction"] == "imported_by" + assert payload["target"] is None + assert payload["query"] == "" # pinned deterministic value (Critic note 3) + assert payload["sites"] == [] + assert payload["query_too_broad"] is False + + +@pytest.mark.unit +def test_reference_builders_importable_without_perturbing_search_code_export_set() -> None: + # Regression guard (plan D8 note): no existing unit test pins an exact `app.service` + # export set (test_main.py pins search_code's ENVELOPE, not the module's exports), so two + # additive builders are safe to add. This just proves they're importable as documented. + assert callable(service.find_references_payload) + assert callable(service.list_imports_payload) diff --git a/tests/unit/test_smoke.py b/tests/unit/test_smoke.py index df1cdd1..2df4099 100644 --- a/tests/unit/test_smoke.py +++ b/tests/unit/test_smoke.py @@ -142,6 +142,123 @@ def test_validate_search_payload_rejects_match_non_list_byte_ranges() -> None: assert smoke.validate_search_payload(payload).ok is False +# --- validate_references_payload (shape-only + negatives) ------------------- + + +# find_references-shaped golden: one ambiguous site with two ranked candidates. +REFERENCES_GOLDEN = { + "query": "process", + "kind": "references", + "symbol": "process", + "branch": None, + "query_too_broad": False, + "site_count": 1, + "sites": [ + { + "repo": "acme/widgets", + "file": "src/caller.py", + "line": 5, + "edge_kind": "call", + "target_name": "process", + "enclosing_symbol": {"name": "run", "kind": "function"}, + "resolution": "ambiguous", + "candidate_count": 2, + "candidates_truncated": False, + "candidates": [ + {"repo": "acme/widgets", "file": "src/service.py", "line": 10, "name": "process"}, + {"repo": "acme/widgets", "file": "src/worker.py", "line": 4, "name": "process"}, + ], + } + ], + "resolution_summary": {"unique": 0, "ambiguous": 1, "unresolved": 0}, + "truncated": False, + "truncation_reason": None, +} + +# list_imports-shaped golden: carries `direction`, zero sites (accepted by design). +IMPORTS_GOLDEN = { + "query": "acme/widgets", + "kind": "imports", + "direction": "imports", + "repo": "acme/widgets", + "repo_known": True, + "target": None, + "branch": None, + "query_too_broad": False, + "site_count": 0, + "sites": [], + "resolution_summary": {"unique": 0, "ambiguous": 0, "unresolved": 0}, + "truncated": False, + "truncation_reason": None, +} + + +@pytest.mark.unit +def test_validate_references_payload_accepts_references_golden() -> None: + assert smoke.validate_references_payload(REFERENCES_GOLDEN).ok is True + + +@pytest.mark.unit +def test_validate_references_payload_accepts_imports_golden_with_direction() -> None: + assert smoke.validate_references_payload(IMPORTS_GOLDEN).ok is True + + +@pytest.mark.unit +def test_validate_references_payload_accepts_zero_sites() -> None: + # A live corpus's symbols are unpredictable, so an empty-but-well-formed envelope is a PASS. + payload = {**REFERENCES_GOLDEN, "site_count": 0, "sites": []} + assert smoke.validate_references_payload(payload).ok is True + + +@pytest.mark.unit +def test_validate_references_payload_rejects_site_count_mismatch() -> None: + payload = {**REFERENCES_GOLDEN, "site_count": 5} + assert smoke.validate_references_payload(payload).ok is False + + +@pytest.mark.unit +@pytest.mark.parametrize("flag", ["truncated", "query_too_broad"]) +def test_validate_references_payload_rejects_non_bool_flags(flag: str) -> None: + payload = {**REFERENCES_GOLDEN, flag: "nope"} + assert smoke.validate_references_payload(payload).ok is False + + +@pytest.mark.unit +def test_validate_references_payload_rejects_wrong_resolution_summary_keys() -> None: + payload = {**REFERENCES_GOLDEN, "resolution_summary": {"unique": 0, "ambiguous": 1}} + assert smoke.validate_references_payload(payload).ok is False + + +@pytest.mark.unit +def test_validate_references_payload_rejects_non_int_resolution_summary_values() -> None: + payload = { + **REFERENCES_GOLDEN, + "resolution_summary": {"unique": "0", "ambiguous": 1, "unresolved": 0}, + } + assert smoke.validate_references_payload(payload).ok is False + + +@pytest.mark.unit +def test_validate_references_payload_rejects_non_list_sites() -> None: + payload = {**REFERENCES_GOLDEN, "sites": "nope", "site_count": 0} + assert smoke.validate_references_payload(payload).ok is False + + +@pytest.mark.unit +def test_validate_references_payload_rejects_site_missing_file() -> None: + bad_site = {**REFERENCES_GOLDEN["sites"][0]} + del bad_site["file"] + payload = {**REFERENCES_GOLDEN, "sites": [bad_site]} + assert smoke.validate_references_payload(payload).ok is False + + +@pytest.mark.unit +def test_validate_references_payload_rejects_site_non_int_line() -> None: + bad_site = {**REFERENCES_GOLDEN["sites"][0], "line": "5"} + payload = {**REFERENCES_GOLDEN, "sites": [bad_site]} + assert smoke.validate_references_payload(payload).ok is False + + # --- MCP leg TLS guard (returns before any I/O) ----------------------------- diff --git a/tests/unit/test_store_chunk_writer.py b/tests/unit/test_store_chunk_writer.py index 037a322..a4a788b 100644 --- a/tests/unit/test_store_chunk_writer.py +++ b/tests/unit/test_store_chunk_writer.py @@ -16,7 +16,7 @@ import pytest from sqlalchemy import Delete, Insert, Update -from indexer.languages import ExtractedSymbol, IndexCounts, ParsedFile +from indexer.languages import ExtractedSymbol, FileExtraction, IndexCounts, ParsedFile from indexer.store import StaleIndexError, index_repo @@ -73,6 +73,10 @@ def execute(self, stmt: Any, params: Any = None) -> _FakeResult: return _FakeResult() if isinstance(stmt, Delete) and table == "symbols": return _FakeResult() + if isinstance(stmt, Insert) and table == "reference_edges": + return _FakeResult() + if isinstance(stmt, Delete) and table == "reference_edges": + return _FakeResult() if isinstance(stmt, Update) and table == "repo_branches": return _FakeResult(rowcount=self._stamp_rowcount) raise AssertionError(f"unexpected statement against {table!r}: {stmt}") @@ -84,7 +88,12 @@ def _pf(path: str, content: str) -> ParsedFile: @pytest.mark.unit def test_chunk_writer_defaults_to_none_and_behavior_is_unchanged() -> None: - items = [(_pf("a.py", "x = 1\n"), [ExtractedSymbol("x", "variable", 1, 1)])] + items = [ + ( + _pf("a.py", "x = 1\n"), + FileExtraction(symbols=[ExtractedSymbol("x", "variable", 1, 1)], edges=[]), + ) + ] counts = index_repo( _FakeConn(), name="acme/widgets", @@ -93,7 +102,7 @@ def test_chunk_writer_defaults_to_none_and_behavior_is_unchanged() -> None: head_sha="sha1", items=items, ) - assert counts == IndexCounts(files=1, symbols=1, swept=0) + assert counts == IndexCounts(files=1, symbols=1, swept=0, edges=0) @pytest.mark.unit @@ -104,8 +113,8 @@ def chunk_writer(conn: Any, repo_id: int, file_id: int, pf: ParsedFile) -> None: calls.append((repo_id, file_id, pf.path)) items = [ - (_pf("a.py", "x = 1\n"), []), - (_pf("b.py", "y = 2\n"), []), + (_pf("a.py", "x = 1\n"), FileExtraction(symbols=[], edges=[])), + (_pf("b.py", "y = 2\n"), FileExtraction(symbols=[], edges=[])), ] index_repo( _FakeConn(), @@ -122,7 +131,7 @@ def chunk_writer(conn: Any, repo_id: int, file_id: int, pf: ParsedFile) -> None: @pytest.mark.unit def test_no_chunk_writer_means_no_extra_calls() -> None: # A None chunk_writer must never itself be invoked (it isn't callable). - items = [(_pf("a.py", "x = 1\n"), [])] + items = [(_pf("a.py", "x = 1\n"), FileExtraction(symbols=[], edges=[]))] # No AttributeError/TypeError from trying to call None -> proves the `if # chunk_writer is not None` guard is doing its job. index_repo( @@ -140,7 +149,7 @@ def test_no_chunk_writer_means_no_extra_calls() -> None: def test_stamp_matching_no_row_raises_stale_index_error() -> None: # The CAS UPDATE matching zero rows means the repo_branches row moved out # from under the statement-2 baseline; index_repo must abort rather than stamp. - items = [(_pf("a.py", "x = 1\n"), [])] + items = [(_pf("a.py", "x = 1\n"), FileExtraction(symbols=[], edges=[]))] with pytest.raises(StaleIndexError, match="acme/widgets"): index_repo( _FakeConn(stamp_rowcount=0), diff --git a/tests/unit/test_webui_main.py b/tests/unit/test_webui_main.py index 1ce56cd..a7d32e2 100644 --- a/tests/unit/test_webui_main.py +++ b/tests/unit/test_webui_main.py @@ -12,6 +12,7 @@ from __future__ import annotations +import inspect from typing import Any import pytest @@ -21,7 +22,7 @@ from app import service from app.config import Settings from app.search.grep import FileCursor, FileMatches, GrepResult, LineMatch -from webui.main import app, get_engine, get_settings +from webui.main import api_imports, api_references, app, get_engine, get_settings _NUL_BYTE_ERROR = DataError( "SELECT 1", {}, ValueError("PostgreSQL text fields cannot contain NUL (0x00) bytes") @@ -650,6 +651,318 @@ def _enabled_cfg() -> Settings: assert resp.json() == {"semantic_enabled": True} +# ---------------------------------------------------------------------------- /api/references + + +@pytest.mark.unit +def test_api_references_payload_passes_through_byte_identical( + client: TestClient, monkeypatch: pytest.MonkeyPatch +) -> None: + payload = { + "query": "process", + "kind": "references", + "symbol": "process", + "branch": None, + "query_too_broad": False, + "sites": [ + { + "repo": "acme/widgets", + "file": "src/handler.go", + "line": 10, + "edge_kind": "call", + "target_name": "process", + "enclosing_symbol": {"name": "Handle", "kind": "function"}, + "resolution": "ambiguous", + "candidate_count": 2, + "candidates_truncated": False, + "candidates": [ + { + "repo": "acme/widgets", + "file": "src/proc.go", + "line": 4, + "name": "process", + "kind": "function", + "same_repo": True, + "same_file": False, + "kind_match": True, + }, + { + "repo": "acme/other", + "file": "lib/proc.go", + "line": 9, + "name": "process", + "kind": "function", + "same_repo": False, + "same_file": False, + "kind_match": True, + }, + ], + } + ], + "site_count": 1, + "resolution_summary": {"unique": 0, "ambiguous": 1, "unresolved": 0}, + "truncated": False, + "truncation_reason": None, + } + monkeypatch.setattr(service, "find_references_payload", lambda *a, **k: payload) + + resp = client.get("/api/references", params={"symbol": "process"}) + + assert resp.status_code == 200 + assert resp.json() == payload + + +@pytest.mark.unit +def test_api_references_limit_default_and_clamp_and_branch_threading( + client: TestClient, monkeypatch: pytest.MonkeyPatch +) -> None: + calls: list[tuple[str, int, str | None]] = [] + + def _fake( + _engine: Any, _cfg: Any, name: str, limit: int, branch: str | None = None + ) -> dict[str, Any]: + calls.append((name, limit, branch)) + return { + "query": name, + "kind": "references", + "symbol": name, + "branch": branch, + "query_too_broad": False, + "sites": [], + "site_count": 0, + "resolution_summary": {"unique": 0, "ambiguous": 0, "unresolved": 0}, + "truncated": False, + "truncation_reason": None, + } + + monkeypatch.setattr(service, "find_references_payload", _fake) + + client.get("/api/references", params={"symbol": "process"}) + client.get("/api/references", params={"symbol": "process", "limit": 0}) + client.get("/api/references", params={"symbol": "process", "limit": 5000}) + client.get("/api/references", params={"symbol": "process", "branch": "feature/x"}) + + assert calls[0] == ("process", 200, None) + assert calls[1] == ("process", 200, None) # 0 -> cfg.row_limit (200) + assert calls[2] == ("process", 1000, None) # clamped to cfg.max_row_limit + assert calls[3] == ("process", 200, "feature/x") + + # Silent drift between the route's and the MCP tool's `limit` default would break AC2 + # (same defaulted call must return the same result set) without any test noticing -- + # pin the default itself, not just its observed clamped value. + from app import main as mcp_main + + route_default = inspect.signature(api_references).parameters["limit"].default + mcp_default = inspect.signature(mcp_main.find_references).parameters["limit"].default + assert route_default == mcp_default == 200 + + +@pytest.mark.unit +def test_api_references_missing_symbol_is_422(client: TestClient) -> None: + resp = client.get("/api/references") + + assert resp.status_code == 422 + + +@pytest.mark.unit +def test_api_references_nul_byte_is_400( + client: TestClient, monkeypatch: pytest.MonkeyPatch +) -> None: + def _raise(*_a: object, **_k: object) -> dict[str, Any]: + raise _NUL_BYTE_ERROR + + monkeypatch.setattr(service, "find_references_payload", _raise) + + resp = client.get("/api/references", params={"symbol": "foo\x00bar"}) + + assert resp.status_code == 400 + assert resp.json()["detail"]["error"] == "invalid parameter" + + +# -------------------------------------------------------------------------------- /api/imports + + +@pytest.mark.unit +def test_api_imports_payload_passes_through_byte_identical( + client: TestClient, monkeypatch: pytest.MonkeyPatch +) -> None: + payload = { + "query": "acme/widgets", + "kind": "imports", + "direction": "imports", + "repo": "acme/widgets", + "repo_known": True, + "target": None, + "branch": None, + "query_too_broad": False, + "sites": [ + { + "repo": "acme/widgets", + "file": "src/handler.py", + "line": 1, + "edge_kind": "import", + "target_name": "os.path", + "enclosing_symbol": None, + "resolution": "unresolved", + "candidate_count": 0, + "candidates_truncated": False, + "candidates": [], + } + ], + "site_count": 1, + "resolution_summary": {"unique": 0, "ambiguous": 0, "unresolved": 1}, + "truncated": False, + "truncation_reason": None, + } + monkeypatch.setattr(service, "list_imports_payload", lambda *a, **k: payload) + + resp = client.get("/api/imports", params={"repo": "acme/widgets"}) + + assert resp.status_code == 200 + assert resp.json() == payload + + +@pytest.mark.unit +def test_api_imports_validation_payloads_pass_through_as_200( + client: TestClient, monkeypatch: pytest.MonkeyPatch +) -> None: + # Proves D2's "no payload inspection" for /api/imports: every PRE-DB structured validation + # payload (app.service._list_imports_error_payload) round-trips as a 200 body, byte-identical, + # never re-shaped or gated by this route. + unsupported_direction_payload = { + "query": "", + "kind": "imports", + "direction": "sideways", + "repo": None, + "repo_known": True, + "target": None, + "branch": None, + "query_too_broad": False, + "sites": [], + "site_count": 0, + "resolution_summary": {"unique": 0, "ambiguous": 0, "unresolved": 0}, + "truncated": False, + "truncation_reason": None, + "unsupported_direction": "sideways", + "reason": "direction must be one of 'imports' or 'imported_by'", + } + missing_repo_payload = { + "query": "", + "kind": "imports", + "direction": "imports", + "repo": None, + "repo_known": True, + "target": None, + "branch": None, + "query_too_broad": False, + "sites": [], + "site_count": 0, + "resolution_summary": {"unique": 0, "ambiguous": 0, "unresolved": 0}, + "truncated": False, + "truncation_reason": None, + "missing_repo": True, + "reason": "direction='imports' requires a repo to enumerate; pass repo=", + } + missing_target_payload = { + "query": "", + "kind": "imports", + "direction": "imported_by", + "repo": None, + "repo_known": True, + "target": None, + "branch": None, + "query_too_broad": False, + "sites": [], + "site_count": 0, + "resolution_summary": {"unique": 0, "ambiguous": 0, "unresolved": 0}, + "truncated": False, + "truncation_reason": None, + "missing_target": True, + "reason": "direction='imported_by' requires a target dotted path; pass target=", + } + + for payload in (unsupported_direction_payload, missing_repo_payload, missing_target_payload): + monkeypatch.setattr(service, "list_imports_payload", lambda *a, _p=payload, **k: _p) + + resp = client.get("/api/imports", params={"direction": payload["direction"]}) + + assert resp.status_code == 200 + assert resp.json() == payload + + +@pytest.mark.unit +def test_api_imports_limit_default_clamp_and_arg_threading( + client: TestClient, monkeypatch: pytest.MonkeyPatch +) -> None: + calls: list[tuple[str | None, int, str | None, str | None, str]] = [] + + def _fake( + _engine: Any, + _cfg: Any, + repo: str | None, + limit: int, + branch: str | None = None, + *, + target: str | None = None, + direction: str = "imports", + ) -> dict[str, Any]: + calls.append((repo, limit, branch, target, direction)) + return { + "query": repo or target or "", + "kind": "imports", + "direction": direction, + "repo": repo, + "repo_known": True, + "target": target, + "branch": branch, + "query_too_broad": False, + "sites": [], + "site_count": 0, + "resolution_summary": {"unique": 0, "ambiguous": 0, "unresolved": 0}, + "truncated": False, + "truncation_reason": None, + } + + monkeypatch.setattr(service, "list_imports_payload", _fake) + + client.get("/api/imports", params={"repo": "acme/widgets"}) + client.get("/api/imports", params={"repo": "acme/widgets", "limit": 0}) + client.get("/api/imports", params={"repo": "acme/widgets", "limit": 5000}) + client.get( + "/api/imports", + params={ + "target": "os.path", + "direction": "imported_by", + "repo": "acme/widgets", + "branch": "feature/x", + }, + ) + + assert calls[0] == ("acme/widgets", 200, None, None, "imports") + assert calls[1] == ("acme/widgets", 200, None, None, "imports") # 0 -> cfg.row_limit (200) + assert calls[2] == ("acme/widgets", 1000, None, None, "imports") # clamped + assert calls[3] == ("acme/widgets", 200, "feature/x", "os.path", "imported_by") + + from app import main as mcp_main + + route_default = inspect.signature(api_imports).parameters["limit"].default + mcp_default = inspect.signature(mcp_main.list_imports).parameters["limit"].default + assert route_default == mcp_default == 200 + + +@pytest.mark.unit +def test_api_imports_nul_byte_is_400(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: + def _raise(*_a: object, **_k: object) -> dict[str, Any]: + raise _NUL_BYTE_ERROR + + monkeypatch.setattr(service, "list_imports_payload", _raise) + + resp = client.get("/api/imports", params={"repo": "foo\x00bar"}) + + assert resp.status_code == 400 + assert resp.json()["detail"]["error"] == "invalid parameter" + + # --------------------------------------------------------------------- security headers diff --git a/webui/frontend/dist/assets/index-CpW1qpG3.js b/webui/frontend/dist/assets/index-CpW1qpG3.js new file mode 100644 index 0000000..2909137 --- /dev/null +++ b/webui/frontend/dist/assets/index-CpW1qpG3.js @@ -0,0 +1,46 @@ +const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/core-DqGsPV3B.js","assets/engine-compile-V7GR9ytC.js","assets/engine-javascript-ohkglBQk.js"])))=>i.map(i=>d[i]); +var Mc=Object.defineProperty;var Uc=(e,t,n)=>t in e?Mc(e,t,{enumerable:!0,configurable:!0,writable:!0,value:n}):e[t]=n;var Yo=(e,t,n)=>Uc(e,typeof t!="symbol"?t+"":t,n);(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const l of document.querySelectorAll('link[rel="modulepreload"]'))r(l);new MutationObserver(l=>{for(const i of l)if(i.type==="childList")for(const o of i.addedNodes)o.tagName==="LINK"&&o.rel==="modulepreload"&&r(o)}).observe(document,{childList:!0,subtree:!0});function n(l){const i={};return l.integrity&&(i.integrity=l.integrity),l.referrerPolicy&&(i.referrerPolicy=l.referrerPolicy),l.crossOrigin==="use-credentials"?i.credentials="include":l.crossOrigin==="anonymous"?i.credentials="omit":i.credentials="same-origin",i}function r(l){if(l.ep)return;l.ep=!0;const i=n(l);fetch(l.href,i)}})();var cs={exports:{}},ol={},fs={exports:{}},z={};/** + * @license React + * react.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var bn=Symbol.for("react.element"),Ac=Symbol.for("react.portal"),Bc=Symbol.for("react.fragment"),Vc=Symbol.for("react.strict_mode"),Hc=Symbol.for("react.profiler"),Wc=Symbol.for("react.provider"),Qc=Symbol.for("react.context"),Kc=Symbol.for("react.forward_ref"),Yc=Symbol.for("react.suspense"),Xc=Symbol.for("react.memo"),Gc=Symbol.for("react.lazy"),Xo=Symbol.iterator;function Zc(e){return e===null||typeof e!="object"?null:(e=Xo&&e[Xo]||e["@@iterator"],typeof e=="function"?e:null)}var ds={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},ps=Object.assign,hs={};function cn(e,t,n){this.props=e,this.context=t,this.refs=hs,this.updater=n||ds}cn.prototype.isReactComponent={};cn.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")};cn.prototype.forceUpdate=function(e){this.updater.enqueueForceUpdate(this,e,"forceUpdate")};function ms(){}ms.prototype=cn.prototype;function Ji(e,t,n){this.props=e,this.context=t,this.refs=hs,this.updater=n||ds}var bi=Ji.prototype=new ms;bi.constructor=Ji;ps(bi,cn.prototype);bi.isPureReactComponent=!0;var Go=Array.isArray,gs=Object.prototype.hasOwnProperty,eo={current:null},vs={key:!0,ref:!0,__self:!0,__source:!0};function ys(e,t,n){var r,l={},i=null,o=null;if(t!=null)for(r in t.ref!==void 0&&(o=t.ref),t.key!==void 0&&(i=""+t.key),t)gs.call(t,r)&&!vs.hasOwnProperty(r)&&(l[r]=t[r]);var u=arguments.length-2;if(u===1)l.children=n;else if(1>>1,q=C[Q];if(0>>1;Ql(Nl,R))xtl(or,Nl)?(C[Q]=or,C[xt]=R,Q=xt):(C[Q]=Nl,C[St]=R,Q=St);else if(xtl(or,R))C[Q]=or,C[xt]=R,Q=xt;else break e}}return T}function l(C,T){var R=C.sortIndex-T.sortIndex;return R!==0?R:C.id-T.id}if(typeof performance=="object"&&typeof performance.now=="function"){var i=performance;e.unstable_now=function(){return i.now()}}else{var o=Date,u=o.now();e.unstable_now=function(){return o.now()-u}}var s=[],d=[],g=1,h=null,m=3,v=!1,x=!1,S=!1,I=typeof setTimeout=="function"?setTimeout:null,f=typeof clearTimeout=="function"?clearTimeout:null,c=typeof setImmediate<"u"?setImmediate:null;typeof navigator<"u"&&navigator.scheduling!==void 0&&navigator.scheduling.isInputPending!==void 0&&navigator.scheduling.isInputPending.bind(navigator.scheduling);function p(C){for(var T=n(d);T!==null;){if(T.callback===null)r(d);else if(T.startTime<=C)r(d),T.sortIndex=T.expirationTime,t(s,T);else break;T=n(d)}}function y(C){if(S=!1,p(C),!x)if(n(s)!==null)x=!0,Cl(_);else{var T=n(d);T!==null&&jl(y,T.startTime-C)}}function _(C,T){x=!1,S&&(S=!1,f(k),k=-1),v=!0;var R=m;try{for(p(T),h=n(s);h!==null&&(!(h.expirationTime>T)||C&&!Y());){var Q=h.callback;if(typeof Q=="function"){h.callback=null,m=h.priorityLevel;var q=Q(h.expirationTime<=T);T=e.unstable_now(),typeof q=="function"?h.callback=q:h===n(s)&&r(s),p(T)}else r(s);h=n(s)}if(h!==null)var ir=!0;else{var St=n(d);St!==null&&jl(y,St.startTime-T),ir=!1}return ir}finally{h=null,m=R,v=!1}}var j=!1,N=null,k=-1,D=5,L=-1;function Y(){return!(e.unstable_now()-LC||125Q?(C.sortIndex=R,t(d,C),n(s)===null&&C===n(d)&&(S?(f(k),k=-1):S=!0,jl(y,R-Q))):(C.sortIndex=q,t(s,C),x||v||(x=!0,Cl(_))),C},e.unstable_shouldYield=Y,e.unstable_wrapCallback=function(C){var T=m;return function(){var R=m;m=T;try{return C.apply(this,arguments)}finally{m=R}}}})(_s);ks.exports=_s;var sf=ks.exports;/** + * @license React + * react-dom.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var af=P,Se=sf;function w(e){for(var t="https://reactjs.org/docs/error-decoder.html?invariant="+e,n=1;n"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),ti=Object.prototype.hasOwnProperty,cf=/^[: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]*$/,qo={},Jo={};function ff(e){return ti.call(Jo,e)?!0:ti.call(qo,e)?!1:cf.test(e)?Jo[e]=!0:(qo[e]=!0,!1)}function df(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?!n.acceptsBooleans:(e=e.toLowerCase().slice(0,5),e!=="data-"&&e!=="aria-");default:return!1}}function pf(e,t,n,r){if(t===null||typeof t>"u"||df(e,t,n,r))return!0;if(r)return!1;if(n!==null)switch(n.type){case 3:return!t;case 4:return t===!1;case 5:return isNaN(t);case 6:return isNaN(t)||1>t}return!1}function ce(e,t,n,r,l,i,o){this.acceptsBooleans=t===2||t===3||t===4,this.attributeName=r,this.attributeNamespace=l,this.mustUseProperty=n,this.propertyName=e,this.type=t,this.sanitizeURL=i,this.removeEmptyString=o}var ne={};"children dangerouslySetInnerHTML defaultValue defaultChecked innerHTML suppressContentEditableWarning suppressHydrationWarning style".split(" ").forEach(function(e){ne[e]=new ce(e,0,!1,e,null,!1,!1)});[["acceptCharset","accept-charset"],["className","class"],["htmlFor","for"],["httpEquiv","http-equiv"]].forEach(function(e){var t=e[0];ne[t]=new ce(t,1,!1,e[1],null,!1,!1)});["contentEditable","draggable","spellCheck","value"].forEach(function(e){ne[e]=new ce(e,2,!1,e.toLowerCase(),null,!1,!1)});["autoReverse","externalResourcesRequired","focusable","preserveAlpha"].forEach(function(e){ne[e]=new ce(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){ne[e]=new ce(e,3,!1,e.toLowerCase(),null,!1,!1)});["checked","multiple","muted","selected"].forEach(function(e){ne[e]=new ce(e,3,!0,e,null,!1,!1)});["capture","download"].forEach(function(e){ne[e]=new ce(e,4,!1,e,null,!1,!1)});["cols","rows","size","span"].forEach(function(e){ne[e]=new ce(e,6,!1,e,null,!1,!1)});["rowSpan","start"].forEach(function(e){ne[e]=new ce(e,5,!1,e.toLowerCase(),null,!1,!1)});var no=/[\-:]([a-z])/g;function ro(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(no,ro);ne[t]=new ce(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(no,ro);ne[t]=new ce(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(no,ro);ne[t]=new ce(t,1,!1,e,"http://www.w3.org/XML/1998/namespace",!1,!1)});["tabIndex","crossOrigin"].forEach(function(e){ne[e]=new ce(e,1,!1,e.toLowerCase(),null,!1,!1)});ne.xlinkHref=new ce("xlinkHref",1,!1,"xlink:href","http://www.w3.org/1999/xlink",!0,!1);["src","href","action","formAction"].forEach(function(e){ne[e]=new ce(e,1,!1,e.toLowerCase(),null,!0,!0)});function lo(e,t,n,r){var l=ne.hasOwnProperty(t)?ne[t]:null;(l!==null?l.type!==0:r||!(2u||l[o]!==i[u]){var s=` +`+l[o].replace(" at new "," at ");return e.displayName&&s.includes("")&&(s=s.replace("",e.displayName)),s}while(1<=o&&0<=u);break}}}finally{Tl=!1,Error.prepareStackTrace=n}return(e=e?e.displayName||e.name:"")?xn(e):""}function hf(e){switch(e.tag){case 5:return xn(e.type);case 16:return xn("Lazy");case 13:return xn("Suspense");case 19:return xn("SuspenseList");case 0:case 2:case 15:return e=Rl(e.type,!1),e;case 11:return e=Rl(e.type.render,!1),e;case 1:return e=Rl(e.type,!0),e;default:return""}}function ii(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 Ut:return"Fragment";case Mt:return"Portal";case ni:return"Profiler";case io:return"StrictMode";case ri:return"Suspense";case li:return"SuspenseList"}if(typeof e=="object")switch(e.$$typeof){case js:return(e.displayName||"Context")+".Consumer";case Cs:return(e._context.displayName||"Context")+".Provider";case oo:var t=e.render;return e=e.displayName,e||(e=t.displayName||t.name||"",e=e!==""?"ForwardRef("+e+")":"ForwardRef"),e;case uo:return t=e.displayName||null,t!==null?t:ii(e.type)||"Memo";case tt:t=e._payload,e=e._init;try{return ii(e(t))}catch{}}return null}function mf(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("+e+")":"ForwardRef");case 7:return"Fragment";case 5:return t;case 4:return"Portal";case 3:return"Root";case 6:return"Text";case 16:return ii(t);case 8:return t===io?"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 mt(e){switch(typeof e){case"boolean":case"number":case"string":case"undefined":return e;case"object":return e;default:return""}}function Ps(e){var t=e.type;return(e=e.nodeName)&&e.toLowerCase()==="input"&&(t==="checkbox"||t==="radio")}function gf(e){var t=Ps(e)?"checked":"value",n=Object.getOwnPropertyDescriptor(e.constructor.prototype,t),r=""+e[t];if(!e.hasOwnProperty(t)&&typeof n<"u"&&typeof n.get=="function"&&typeof n.set=="function"){var l=n.get,i=n.set;return Object.defineProperty(e,t,{configurable:!0,get:function(){return l.call(this)},set:function(o){r=""+o,i.call(this,o)}}),Object.defineProperty(e,t,{enumerable:n.enumerable}),{getValue:function(){return r},setValue:function(o){r=""+o},stopTracking:function(){e._valueTracker=null,delete e[t]}}}}function ar(e){e._valueTracker||(e._valueTracker=gf(e))}function Ls(e){if(!e)return!1;var t=e._valueTracker;if(!t)return!0;var n=t.getValue(),r="";return e&&(r=Ps(e)?e.checked?"true":"false":e.value),e=r,e!==n?(t.setValue(e),!0):!1}function $r(e){if(e=e||(typeof document<"u"?document:void 0),typeof e>"u")return null;try{return e.activeElement||e.body}catch{return e.body}}function oi(e,t){var n=t.checked;return H({},t,{defaultChecked:void 0,defaultValue:void 0,value:void 0,checked:n??e._wrapperState.initialChecked})}function eu(e,t){var n=t.defaultValue==null?"":t.defaultValue,r=t.checked!=null?t.checked:t.defaultChecked;n=mt(t.value!=null?t.value:n),e._wrapperState={initialChecked:r,initialValue:n,controlled:t.type==="checkbox"||t.type==="radio"?t.checked!=null:t.value!=null}}function Ts(e,t){t=t.checked,t!=null&&lo(e,"checked",t,!1)}function ui(e,t){Ts(e,t);var n=mt(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")?si(e,t.type,n):t.hasOwnProperty("defaultValue")&&si(e,t.type,mt(t.defaultValue)),t.checked==null&&t.defaultChecked!=null&&(e.defaultChecked=!!t.defaultChecked)}function tu(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 si(e,t,n){(t!=="number"||$r(e.ownerDocument)!==e)&&(n==null?e.defaultValue=""+e._wrapperState.initialValue:e.defaultValue!==""+n&&(e.defaultValue=""+n))}var kn=Array.isArray;function Zt(e,t,n,r){if(e=e.options,t){t={};for(var l=0;l"+t.valueOf().toString()+"",t=cr.firstChild;e.firstChild;)e.removeChild(e.firstChild);for(;t.firstChild;)e.appendChild(t.firstChild)}});function Dn(e,t){if(t){var n=e.firstChild;if(n&&n===e.lastChild&&n.nodeType===3){n.nodeValue=t;return}}e.textContent=t}var Cn={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},vf=["Webkit","ms","Moz","O"];Object.keys(Cn).forEach(function(e){vf.forEach(function(t){t=t+e.charAt(0).toUpperCase()+e.substring(1),Cn[t]=Cn[e]})});function Os(e,t,n){return t==null||typeof t=="boolean"||t===""?"":n||typeof t!="number"||t===0||Cn.hasOwnProperty(e)&&Cn[e]?(""+t).trim():t+"px"}function Ds(e,t){e=e.style;for(var n in t)if(t.hasOwnProperty(n)){var r=n.indexOf("--")===0,l=Os(n,t[n],r);n==="float"&&(n="cssFloat"),r?e.setProperty(n,l):e[n]=l}}var yf=H({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 fi(e,t){if(t){if(yf[e]&&(t.children!=null||t.dangerouslySetInnerHTML!=null))throw Error(w(137,e));if(t.dangerouslySetInnerHTML!=null){if(t.children!=null)throw Error(w(60));if(typeof t.dangerouslySetInnerHTML!="object"||!("__html"in t.dangerouslySetInnerHTML))throw Error(w(61))}if(t.style!=null&&typeof t.style!="object")throw Error(w(62))}}function di(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 pi=null;function so(e){return e=e.target||e.srcElement||window,e.correspondingUseElement&&(e=e.correspondingUseElement),e.nodeType===3?e.parentNode:e}var hi=null,qt=null,Jt=null;function lu(e){if(e=nr(e)){if(typeof hi!="function")throw Error(w(280));var t=e.stateNode;t&&(t=fl(t),hi(e.stateNode,e.type,t))}}function $s(e){qt?Jt?Jt.push(e):Jt=[e]:qt=e}function Fs(){if(qt){var e=qt,t=Jt;if(Jt=qt=null,lu(e),t)for(e=0;e>>=0,e===0?32:31-(Lf(e)/Tf|0)|0}var fr=64,dr=4194304;function _n(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 Ar(e,t){var n=e.pendingLanes;if(n===0)return 0;var r=0,l=e.suspendedLanes,i=e.pingedLanes,o=n&268435455;if(o!==0){var u=o&~l;u!==0?r=_n(u):(i&=o,i!==0&&(r=_n(i)))}else o=n&~l,o!==0?r=_n(o):i!==0&&(r=_n(i));if(r===0)return 0;if(t!==0&&t!==r&&!(t&l)&&(l=r&-r,i=t&-t,l>=i||l===16&&(i&4194240)!==0))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 er(e,t,n){e.pendingLanes|=t,t!==536870912&&(e.suspendedLanes=0,e.pingedLanes=0),e=e.eventTimes,t=31-Oe(t),e[t]=n}function Of(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=Nn),pu=" ",hu=!1;function ra(e,t){switch(e){case"keyup":return sd.indexOf(t.keyCode)!==-1;case"keydown":return t.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function la(e){return e=e.detail,typeof e=="object"&&"data"in e?e.data:null}var At=!1;function cd(e,t){switch(e){case"compositionend":return la(t);case"keypress":return t.which!==32?null:(hu=!0,pu);case"textInput":return e=t.data,e===pu&&hu?null:e;default:return null}}function fd(e,t){if(At)return e==="compositionend"||!vo&&ra(e,t)?(e=ta(),Nr=ho=it=null,At=!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}e:{for(;n;){if(n.nextSibling){n=n.nextSibling;break e}n=n.parentNode}n=void 0}n=yu(n)}}function sa(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?sa(e,t.parentNode):"contains"in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function aa(){for(var e=window,t=$r();t instanceof e.HTMLIFrameElement;){try{var n=typeof t.contentWindow.location.href=="string"}catch{n=!1}if(n)e=t.contentWindow;else break;t=$r(e.document)}return t}function yo(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 Sd(e){var t=aa(),n=e.focusedElem,r=e.selectionRange;if(t!==n&&n&&n.ownerDocument&&sa(n.ownerDocument.documentElement,n)){if(r!==null&&yo(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 l=n.textContent.length,i=Math.min(r.start,l);r=r.end===void 0?i:Math.min(r.end,l),!e.extend&&i>r&&(l=r,r=i,i=l),l=wu(n,i);var o=wu(n,r);l&&o&&(e.rangeCount!==1||e.anchorNode!==l.node||e.anchorOffset!==l.offset||e.focusNode!==o.node||e.focusOffset!==o.offset)&&(t=t.createRange(),t.setStart(l.node,l.offset),e.removeAllRanges(),i>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,Bt=null,Si=null,Ln=null,xi=!1;function Su(e,t,n){var r=n.window===n?n.document:n.nodeType===9?n:n.ownerDocument;xi||Bt==null||Bt!==$r(r)||(r=Bt,"selectionStart"in r&&yo(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}),Ln&&Bn(Ln,r)||(Ln=r,r=Hr(Si,"onSelect"),0Wt||(e.current=Ni[Wt],Ni[Wt]=null,Wt--)}function F(e,t){Wt++,Ni[Wt]=e.current,e.current=t}var gt={},oe=yt(gt),pe=yt(!1),Lt=gt;function rn(e,t){var n=e.type.contextTypes;if(!n)return gt;var r=e.stateNode;if(r&&r.__reactInternalMemoizedUnmaskedChildContext===t)return r.__reactInternalMemoizedMaskedChildContext;var l={},i;for(i in n)l[i]=t[i];return r&&(e=e.stateNode,e.__reactInternalMemoizedUnmaskedChildContext=t,e.__reactInternalMemoizedMaskedChildContext=l),l}function he(e){return e=e.childContextTypes,e!=null}function Qr(){U(pe),U(oe)}function Nu(e,t,n){if(oe.current!==gt)throw Error(w(168));F(oe,t),F(pe,n)}function ya(e,t,n){var r=e.stateNode;if(t=t.childContextTypes,typeof r.getChildContext!="function")return n;r=r.getChildContext();for(var l in r)if(!(l in t))throw Error(w(108,mf(e)||"Unknown",l));return H({},n,r)}function Kr(e){return e=(e=e.stateNode)&&e.__reactInternalMemoizedMergedChildContext||gt,Lt=oe.current,F(oe,e),F(pe,pe.current),!0}function Pu(e,t,n){var r=e.stateNode;if(!r)throw Error(w(169));n?(e=ya(e,t,Lt),r.__reactInternalMemoizedMergedChildContext=e,U(pe),U(oe),F(oe,e)):U(pe),F(pe,n)}var We=null,dl=!1,Ql=!1;function wa(e){We===null?We=[e]:We.push(e)}function zd(e){dl=!0,wa(e)}function wt(){if(!Ql&&We!==null){Ql=!0;var e=0,t=$;try{var n=We;for($=1;e>=o,l-=o,Qe=1<<32-Oe(t)+l|n<k?(D=N,N=null):D=N.sibling;var L=m(f,N,p[k],y);if(L===null){N===null&&(N=D);break}e&&N&&L.alternate===null&&t(f,N),c=i(L,c,k),j===null?_=L:j.sibling=L,j=L,N=D}if(k===p.length)return n(f,N),A&&kt(f,k),_;if(N===null){for(;kk?(D=N,N=null):D=N.sibling;var Y=m(f,N,L.value,y);if(Y===null){N===null&&(N=D);break}e&&N&&Y.alternate===null&&t(f,N),c=i(Y,c,k),j===null?_=Y:j.sibling=Y,j=Y,N=D}if(L.done)return n(f,N),A&&kt(f,k),_;if(N===null){for(;!L.done;k++,L=p.next())L=h(f,L.value,y),L!==null&&(c=i(L,c,k),j===null?_=L:j.sibling=L,j=L);return A&&kt(f,k),_}for(N=r(f,N);!L.done;k++,L=p.next())L=v(N,f,k,L.value,y),L!==null&&(e&&L.alternate!==null&&N.delete(L.key===null?k:L.key),c=i(L,c,k),j===null?_=L:j.sibling=L,j=L);return e&&N.forEach(function(ge){return t(f,ge)}),A&&kt(f,k),_}function I(f,c,p,y){if(typeof p=="object"&&p!==null&&p.type===Ut&&p.key===null&&(p=p.props.children),typeof p=="object"&&p!==null){switch(p.$$typeof){case sr:e:{for(var _=p.key,j=c;j!==null;){if(j.key===_){if(_=p.type,_===Ut){if(j.tag===7){n(f,j.sibling),c=l(j,p.props.children),c.return=f,f=c;break e}}else if(j.elementType===_||typeof _=="object"&&_!==null&&_.$$typeof===tt&&Ru(_)===j.type){n(f,j.sibling),c=l(j,p.props),c.ref=yn(f,j,p),c.return=f,f=c;break e}n(f,j);break}else t(f,j);j=j.sibling}p.type===Ut?(c=Pt(p.props.children,f.mode,y,p.key),c.return=f,f=c):(y=Dr(p.type,p.key,p.props,null,f.mode,y),y.ref=yn(f,c,p),y.return=f,f=y)}return o(f);case Mt:e:{for(j=p.key;c!==null;){if(c.key===j)if(c.tag===4&&c.stateNode.containerInfo===p.containerInfo&&c.stateNode.implementation===p.implementation){n(f,c.sibling),c=l(c,p.children||[]),c.return=f,f=c;break e}else{n(f,c);break}else t(f,c);c=c.sibling}c=bl(p,f.mode,y),c.return=f,f=c}return o(f);case tt:return j=p._init,I(f,c,j(p._payload),y)}if(kn(p))return x(f,c,p,y);if(pn(p))return S(f,c,p,y);wr(f,p)}return typeof p=="string"&&p!==""||typeof p=="number"?(p=""+p,c!==null&&c.tag===6?(n(f,c.sibling),c=l(c,p),c.return=f,f=c):(n(f,c),c=Jl(p,f.mode,y),c.return=f,f=c),o(f)):n(f,c)}return I}var on=_a(!0),Ea=_a(!1),Gr=yt(null),Zr=null,Yt=null,ko=null;function _o(){ko=Yt=Zr=null}function Eo(e){var t=Gr.current;U(Gr),e._currentValue=t}function Ti(e,t,n){for(;e!==null;){var r=e.alternate;if((e.childLanes&t)!==t?(e.childLanes|=t,r!==null&&(r.childLanes|=t)):r!==null&&(r.childLanes&t)!==t&&(r.childLanes|=t),e===n)break;e=e.return}}function en(e,t){Zr=e,ko=Yt=null,e=e.dependencies,e!==null&&e.firstContext!==null&&(e.lanes&t&&(de=!0),e.firstContext=null)}function Ne(e){var t=e._currentValue;if(ko!==e)if(e={context:e,memoizedValue:t,next:null},Yt===null){if(Zr===null)throw Error(w(308));Yt=e,Zr.dependencies={lanes:0,firstContext:e}}else Yt=Yt.next=e;return t}var Ct=null;function Co(e){Ct===null?Ct=[e]:Ct.push(e)}function Ca(e,t,n,r){var l=t.interleaved;return l===null?(n.next=n,Co(t)):(n.next=l.next,l.next=n),t.interleaved=n,Ze(e,r)}function Ze(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 nt=!1;function jo(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,interleaved:null,lanes:0},effects:null}}function ja(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 Ye(e,t){return{eventTime:e,lane:t,tag:0,payload:null,callback:null,next:null}}function ft(e,t,n){var r=e.updateQueue;if(r===null)return null;if(r=r.shared,O&2){var l=r.pending;return l===null?t.next=t:(t.next=l.next,l.next=t),r.pending=t,Ze(e,n)}return l=r.interleaved,l===null?(t.next=t,Co(r)):(t.next=l.next,l.next=t),r.interleaved=t,Ze(e,n)}function Lr(e,t,n){if(t=t.updateQueue,t!==null&&(t=t.shared,(n&4194240)!==0)){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,co(e,n)}}function zu(e,t){var n=e.updateQueue,r=e.alternate;if(r!==null&&(r=r.updateQueue,n===r)){var l=null,i=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};i===null?l=i=o:i=i.next=o,n=n.next}while(n!==null);i===null?l=i=t:i=i.next=t}else l=i=t;n={baseState:r.baseState,firstBaseUpdate:l,lastBaseUpdate:i,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 qr(e,t,n,r){var l=e.updateQueue;nt=!1;var i=l.firstBaseUpdate,o=l.lastBaseUpdate,u=l.shared.pending;if(u!==null){l.shared.pending=null;var s=u,d=s.next;s.next=null,o===null?i=d:o.next=d,o=s;var g=e.alternate;g!==null&&(g=g.updateQueue,u=g.lastBaseUpdate,u!==o&&(u===null?g.firstBaseUpdate=d:u.next=d,g.lastBaseUpdate=s))}if(i!==null){var h=l.baseState;o=0,g=d=s=null,u=i;do{var m=u.lane,v=u.eventTime;if((r&m)===m){g!==null&&(g=g.next={eventTime:v,lane:0,tag:u.tag,payload:u.payload,callback:u.callback,next:null});e:{var x=e,S=u;switch(m=t,v=n,S.tag){case 1:if(x=S.payload,typeof x=="function"){h=x.call(v,h,m);break e}h=x;break e;case 3:x.flags=x.flags&-65537|128;case 0:if(x=S.payload,m=typeof x=="function"?x.call(v,h,m):x,m==null)break e;h=H({},h,m);break e;case 2:nt=!0}}u.callback!==null&&u.lane!==0&&(e.flags|=64,m=l.effects,m===null?l.effects=[u]:m.push(u))}else v={eventTime:v,lane:m,tag:u.tag,payload:u.payload,callback:u.callback,next:null},g===null?(d=g=v,s=h):g=g.next=v,o|=m;if(u=u.next,u===null){if(u=l.shared.pending,u===null)break;m=u,u=m.next,m.next=null,l.lastBaseUpdate=m,l.shared.pending=null}}while(!0);if(g===null&&(s=h),l.baseState=s,l.firstBaseUpdate=d,l.lastBaseUpdate=g,t=l.shared.interleaved,t!==null){l=t;do o|=l.lane,l=l.next;while(l!==t)}else i===null&&(l.shared.lanes=0);zt|=o,e.lanes=o,e.memoizedState=h}}function Iu(e,t,n){if(e=t.effects,t.effects=null,e!==null)for(t=0;tn?n:4,e(!0);var r=Yl.transition;Yl.transition={};try{e(!1),t()}finally{$=n,Yl.transition=r}}function Ha(){return Pe().memoizedState}function $d(e,t,n){var r=pt(e);if(n={lane:r,action:n,hasEagerState:!1,eagerState:null,next:null},Wa(e))Qa(t,n);else if(n=Ca(e,t,n,r),n!==null){var l=se();De(n,e,r,l),Ka(n,t,r)}}function Fd(e,t,n){var r=pt(e),l={lane:r,action:n,hasEagerState:!1,eagerState:null,next:null};if(Wa(e))Qa(t,l);else{var i=e.alternate;if(e.lanes===0&&(i===null||i.lanes===0)&&(i=t.lastRenderedReducer,i!==null))try{var o=t.lastRenderedState,u=i(o,n);if(l.hasEagerState=!0,l.eagerState=u,$e(u,o)){var s=t.interleaved;s===null?(l.next=l,Co(t)):(l.next=s.next,s.next=l),t.interleaved=l;return}}catch{}finally{}n=Ca(e,t,l,r),n!==null&&(l=se(),De(n,e,r,l),Ka(n,t,r))}}function Wa(e){var t=e.alternate;return e===V||t!==null&&t===V}function Qa(e,t){Tn=br=!0;var n=e.pending;n===null?t.next=t:(t.next=n.next,n.next=t),e.pending=t}function Ka(e,t,n){if(n&4194240){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,co(e,n)}}var el={readContext:Ne,useCallback:re,useContext:re,useEffect:re,useImperativeHandle:re,useInsertionEffect:re,useLayoutEffect:re,useMemo:re,useReducer:re,useRef:re,useState:re,useDebugValue:re,useDeferredValue:re,useTransition:re,useMutableSource:re,useSyncExternalStore:re,useId:re,unstable_isNewReconciler:!1},Md={readContext:Ne,useCallback:function(e,t){return Ue().memoizedState=[e,t===void 0?null:t],e},useContext:Ne,useEffect:Du,useImperativeHandle:function(e,t,n){return n=n!=null?n.concat([e]):null,Rr(4194308,4,Ma.bind(null,t,e),n)},useLayoutEffect:function(e,t){return Rr(4194308,4,e,t)},useInsertionEffect:function(e,t){return Rr(4,2,e,t)},useMemo:function(e,t){var n=Ue();return t=t===void 0?null:t,e=e(),n.memoizedState=[e,t],e},useReducer:function(e,t,n){var r=Ue();return t=n!==void 0?n(t):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=$d.bind(null,V,e),[r.memoizedState,e]},useRef:function(e){var t=Ue();return e={current:e},t.memoizedState=e},useState:Ou,useDebugValue:Oo,useDeferredValue:function(e){return Ue().memoizedState=e},useTransition:function(){var e=Ou(!1),t=e[0];return e=Dd.bind(null,e[1]),Ue().memoizedState=e,[t,e]},useMutableSource:function(){},useSyncExternalStore:function(e,t,n){var r=V,l=Ue();if(A){if(n===void 0)throw Error(w(407));n=n()}else{if(n=t(),b===null)throw Error(w(349));Rt&30||Ta(r,t,n)}l.memoizedState=n;var i={value:n,getSnapshot:t};return l.queue=i,Du(za.bind(null,r,i,e),[e]),r.flags|=2048,Gn(9,Ra.bind(null,r,i,n,t),void 0,null),n},useId:function(){var e=Ue(),t=b.identifierPrefix;if(A){var n=Ke,r=Qe;n=(r&~(1<<32-Oe(r)-1)).toString(32)+n,t=":"+t+"R"+n,n=Yn++,0<\/script>",e=e.removeChild(e.firstChild)):typeof r.is=="string"?e=o.createElement(n,{is:r.is}):(e=o.createElement(n),n==="select"&&(o=e,r.multiple?o.multiple=!0:r.size&&(o.size=r.size))):e=o.createElementNS(e,n),e[Ae]=t,e[Wn]=r,nc(e,t,!1,!1),t.stateNode=e;e:{switch(o=di(n,r),n){case"dialog":M("cancel",e),M("close",e),l=r;break;case"iframe":case"object":case"embed":M("load",e),l=r;break;case"video":case"audio":for(l=0;lan&&(t.flags|=128,r=!0,wn(i,!1),t.lanes=4194304)}else{if(!r)if(e=Jr(o),e!==null){if(t.flags|=128,r=!0,n=e.updateQueue,n!==null&&(t.updateQueue=n,t.flags|=4),wn(i,!0),i.tail===null&&i.tailMode==="hidden"&&!o.alternate&&!A)return le(t),null}else 2*K()-i.renderingStartTime>an&&n!==1073741824&&(t.flags|=128,r=!0,wn(i,!1),t.lanes=4194304);i.isBackwards?(o.sibling=t.child,t.child=o):(n=i.last,n!==null?n.sibling=o:t.child=o,i.last=o)}return i.tail!==null?(t=i.tail,i.rendering=t,i.tail=t.sibling,i.renderingStartTime=K(),t.sibling=null,n=B.current,F(B,r?n&1|2:n&1),t):(le(t),null);case 22:case 23:return Ao(),r=t.memoizedState!==null,e!==null&&e.memoizedState!==null!==r&&(t.flags|=8192),r&&t.mode&1?ve&1073741824&&(le(t),t.subtreeFlags&6&&(t.flags|=8192)):le(t),null;case 24:return null;case 25:return null}throw Error(w(156,t.tag))}function Kd(e,t){switch(So(t),t.tag){case 1:return he(t.type)&&Qr(),e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 3:return un(),U(pe),U(oe),Lo(),e=t.flags,e&65536&&!(e&128)?(t.flags=e&-65537|128,t):null;case 5:return Po(t),null;case 13:if(U(B),e=t.memoizedState,e!==null&&e.dehydrated!==null){if(t.alternate===null)throw Error(w(340));ln()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 19:return U(B),null;case 4:return un(),null;case 10:return Eo(t.type._context),null;case 22:case 23:return Ao(),null;case 24:return null;default:return null}}var xr=!1,ie=!1,Yd=typeof WeakSet=="function"?WeakSet:Set,E=null;function Xt(e,t){var n=e.ref;if(n!==null)if(typeof n=="function")try{n(null)}catch(r){W(e,t,r)}else n.current=null}function Ui(e,t,n){try{n()}catch(r){W(e,t,r)}}var Ku=!1;function Xd(e,t){if(ki=Br,e=aa(),yo(e)){if("selectionStart"in e)var n={start:e.selectionStart,end:e.selectionEnd};else e:{n=(n=e.ownerDocument)&&n.defaultView||window;var r=n.getSelection&&n.getSelection();if(r&&r.rangeCount!==0){n=r.anchorNode;var l=r.anchorOffset,i=r.focusNode;r=r.focusOffset;try{n.nodeType,i.nodeType}catch{n=null;break e}var o=0,u=-1,s=-1,d=0,g=0,h=e,m=null;t:for(;;){for(var v;h!==n||l!==0&&h.nodeType!==3||(u=o+l),h!==i||r!==0&&h.nodeType!==3||(s=o+r),h.nodeType===3&&(o+=h.nodeValue.length),(v=h.firstChild)!==null;)m=h,h=v;for(;;){if(h===e)break t;if(m===n&&++d===l&&(u=o),m===i&&++g===r&&(s=o),(v=h.nextSibling)!==null)break;h=m,m=h.parentNode}h=v}n=u===-1||s===-1?null:{start:u,end:s}}else n=null}n=n||{start:0,end:0}}else n=null;for(_i={focusedElem:e,selectionRange:n},Br=!1,E=t;E!==null;)if(t=E,e=t.child,(t.subtreeFlags&1028)!==0&&e!==null)e.return=t,E=e;else for(;E!==null;){t=E;try{var x=t.alternate;if(t.flags&1024)switch(t.tag){case 0:case 11:case 15:break;case 1:if(x!==null){var S=x.memoizedProps,I=x.memoizedState,f=t.stateNode,c=f.getSnapshotBeforeUpdate(t.elementType===t.type?S:Te(t.type,S),I);f.__reactInternalSnapshotBeforeUpdate=c}break;case 3:var p=t.stateNode.containerInfo;p.nodeType===1?p.textContent="":p.nodeType===9&&p.documentElement&&p.removeChild(p.documentElement);break;case 5:case 6:case 4:case 17:break;default:throw Error(w(163))}}catch(y){W(t,t.return,y)}if(e=t.sibling,e!==null){e.return=t.return,E=e;break}E=t.return}return x=Ku,Ku=!1,x}function Rn(e,t,n){var r=t.updateQueue;if(r=r!==null?r.lastEffect:null,r!==null){var l=r=r.next;do{if((l.tag&e)===e){var i=l.destroy;l.destroy=void 0,i!==void 0&&Ui(t,n,i)}l=l.next}while(l!==r)}}function ml(e,t){if(t=t.updateQueue,t=t!==null?t.lastEffect:null,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 Ai(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 ic(e){var t=e.alternate;t!==null&&(e.alternate=null,ic(t)),e.child=null,e.deletions=null,e.sibling=null,e.tag===5&&(t=e.stateNode,t!==null&&(delete t[Ae],delete t[Wn],delete t[ji],delete t[Td],delete t[Rd])),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 oc(e){return e.tag===5||e.tag===3||e.tag===4}function Yu(e){e:for(;;){for(;e.sibling===null;){if(e.return===null||oc(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.flags&2||e.child===null||e.tag===4)continue e;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function Bi(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=Wr));else if(r!==4&&(e=e.child,e!==null))for(Bi(e,t,n),e=e.sibling;e!==null;)Bi(e,t,n),e=e.sibling}function Vi(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(Vi(e,t,n),e=e.sibling;e!==null;)Vi(e,t,n),e=e.sibling}var ee=null,Re=!1;function et(e,t,n){for(n=n.child;n!==null;)uc(e,t,n),n=n.sibling}function uc(e,t,n){if(Be&&typeof Be.onCommitFiberUnmount=="function")try{Be.onCommitFiberUnmount(ul,n)}catch{}switch(n.tag){case 5:ie||Xt(n,t);case 6:var r=ee,l=Re;ee=null,et(e,t,n),ee=r,Re=l,ee!==null&&(Re?(e=ee,n=n.stateNode,e.nodeType===8?e.parentNode.removeChild(n):e.removeChild(n)):ee.removeChild(n.stateNode));break;case 18:ee!==null&&(Re?(e=ee,n=n.stateNode,e.nodeType===8?Wl(e.parentNode,n):e.nodeType===1&&Wl(e,n),Un(e)):Wl(ee,n.stateNode));break;case 4:r=ee,l=Re,ee=n.stateNode.containerInfo,Re=!0,et(e,t,n),ee=r,Re=l;break;case 0:case 11:case 14:case 15:if(!ie&&(r=n.updateQueue,r!==null&&(r=r.lastEffect,r!==null))){l=r=r.next;do{var i=l,o=i.destroy;i=i.tag,o!==void 0&&(i&2||i&4)&&Ui(n,t,o),l=l.next}while(l!==r)}et(e,t,n);break;case 1:if(!ie&&(Xt(n,t),r=n.stateNode,typeof r.componentWillUnmount=="function"))try{r.props=n.memoizedProps,r.state=n.memoizedState,r.componentWillUnmount()}catch(u){W(n,t,u)}et(e,t,n);break;case 21:et(e,t,n);break;case 22:n.mode&1?(ie=(r=ie)||n.memoizedState!==null,et(e,t,n),ie=r):et(e,t,n);break;default:et(e,t,n)}}function Xu(e){var t=e.updateQueue;if(t!==null){e.updateQueue=null;var n=e.stateNode;n===null&&(n=e.stateNode=new Yd),t.forEach(function(r){var l=rp.bind(null,e,r);n.has(r)||(n.add(r),r.then(l,l))})}}function Le(e,t){var n=t.deletions;if(n!==null)for(var r=0;rl&&(l=o),r&=~i}if(r=l,r=K()-r,r=(120>r?120:480>r?480:1080>r?1080:1920>r?1920:3e3>r?3e3:4320>r?4320:1960*Zd(r/1960))-r,10e?16:e,ot===null)var r=!1;else{if(e=ot,ot=null,rl=0,O&6)throw Error(w(331));var l=O;for(O|=4,E=e.current;E!==null;){var i=E,o=i.child;if(E.flags&16){var u=i.deletions;if(u!==null){for(var s=0;sK()-Mo?Nt(e,0):Fo|=n),me(e,t)}function mc(e,t){t===0&&(e.mode&1?(t=dr,dr<<=1,!(dr&130023424)&&(dr=4194304)):t=1);var n=se();e=Ze(e,t),e!==null&&(er(e,t,n),me(e,n))}function np(e){var t=e.memoizedState,n=0;t!==null&&(n=t.retryLane),mc(e,n)}function rp(e,t){var n=0;switch(e.tag){case 13:var r=e.stateNode,l=e.memoizedState;l!==null&&(n=l.retryLane);break;case 19:r=e.stateNode;break;default:throw Error(w(314))}r!==null&&r.delete(t),mc(e,n)}var gc;gc=function(e,t,n){if(e!==null)if(e.memoizedProps!==t.pendingProps||pe.current)de=!0;else{if(!(e.lanes&n)&&!(t.flags&128))return de=!1,Wd(e,t,n);de=!!(e.flags&131072)}else de=!1,A&&t.flags&1048576&&Sa(t,Xr,t.index);switch(t.lanes=0,t.tag){case 2:var r=t.type;zr(e,t),e=t.pendingProps;var l=rn(t,oe.current);en(t,n),l=Ro(null,t,r,e,l,n);var i=zo();return t.flags|=1,typeof l=="object"&&l!==null&&typeof l.render=="function"&&l.$$typeof===void 0?(t.tag=1,t.memoizedState=null,t.updateQueue=null,he(r)?(i=!0,Kr(t)):i=!1,t.memoizedState=l.state!==null&&l.state!==void 0?l.state:null,jo(t),l.updater=hl,t.stateNode=l,l._reactInternals=t,zi(t,r,e,n),t=Di(null,t,r,!0,i,n)):(t.tag=0,A&&i&&wo(t),ue(null,t,l,n),t=t.child),t;case 16:r=t.elementType;e:{switch(zr(e,t),e=t.pendingProps,l=r._init,r=l(r._payload),t.type=r,l=t.tag=ip(r),e=Te(r,e),l){case 0:t=Oi(null,t,r,e,n);break e;case 1:t=Hu(null,t,r,e,n);break e;case 11:t=Bu(null,t,r,e,n);break e;case 14:t=Vu(null,t,r,Te(r.type,e),n);break e}throw Error(w(306,r,""))}return t;case 0:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:Te(r,l),Oi(e,t,r,l,n);case 1:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:Te(r,l),Hu(e,t,r,l,n);case 3:e:{if(ba(t),e===null)throw Error(w(387));r=t.pendingProps,i=t.memoizedState,l=i.element,ja(e,t),qr(t,r,null,n);var o=t.memoizedState;if(r=o.element,i.isDehydrated)if(i={element:r,isDehydrated:!1,cache:o.cache,pendingSuspenseBoundaries:o.pendingSuspenseBoundaries,transitions:o.transitions},t.updateQueue.baseState=i,t.memoizedState=i,t.flags&256){l=sn(Error(w(423)),t),t=Wu(e,t,r,n,l);break e}else if(r!==l){l=sn(Error(w(424)),t),t=Wu(e,t,r,n,l);break e}else for(ye=ct(t.stateNode.containerInfo.firstChild),we=t,A=!0,Ie=null,n=Ea(t,null,r,n),t.child=n;n;)n.flags=n.flags&-3|4096,n=n.sibling;else{if(ln(),r===l){t=qe(e,t,n);break e}ue(e,t,r,n)}t=t.child}return t;case 5:return Na(t),e===null&&Li(t),r=t.type,l=t.pendingProps,i=e!==null?e.memoizedProps:null,o=l.children,Ei(r,l)?o=null:i!==null&&Ei(r,i)&&(t.flags|=32),Ja(e,t),ue(e,t,o,n),t.child;case 6:return e===null&&Li(t),null;case 13:return ec(e,t,n);case 4:return No(t,t.stateNode.containerInfo),r=t.pendingProps,e===null?t.child=on(t,null,r,n):ue(e,t,r,n),t.child;case 11:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:Te(r,l),Bu(e,t,r,l,n);case 7:return ue(e,t,t.pendingProps,n),t.child;case 8:return ue(e,t,t.pendingProps.children,n),t.child;case 12:return ue(e,t,t.pendingProps.children,n),t.child;case 10:e:{if(r=t.type._context,l=t.pendingProps,i=t.memoizedProps,o=l.value,F(Gr,r._currentValue),r._currentValue=o,i!==null)if($e(i.value,o)){if(i.children===l.children&&!pe.current){t=qe(e,t,n);break e}}else for(i=t.child,i!==null&&(i.return=t);i!==null;){var u=i.dependencies;if(u!==null){o=i.child;for(var s=u.firstContext;s!==null;){if(s.context===r){if(i.tag===1){s=Ye(-1,n&-n),s.tag=2;var d=i.updateQueue;if(d!==null){d=d.shared;var g=d.pending;g===null?s.next=s:(s.next=g.next,g.next=s),d.pending=s}}i.lanes|=n,s=i.alternate,s!==null&&(s.lanes|=n),Ti(i.return,n,t),u.lanes|=n;break}s=s.next}}else if(i.tag===10)o=i.type===t.type?null:i.child;else if(i.tag===18){if(o=i.return,o===null)throw Error(w(341));o.lanes|=n,u=o.alternate,u!==null&&(u.lanes|=n),Ti(o,n,t),o=i.sibling}else o=i.child;if(o!==null)o.return=i;else for(o=i;o!==null;){if(o===t){o=null;break}if(i=o.sibling,i!==null){i.return=o.return,o=i;break}o=o.return}i=o}ue(e,t,l.children,n),t=t.child}return t;case 9:return l=t.type,r=t.pendingProps.children,en(t,n),l=Ne(l),r=r(l),t.flags|=1,ue(e,t,r,n),t.child;case 14:return r=t.type,l=Te(r,t.pendingProps),l=Te(r.type,l),Vu(e,t,r,l,n);case 15:return Za(e,t,t.type,t.pendingProps,n);case 17:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:Te(r,l),zr(e,t),t.tag=1,he(r)?(e=!0,Kr(t)):e=!1,en(t,n),Ya(t,r,l),zi(t,r,l,n),Di(null,t,r,!0,e,n);case 19:return tc(e,t,n);case 22:return qa(e,t,n)}throw Error(w(156,t.tag))};function vc(e,t){return Ws(e,t)}function lp(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 Ce(e,t,n,r){return new lp(e,t,n,r)}function Vo(e){return e=e.prototype,!(!e||!e.isReactComponent)}function ip(e){if(typeof e=="function")return Vo(e)?1:0;if(e!=null){if(e=e.$$typeof,e===oo)return 11;if(e===uo)return 14}return 2}function ht(e,t){var n=e.alternate;return n===null?(n=Ce(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 Dr(e,t,n,r,l,i){var o=2;if(r=e,typeof e=="function")Vo(e)&&(o=1);else if(typeof e=="string")o=5;else e:switch(e){case Ut:return Pt(n.children,l,i,t);case io:o=8,l|=8;break;case ni:return e=Ce(12,n,t,l|2),e.elementType=ni,e.lanes=i,e;case ri:return e=Ce(13,n,t,l),e.elementType=ri,e.lanes=i,e;case li:return e=Ce(19,n,t,l),e.elementType=li,e.lanes=i,e;case Ns:return vl(n,l,i,t);default:if(typeof e=="object"&&e!==null)switch(e.$$typeof){case Cs:o=10;break e;case js:o=9;break e;case oo:o=11;break e;case uo:o=14;break e;case tt:o=16,r=null;break e}throw Error(w(130,e==null?e:typeof e,""))}return t=Ce(o,n,t,l),t.elementType=e,t.type=r,t.lanes=i,t}function Pt(e,t,n,r){return e=Ce(7,e,r,t),e.lanes=n,e}function vl(e,t,n,r){return e=Ce(22,e,r,t),e.elementType=Ns,e.lanes=n,e.stateNode={isHidden:!1},e}function Jl(e,t,n){return e=Ce(6,e,null,t),e.lanes=n,e}function bl(e,t,n){return t=Ce(4,e.children!==null?e.children:[],e.key,t),t.lanes=n,t.stateNode={containerInfo:e.containerInfo,pendingChildren:null,implementation:e.implementation},t}function op(e,t,n,r,l){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=Il(0),this.expirationTimes=Il(-1),this.entangledLanes=this.finishedLanes=this.mutableReadLanes=this.expiredLanes=this.pingedLanes=this.suspendedLanes=this.pendingLanes=0,this.entanglements=Il(0),this.identifierPrefix=r,this.onRecoverableError=l,this.mutableSourceEagerHydrationData=null}function Ho(e,t,n,r,l,i,o,u,s){return e=new op(e,t,n,u,s),t===1?(t=1,i===!0&&(t|=8)):t=0,i=Ce(3,null,null,t),e.current=i,i.stateNode=e,i.memoizedState={element:r,isDehydrated:n,cache:null,transitions:null,pendingSuspenseBoundaries:null},jo(i),e}function up(e,t,n){var r=3"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(xc)}catch(e){console.error(e)}}xc(),xs.exports=xe;var dp=xs.exports,kc,ns=dp;kc=ns.createRoot,ns.hydrateRoot;class Je extends Error{constructor(n,r){super(n);Yo(this,"status");this.name="ApiError",this.status=r}}async function $t(e){const t=await fetch(e);if(!t.ok){const n=await t.json().catch(()=>null),r=(n&&typeof n.error=="string"?n.error:null)??t.statusText;throw new Je(r,t.status)}return await t.json()}function rs(e,t={}){const n=new URLSearchParams({q:e});return t.limit&&n.set("limit",String(t.limit)),t.cursor&&n.set("cursor",t.cursor),$t(`/api/search?${n.toString()}`)}function pp(e,t={}){const n=new URLSearchParams({q:e});return t.limit&&n.set("limit",String(t.limit)),t.branch&&n.set("branch",t.branch),$t(`/api/semantic?${n.toString()}`)}function _c(){return $t("/api/semantic/status")}function hp(e,t,n){const r=new URLSearchParams({repo:e,path:t});return n&&r.set("branch",n),$t(`/api/file?${r.toString()}`)}function Ec(){return $t("/api/repos")}function mp(e,t={}){const n=new URLSearchParams({symbol:e});return t.branch&&n.set("branch",t.branch),$t(`/api/references?${n.toString()}`)}function gp(e){const t=new URLSearchParams;return e.repo&&t.set("repo",e.repo),e.target&&t.set("target",e.target),e.direction&&t.set("direction",e.direction),e.branch&&t.set("branch",e.branch),$t(`/api/imports?${t.toString()}`)}const Cc="webui-theme";function jc(){return window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light"}function Nc(){const e=window.localStorage.getItem(Cc);return e==="light"||e==="dark"?e:null}let kl=Nc()??jc();const Yi=new Set;function Pc(e){document.documentElement.setAttribute("data-theme",e)}Pc(kl);function Lc(e){kl=e,window.localStorage.setItem(Cc,e),Pc(e),Yi.forEach(t=>t())}function vp(){Lc(kl==="dark"?"light":"dark")}function yp(e){return Yi.add(e),()=>Yi.delete(e)}function Tc(){return P.useEffect(()=>{if(Nc()!==null)return;const e=window.matchMedia("(prefers-color-scheme: dark)"),t=()=>Lc(jc());return e.addEventListener("change",t),()=>e.removeEventListener("change",t)},[]),P.useSyncExternalStore(yp,()=>kl)}function wp(){const e=Tc();return a.jsx("button",{type:"button",className:"theme-toggle",onClick:vp,"aria-label":`Switch to ${e==="dark"?"light":"dark"} theme`,title:`Switch to ${e==="dark"?"light":"dark"} theme`,children:e==="dark"?"🌙":"☀️"})}const Sp="modulepreload",xp=function(e){return"/"+e},ls={},ze=function(t,n,r){let l=Promise.resolve();if(n&&n.length>0){document.getElementsByTagName("link");const o=document.querySelector("meta[property=csp-nonce]"),u=(o==null?void 0:o.nonce)||(o==null?void 0:o.getAttribute("nonce"));l=Promise.allSettled(n.map(s=>{if(s=xp(s),s in ls)return;ls[s]=!0;const d=s.endsWith(".css"),g=d?'[rel="stylesheet"]':"";if(document.querySelector(`link[href="${s}"]${g}`))return;const h=document.createElement("link");if(h.rel=d?"stylesheet":Sp,d||(h.as="script"),h.crossOrigin="",h.href=s,u&&h.setAttribute("nonce",u),document.head.appendChild(h),d)return new Promise((m,v)=>{h.addEventListener("load",m),h.addEventListener("error",()=>v(new Error(`Unable to preload CSS for ${s}`)))})}))}function i(o){const u=new Event("vite:preloadError",{cancelable:!0});if(u.payload=o,window.dispatchEvent(u),!u.defaultPrevented)throw o}return l.then(o=>{for(const u of o||[])u.status==="rejected"&&i(u.reason);return t().catch(i)})},kp="github-light",_p="github-dark",Xi={python:()=>ze(()=>import("./python-DhUJRlN_.js"),[]),javascript:()=>ze(()=>import("./javascript-ySlJ1b_l.js"),[]),typescript:()=>ze(()=>import("./typescript-Dj6nwHGl.js"),[]),tsx:()=>ze(()=>import("./tsx-B6W0miNI.js"),[]),go:()=>ze(()=>import("./go-B1SYOhNW.js"),[]),java:()=>ze(()=>import("./java-xI-RfyKK.js"),[]),rust:()=>ze(()=>import("./rust-Be6lgOlo.js"),[])};let ei=null;function Ep(){return ei||(ei=(async()=>{const[{createHighlighterCore:e},{createJavaScriptRegexEngine:t}]=await Promise.all([ze(()=>import("./core-DqGsPV3B.js"),__vite__mapDeps([0,1])),ze(()=>import("./engine-javascript-ohkglBQk.js"),__vite__mapDeps([2,1]))]);return e({themes:[ze(()=>import("./github-light-DAi9KRSo.js"),[]),ze(()=>import("./github-dark-DHJKELXO.js"),[])],langs:Object.values(Xi).map(n=>n()),engine:t()})})()),ei}async function Cp(e,t,n){try{const r=await Ep(),l=t&&t in Xi?t:"text";return l!=="text"&&!r.getLoadedLanguages().includes(l)&&await r.loadLanguage(Xi[l]()),r.codeToTokens(e,{lang:l,theme:n}).tokens.map(o=>o.map(u=>({content:u.content,color:u.color})))}catch{return null}}function jp({content:e,lang:t,targetLine:n,targetEndLine:r=null}){const l=Tc(),[i,o]=P.useState(null),u=P.useRef(null),s=l==="dark"?_p:kp;P.useEffect(()=>{let h=!1;return o(null),Cp(e,t,s).then(m=>{h||o(m)}),()=>{h=!0}},[e,t,s]),P.useEffect(()=>{var h;(h=u.current)==null||h.scrollIntoView({block:"center"})},[i,n]);const d=e.split(` +`),g=i??d.map(h=>[{content:h}]);return a.jsx("div",{className:"code-view",children:a.jsx("pre",{children:g.map((h,m)=>{const v=m+1,x=n!==null&&v>=n&&v<=(r??n);return a.jsxs("div",{id:`L${v}`,ref:v===n?u:void 0,className:`code-line${x?" target":""}`,children:[a.jsx("a",{className:"line-no",href:`#L${v}`,children:v}),a.jsx("span",{className:"line-text",children:h.map((S,I)=>a.jsx("span",{style:S.color?{color:S.color}:void 0,children:S.content},I))})]},v)})})})}function _l(){const{pathname:e,search:t,hash:n}=window.location,r=new URLSearchParams(t);if(e==="/file"){const l=n.match(/^#L(\d+)(?:-L(\d+))?$/);return{page:"file",repo:r.get("repo")??"",path:r.get("path")??"",line:l?Number(l[1]):null,endLine:l!=null&&l[2]?Number(l[2]):null,find:r.get("find"),branch:r.get("branch")}}return e==="/repos"?{page:"repos"}:e==="/semantic"?{page:"semantic",query:r.get("q")??""}:e==="/references"?{page:"graph",mode:"references",symbol:r.get("symbol")??"",branch:r.get("branch")}:e==="/imports"?{page:"graph",mode:"imports",repo:r.get("repo")??"",target:r.get("target")??"",direction:r.get("direction")??"imports",branch:r.get("branch")}:{page:"search",query:r.get("q")??""}}let El=_l();const qn=new Set;window.addEventListener("popstate",()=>{El=_l(),qn.forEach(e=>e())});function Np(e){return qn.add(e),()=>qn.delete(e)}function Pp(){return El}function Lp(e){window.history.pushState(null,"",e),El=_l(),qn.forEach(t=>t())}function Jn(e){window.history.replaceState(null,"",e),El=_l(),qn.forEach(t=>t())}function Tp(){return P.useSyncExternalStore(Np,Pp)}function Rp(){P.useEffect(()=>{function e(t){if(t.defaultPrevented||t.button!==0||t.metaKey||t.ctrlKey||t.shiftKey||t.altKey)return;const n=t.target.closest("a");if(!n||n.target||n.hasAttribute("download"))return;const r=n.getAttribute("href");!r||!r.startsWith("/")||r.startsWith("//")||(t.preventDefault(),Lp(r))}return document.addEventListener("click",e),()=>document.removeEventListener("click",e)},[])}function zp(e){let t=null;for(const n of e.split(` +`)){const r=n.trim();r.length>0&&(t===null||r.length>t.length)&&(t=r)}return t}function Ip(e,t){const n=e.indexOf(t);if(n===-1)return null;const r=e.slice(0,n).split(` +`).length;let l=1,i=e.indexOf(t,n+1);for(;i!==-1;)l+=1,i=e.indexOf(t,i+1);return{line:r,occurrences:l}}function Op(e){var r;const t=(r=e.split(".").pop())==null?void 0:r.toLowerCase();return t?{py:"python",js:"javascript",jsx:"javascript",ts:"typescript",tsx:"tsx",go:"go",java:"java",rs:"rust"}[t]??null:null}function Dp({repo:e,path:t,line:n,endLine:r,find:l,branch:i}){const[o,u]=P.useState({status:"loading"}),[s,d]=P.useState(!1),[g,h]=P.useState(null);P.useEffect(()=>{u({status:"loading"}),h(null),hp(e,t,i).then(v=>{if(u({status:"loaded",file:v}),l&&n===null&&v.content!=null){const x=Ip(v.content,l);if(x===null){h("Couldn't locate the chunk in the current file content — content may have been re-indexed.");return}const S=new URLSearchParams({repo:e,path:t});i&&S.set("branch",i),Jn(`/file?${S.toString()}#L${x.line}`),x.occurrences>1&&h(`This line appears ${x.occurrences} times — showing the first occurrence, which may not be the chunk's exact location.`)}}).catch(v=>{if(v instanceof Je&&v.status===404){u({status:"not_found"});return}const x=v instanceof Je?v.message:"Failed to load file.";u({status:"error",message:x})})},[e,t,i]);function m(){const v=new URL(window.location.href);navigator.clipboard.writeText(v.toString()).then(()=>{d(!0),setTimeout(()=>d(!1),1500)})}return o.status==="loading"?a.jsx("div",{className:"result-summary",children:"Loading…"}):o.status==="not_found"?a.jsxs("div",{className:"banner warn",children:["File not found: ",e,"/",t]}):o.status==="error"?a.jsx("div",{className:"banner error",children:o.message}):o.file.content==null?a.jsxs("div",{className:"banner warn",children:["File not found: ",e,"/",t]}):a.jsxs("div",{children:[a.jsxs("div",{className:"file-view-header",children:[a.jsxs("h2",{children:[e,"/",t]}),a.jsx("span",{className:"badge",children:o.file.branch}),o.file.commit&&a.jsx("span",{className:"badge commit-badge",children:o.file.commit.slice(0,12)}),a.jsx("button",{type:"button",className:"theme-toggle",onClick:m,children:s?"Copied!":"Copy permalink"})]}),g&&a.jsxs("div",{className:"banner warn",children:[g,a.jsx("button",{type:"button",className:"theme-toggle",onClick:()=>h(null),children:"Dismiss"})]}),a.jsx(jp,{content:o.file.content,lang:Op(t),targetLine:n,targetEndLine:r})]})}function Rc(e,t,n,r){const l=new URLSearchParams({repo:e,path:t});r&&l.set("branch",r);const i=n!=null?`#L${n}`:"";return`/file?${l.toString()}${i}`}function $p({candidate:e}){const t=[];return e.same_repo&&t.push("same repo"),e.same_file&&t.push("same file"),e.kind_match&&t.push("kind match"),t.length===0?null:a.jsx("span",{className:"signal-chips",children:t.map(n=>a.jsx("span",{className:"chip",children:n},n))})}function Fp({candidate:e,branch:t}){return a.jsxs("li",{className:"candidate-row",children:[a.jsxs("a",{href:Rc(e.repo,e.file,e.line,t),children:[e.name," ",a.jsxs("span",{className:"lang",children:["(",e.kind,")"]})]}),a.jsxs("span",{className:"candidate-location",children:[e.repo,"/",e.file,":",e.line]}),a.jsx($p,{candidate:e})]})}function Mp({site:e,branch:t}){const n=e.enclosing_symbol?`${e.enclosing_symbol.name} (${e.enclosing_symbol.kind})`:"module scope";return a.jsxs("div",{className:"result-file",children:[a.jsxs("div",{className:"result-file-header",children:[a.jsxs("a",{href:Rc(e.repo,e.file,e.line,t),children:[e.repo,"/",e.file,":",e.line]}),a.jsx("span",{className:`badge resolution-${e.resolution}`,children:e.resolution})]}),a.jsxs("div",{className:"result-summary",children:[a.jsx("code",{children:e.target_name})," in ",n]}),e.candidates.length>0&&a.jsx("ul",{className:"candidate-list",children:e.candidates.map((r,l)=>a.jsx(Fp,{candidate:r,branch:t},l))}),e.candidates_truncated&&a.jsxs("div",{className:"result-summary",children:["showing ",e.candidates.length," of ",e.candidate_count," candidates"]})]})}function zc({sites:e,siteCount:t,resolutionSummary:n,truncated:r,truncationReason:l,branch:i,emptyMessage:o}){return a.jsxs("div",{children:[a.jsxs("div",{className:"result-summary",children:[t," site",t===1?"":"s"," — ",n.unique," unique,"," ",n.ambiguous," ambiguous, ",n.unresolved," unresolved"]}),r&&a.jsxs("div",{className:"banner warn",children:["Results truncated",l?` (${l})`:"","."]}),e.length===0?a.jsx("div",{className:"result-summary",children:o}):e.map((u,s)=>a.jsx(Mp,{site:u,branch:i},s))]})}function Up(e,t,n){return e==="loading"?a.jsx("div",{className:"result-summary",children:"Searching…"}):e==="error"?a.jsx("div",{className:"banner error",children:t}):n?n.query_too_broad?a.jsx("div",{className:"banner warn",children:"Query too broad — results were cut short by the time budget."}):a.jsx(zc,{sites:n.sites,siteCount:n.site_count,resolutionSummary:n.resolution_summary,truncated:n.truncated,truncationReason:n.truncation_reason,branch:n.branch,emptyMessage:"No reference sites."}):null}function Ap(e,t,n){return e==="loading"?a.jsx("div",{className:"result-summary",children:"Searching…"}):e==="error"?a.jsx("div",{className:"banner error",children:t}):n?n.unsupported_direction!==void 0?a.jsxs("div",{className:"banner error",children:['Unsupported direction "',n.unsupported_direction,'".',n.reason?` ${n.reason}`:""]}):n.missing_repo?a.jsxs("div",{className:"banner error",children:["A repo is required.",n.reason?` ${n.reason}`:""]}):n.missing_target?a.jsxs("div",{className:"banner error",children:["A target is required.",n.reason?` ${n.reason}`:""]}):n.repo_known===!1?a.jsxs("div",{className:"banner warn",children:["No such repo: ",n.repo,"."]}):n.query_too_broad?a.jsx("div",{className:"banner warn",children:"Query too broad — results were cut short by the time budget."}):a.jsxs(a.Fragment,{children:[a.jsx("p",{className:"result-summary",children:'Import edges target the full dotted path as written, so most sites are external/stdlib and resolve "unresolved" — that is expected, not an error.'}),a.jsx(zc,{sites:n.sites,siteCount:n.site_count,resolutionSummary:n.resolution_summary,truncated:n.truncated,truncationReason:n.truncation_reason,branch:n.branch,emptyMessage:"No import sites."})]}):null}function Bp({route:e}){const t=e.mode,[n,r]=P.useState(e.mode==="references"?e.symbol:""),[l,i]=P.useState(e.mode==="imports"?e.repo:""),[o,u]=P.useState(e.mode==="imports"?e.target:""),[s,d]=P.useState(e.mode==="imports"?e.direction:"imports"),[g,h]=P.useState(e.branch??""),[m,v]=P.useState("idle"),[x,S]=P.useState(null),[I,f]=P.useState(null),[c,p]=P.useState(null),y=P.useRef(!1);async function _(k,D){if(!k.trim())return;const L=new URLSearchParams({symbol:k});D&&L.set("branch",D),Jn(`/references?${L.toString()}`),v("loading");try{const Y=await mp(k,{branch:D||null});f(Y),v("idle")}catch(Y){const ge=Y instanceof Je?Y.message:"References request failed.";S(ge),v("error")}}async function j(k,D,L,Y){const ge=new URLSearchParams;k&&ge.set("repo",k),D&&ge.set("target",D),L&&ge.set("direction",L),Y&&ge.set("branch",Y),Jn(`/imports?${ge.toString()}`),v("loading");try{const Fe=await gp({repo:k||null,target:D||null,direction:L,branch:Y||null});p(Fe),v("idle")}catch(Fe){const lr=Fe instanceof Je?Fe.message:"Imports request failed.";S(lr),v("error")}}P.useEffect(()=>{y.current||(y.current=!0,e.mode==="references"?e.symbol.trim()&&_(e.symbol,e.branch??""):(e.repo.trim()||e.target.trim())&&j(e.repo,e.target,e.direction,e.branch??""))},[]);function N(k){k.preventDefault(),t==="references"?_(n,g):j(l,o,s,g)}return a.jsxs("div",{children:[a.jsxs("form",{className:"search-box",onSubmit:N,children:[t==="references"?a.jsx("input",{type:"text",value:n,onChange:k=>r(k.target.value),placeholder:"symbol name, e.g. process","aria-label":"Symbol",autoFocus:!0}):a.jsxs(a.Fragment,{children:[a.jsxs("select",{value:s,onChange:k=>d(k.target.value),"aria-label":"Direction",children:[a.jsx("option",{value:"imports",children:"imports"}),a.jsx("option",{value:"imported_by",children:"imported_by"})]}),a.jsx("input",{type:"text",value:l,onChange:k=>i(k.target.value),placeholder:"repo, e.g. acme/widgets","aria-label":"Repo"}),a.jsx("input",{type:"text",value:o,onChange:k=>u(k.target.value),placeholder:"target dotted path, e.g. os.path","aria-label":"Target"})]}),a.jsx("input",{type:"text",value:g,onChange:k=>h(k.target.value),placeholder:"branch (optional)","aria-label":"Branch"}),a.jsx("button",{type:"submit",children:t==="references"?"Find references":"List imports"})]}),a.jsxs("p",{className:"result-summary",children:["Candidate-set results, not compiler-precise references — name-resolved over raw"," ",t==="references"?"call":"import"," edges (grep-not-LSP); ambiguity is preserved, never collapsed to one answer."]}),t==="references"?Up(m,x,I):Ap(m,x,c)]})}function Vp(){const[e,t]=P.useState({status:"loading"});return P.useEffect(()=>{Ec().then(n=>t({status:"loaded",repos:n.repos})).catch(n=>{const r=n instanceof Je?n.message:"Failed to load repositories.";t({status:"error",message:r})})},[]),e.status==="loading"?a.jsx("div",{className:"result-summary",children:"Loading…"}):e.status==="error"?a.jsx("div",{className:"banner error",children:e.message}):a.jsxs("table",{className:"repos-table",children:[a.jsx("thead",{children:a.jsxs("tr",{children:[a.jsx("th",{children:"Repository"}),a.jsx("th",{children:"Default branch"}),a.jsx("th",{children:"Last indexed commit"}),a.jsx("th",{children:"Last indexed at"})]})}),a.jsx("tbody",{children:e.repos.map(n=>a.jsxs("tr",{children:[a.jsxs("td",{children:[a.jsx("a",{href:`/?q=${encodeURIComponent(`repo:${n.name} `)}`,children:n.name})," ",a.jsx("a",{href:`/imports?repo=${encodeURIComponent(n.name)}&direction=imports`,children:"imports"})]}),a.jsx("td",{children:n.default_branch??"—"}),a.jsx("td",{children:n.last_indexed_commit?n.last_indexed_commit.slice(0,12):"—"}),a.jsx("td",{children:n.index_time?new Date(n.index_time).toLocaleString():"—"})]},n.name))})]})}const Hp=new Set([" "," ",` +`,"(",")"]),Ic={repo:"repo",file:"file",lang:"lang",sym:"sym",branch:"branch",commit:"commit"},is=new Set(["content","r","f","l","b","c","s"]),Wp=new Set([...Object.keys(Ic),"case"]);function Gi(e){return e===" "||e===" "||e===` +`}function Qp(e){return e>="a"&&e<="z"}function os(e,t){const n=e.length;let r=t;for(;rr&&os.field==="repo"),r=e.atoms.filter(s=>s.field==="lang"),l=e.atoms.filter(s=>s.field==="branch"),i=n.length===1?n[0].value:null,o=r.length===1?r[0].value:null;let u=us;if(n.length===1&&l.length<=1){const s=t.find(d=>d.name===n[0].value);s&&(u={available:!0,options:s.branches,active:l.length===1?l[0].value:null})}return{editable:!0,repoActive:i,langActive:o,branch:u}}function Yp(e){if(e.length===0)return!1;const t=e[0];if(t==="("||t===")"||t==='"'||t==="/")return!1;for(const n of e)if(Gi(n)||n==="("||n===")"||n==='"')return!1;return!0}function Xp(e){return Yp(e)?e:`"${e.replace(/"/g,'\\"')}"`}function Gp(e,t,n){const r=e.atoms.filter(t).map(l=>e.source.slice(l.start,l.end));return n!==null&&r.push(n),r.join(" ")}function Zi(e,t,n){const r=n===null?null:`${t}:${Xp(n)}`;return Gp(e,l=>l.field!==t,r)}function Zp(e,t){const n=e.atoms.filter(l=>l.field==="branch"),r=n.length===1&&n[0].value===t;return Zi(e,"branch",r?null:t)}function qp(e){const t=Oc(e);return t.safe?t.atoms.filter(n=>n.field==="commit").map(n=>n.value):[]}function Jp(e,t){return t.find(r=>e.toLowerCase().startsWith(r.toLowerCase()))??e.slice(0,12)}function bp({banners:e,query:t}){const n=[];if(e.queryParseError&&n.push({key:"parse",tone:"error",text:`Query error: ${e.queryParseError}`}),e.queryTooBroad&&n.push({key:"broad",tone:"warn",text:"Query too broad — results were cut short by the time budget."}),e.regexIncompatible&&n.push({key:"regex",tone:"warn",text:"One or more /regex/ atoms are not supported and were ignored."}),e.truncated){const r=e.truncationReason?` (${e.truncationReason})`:"";n.push({key:"truncated",tone:"warn",text:`Results truncated${r}.`})}if(e.commitNotIndexed&&n.push({key:"commit-not-indexed",tone:"warn",text:"No indexed branch at this commit."}),e.resolved.length>0){const r=qp(t);e.resolved.forEach((l,i)=>{const o=Jp(l.commit,r);n.push({key:`resolved-${i}`,tone:"info",text:`✓ ${o} → ${l.repo} @ ${l.branch} (${l.commit.slice(0,12)})`})})}return n.length===0?null:a.jsx(a.Fragment,{children:n.map(r=>a.jsx("div",{className:`banner ${r.tone}`,children:r.text},r.key))})}const ss="This query has OR/parens/quotes/regex/negation (-) -- edit the text directly.";function eh({repos:e,languages:t,chips:n,onToggleRepo:r,onToggleLanguage:l,onToggleBranch:i}){if(e.length===0&&t.length===0)return null;const o=!n.editable;return a.jsxs("div",{className:"chips",children:[e.map(u=>a.jsxs("button",{type:"button",className:`chip${n.repoActive===u.name?" active":""}`,onClick:()=>r(u.name),disabled:o,title:o?ss:void 0,children:["repo:",u.name]},`repo-${u.name}`)),t.map(u=>a.jsxs("button",{type:"button",className:`chip${n.langActive===u?" active":""}`,onClick:()=>l(u),disabled:o,title:o?ss:void 0,children:["lang:",u]},`lang-${u}`)),n.branch.available&&n.branch.options.map(u=>a.jsxs("button",{type:"button",className:`chip${n.branch.active===u?" active":""}`,onClick:()=>i(u),children:["branch:",u]},`branch-${u}`))]})}function th(e,t){const n=new TextEncoder().encode(e);if(t.length===0)return[{text:e,highlighted:!1}];const r=nh(t,n.length),l=new TextDecoder("utf-8"),i=[];let o=0;for(const[u,s]of r)u>o&&i.push({text:l.decode(n.subarray(o,u)),highlighted:!1}),i.push({text:l.decode(n.subarray(u,s)),highlighted:!0}),o=s;return o[Math.max(0,l),Math.min(t,i)]).filter(([l,i])=>i>l).sort((l,i)=>l[0]-i[0]),r=[];for(const l of n){const i=r[r.length-1];i&&l[0]<=i[1]?i[1]=Math.max(i[1],l[1]):r.push(l)}return r}function qi(e,t,n,r){const l=new URLSearchParams({repo:e,path:t});r&&l.set("branch",r);const i=n!=null?`#L${n}`:"";return`/file?${l.toString()}${i}`}function rh({repo:e,path:t,branch:n,match:r}){if(r.symbols&&r.symbols.length>0)return a.jsxs("div",{className:"result-line",children:[a.jsx("a",{className:"line-no",href:qi(e,t,r.line,n),children:r.line??""}),a.jsx("span",{className:"line-text",children:r.symbols.map((i,o)=>a.jsxs("span",{children:[a.jsx("mark",{children:i.name})," ",a.jsxs("span",{className:"lang",children:["(",i.kind,")"]})," ",a.jsx("a",{href:`/references?symbol=${encodeURIComponent(i.name)}`,children:"refs"})," "]},o))})]});const l=th(r.text,r.byte_ranges);return a.jsxs("div",{className:"result-line",children:[a.jsx("a",{className:"line-no",href:qi(e,t,r.line,n),children:r.line??""}),a.jsx("span",{className:"line-text",children:l.map((i,o)=>i.highlighted?a.jsx("mark",{children:i.text},o):a.jsx("span",{children:i.text},o))})]})}function lh({files:e}){return a.jsx("div",{children:e.map(t=>a.jsxs("div",{className:"result-file",children:[a.jsxs("div",{className:"result-file-header",children:[a.jsxs("a",{href:qi(t.repo,t.file,null,t.permalink_branch),children:[t.repo,"/",t.file]}),t.language&&a.jsx("span",{className:"lang",children:t.language}),t.commit&&a.jsx("span",{className:"commit-badge",children:t.commit.slice(0,12)})]}),t.matches.map((n,r)=>a.jsx(rh,{repo:t.repo,path:t.file,branch:t.permalink_branch,match:n},r))]},`${t.repo}:${t.file}:${t.content_sha}`))})}function ih(){return a.jsxs("details",{className:"syntax-help",children:[a.jsx("summary",{children:"Query syntax"}),a.jsx("table",{children:a.jsxs("tbody",{children:[a.jsxs("tr",{children:[a.jsx("td",{children:a.jsx("code",{children:"repo:name"})}),a.jsx("td",{children:"Limit to a repository (see the Repos page for names)."})]}),a.jsxs("tr",{children:[a.jsx("td",{children:a.jsx("code",{children:"file:pattern"})}),a.jsxs("td",{children:["Limit by file path glob/substring, e.g. ",a.jsx("code",{children:"file:*.py"}),"."]})]}),a.jsxs("tr",{children:[a.jsx("td",{children:a.jsx("code",{children:"lang:go"})}),a.jsx("td",{children:"Limit by detected language."})]}),a.jsxs("tr",{children:[a.jsx("td",{children:a.jsx("code",{children:"sym:Name"})}),a.jsx("td",{children:"Match a symbol definition (function, class, etc.) by name."})]}),a.jsxs("tr",{children:[a.jsx("td",{children:a.jsx("code",{children:"branch:name"})}),a.jsx("td",{children:"Limit to files present on a branch (exact membership, not a glob or regex). Omitted, search covers each repo's default branch."})]}),a.jsxs("tr",{children:[a.jsx("td",{children:a.jsx("code",{children:"case:yes"})}),a.jsx("td",{children:"Case-sensitive match (default is case-insensitive)."})]}),a.jsxs("tr",{children:[a.jsx("td",{children:a.jsx("code",{children:"-term"})}),a.jsxs("td",{children:["Exclude (negate) the next atom, e.g. ",a.jsx("code",{children:"-repo:acme"})," or"," ",a.jsx("code",{children:"-lang:go"}),". Binds tighter than AND; only a leading"," ",a.jsx("code",{children:"-"})," immediately before a non-space, non-",a.jsx("code",{children:")"})," character negates -- a trailing or standalone ",a.jsx("code",{children:"-"})," (as in"," ",a.jsx("code",{children:"foo -"}),") stays a literal dash. Quote it to search for a literal leading dash, e.g. ",a.jsx("code",{children:'"-foo"'}),". Not supported in semantic search."]})]}),a.jsxs("tr",{children:[a.jsx("td",{children:a.jsx("code",{children:"foo bar"})}),a.jsx("td",{children:"Space between atoms is AND."})]}),a.jsxs("tr",{children:[a.jsx("td",{children:a.jsx("code",{children:"foo or bar"})}),a.jsx("td",{children:"Boolean OR between atoms."})]}),a.jsxs("tr",{children:[a.jsx("td",{children:a.jsx("code",{children:"/regex/"})}),a.jsx("td",{children:"Regular expression content match."})]}),a.jsxs("tr",{children:[a.jsx("td",{children:a.jsx("code",{children:'"exact phrase"'})}),a.jsx("td",{children:"Quote a phrase containing spaces or special characters."})]})]})})]})}const Dc={query:"",status:"idle",files:[],fileCount:0,matchCount:0,cursor:null,hasSearched:!1,banners:{truncated:!1,truncationReason:null,queryTooBroad:!1,regexIncompatible:!1,queryParseError:null,resolved:[],commitNotIndexed:!1},error:null};function as(e){return{truncated:e.truncated,truncationReason:e.truncation_reason,queryTooBroad:e.query_too_broad,regexIncompatible:e.regex_incompatible,queryParseError:e.query_parse_error,resolved:e.resolved??[],commitNotIndexed:e.commit_not_indexed??!1}}function oh(e,t){switch(t.type){case"search_start":return{...Dc,query:t.query,status:"loading",hasSearched:!0};case"search_success":return{...e,status:"idle",files:t.payload.files,fileCount:t.payload.file_count,matchCount:t.payload.match_count,cursor:t.payload.next_cursor??null,banners:as(t.payload),error:null};case"search_error":return{...e,status:"error",error:t.error,cursor:null};case"load_more_start":return{...e,status:"loading_more"};case"load_more_success":return{...e,status:"idle",files:[...e.files,...t.payload.files],fileCount:e.fileCount+t.payload.file_count,matchCount:e.matchCount+t.payload.match_count,cursor:t.payload.next_cursor??null,banners:as(t.payload),error:null};case"load_more_error":return{...e,status:"error",error:t.error};default:return e}}function uh({initialQuery:e}){const[t,n]=P.useState(e),[r,l]=P.useReducer(oh,Dc),[i,o]=P.useState([]),u=P.useRef(!1);P.useEffect(()=>{Ec().then(f=>o(f.repos)).catch(()=>o([]))},[]);async function s(f){if(f.trim()){Jn(`/?q=${encodeURIComponent(f)}`),l({type:"search_start",query:f});try{const c=await rs(f,{cursor:null});l({type:"search_success",payload:c})}catch(c){const p=c instanceof Je?c.message:"Search request failed.";l({type:"search_error",error:p})}}}P.useEffect(()=>{u.current||(u.current=!0,e.trim()&&s(e))},[]);async function d(){if(r.cursor){l({type:"load_more_start"});try{const f=await rs(r.query,{cursor:r.cursor});l({type:"load_more_success",payload:f})}catch(f){const c=f instanceof Je?f.message:"Failed to load more results.";l({type:"load_more_error",error:c})}}}function g(f){f.preventDefault(),s(t)}const h=Oc(t),m=Kp(h,i);function v(f){if(!h.safe)return;const c=Zi(h,"repo",m.repoActive===f?null:f);n(c),s(c)}function x(f){if(!h.safe)return;const c=Zi(h,"lang",m.langActive===f?null:f);n(c),s(c)}function S(f){if(!h.safe)return;const c=Zp(h,f);n(c),s(c)}const I=Array.from(new Set(r.files.map(f=>f.language).filter(f=>!!f))).sort();return a.jsxs("div",{children:[a.jsxs("form",{className:"search-box",onSubmit:g,children:[a.jsx("input",{type:"text",value:t,onChange:f=>n(f.target.value),placeholder:'e.g. repo:myrepo lang:go "http.Handler"',"aria-label":"Search query",autoFocus:!0}),a.jsx("button",{type:"submit",children:"Search"})]}),a.jsx(ih,{}),a.jsx(eh,{repos:i,languages:I,chips:m,onToggleRepo:v,onToggleLanguage:x,onToggleBranch:S}),a.jsx(bp,{banners:r.banners,query:r.query}),r.status==="error"&&a.jsx("div",{className:"banner error",children:r.error}),r.hasSearched&&r.status!=="loading"&&a.jsxs("div",{className:"result-summary",children:[r.fileCount," file",r.fileCount===1?"":"s",", ",r.matchCount," match",r.matchCount===1?"":"es"]}),r.status==="loading"&&a.jsx("div",{className:"result-summary",children:"Searching…"}),a.jsx(lh,{files:r.files}),r.cursor&&a.jsx("button",{type:"button",className:"load-more",onClick:()=>void d(),disabled:r.status==="loading_more",children:r.status==="loading_more"?"Loading…":"Load more"})]})}function sh(e){const t=new URLSearchParams({repo:e.repo,path:e.file});if(e.start_line!==null&&e.end_line!==null){const r=e.end_line>e.start_line?`#L${e.start_line}-L${e.end_line}`:`#L${e.start_line}`;return`/file?${t.toString()}${r}`}const n=zp(e.content);return n&&t.set("find",n),`/file?${t.toString()}`}function ah({result:e}){return a.jsxs("div",{className:"chunk-card",children:[a.jsxs("div",{className:"chunk-card-header",children:[a.jsxs("a",{href:sh(e),children:[e.repo,"/",e.file]}),a.jsxs("span",{className:"lang",children:["chunk ",e.chunk_index]}),a.jsxs("span",{className:"lang",children:["score ",e.rrf_score.toFixed(4)]}),a.jsxs("span",{className:"lang",children:["sim ",e.similarity===null?"—":e.similarity.toFixed(3)]})]}),a.jsx("pre",{className:"chunk-card-body",children:e.content})]})}function ch(e,t,n,r){return e==="loading"?a.jsx("div",{className:"result-summary",children:"Searching…"}):e==="error"?a.jsx("div",{className:"banner error",children:t}):n?n.semantic_enabled===!1?a.jsxs("div",{className:"banner warn",children:["Semantic search is not enabled for this deployment.",n.reason?` ${n.reason}`:""]}):n.semantic_schema_missing?a.jsx("div",{className:"banner warn",children:n.reason}):n.query_parse_error?a.jsx("div",{className:"banner error",children:n.query_parse_error}):n.unsupported_filter?a.jsxs("div",{className:"banner error",children:[n.unsupported_filter,n.reason?` ${n.reason}`:""]}):n.nothing_to_embed?a.jsx("div",{className:"banner warn",children:n.reason??"Nothing to search -- the query has no text left to embed."}):a.jsxs(a.Fragment,{children:[a.jsxs("div",{className:"result-summary",children:[n.count," chunk",n.count===1?"":"s",", ranked by hybrid relevance"]}),n.results.map((l,i)=>a.jsx(ah,{result:l},i))]}):r===!1?a.jsx("div",{className:"banner warn",children:"Semantic search is not enabled for this deployment."}):null}function fh({initialQuery:e}){const[t,n]=P.useState(e),[r,l]=P.useState("idle"),[i,o]=P.useState(null),[u,s]=P.useState(null),[d,g]=P.useState(null),h=P.useRef(!1),m=P.useRef(!1);async function v(S){if(S.trim()){Jn(`/semantic?q=${encodeURIComponent(S)}`),l("loading");try{const I=await pp(S);o(I),l("idle")}catch(I){const f=I instanceof Je?I.message:"Semantic search request failed.";s(f),l("error")}}}P.useEffect(()=>{h.current||(h.current=!0,e.trim()&&v(e))},[]),P.useEffect(()=>{m.current||(m.current=!0,_c().then(S=>g(S.semantic_enabled)).catch(()=>{}))},[]);function x(S){S.preventDefault(),v(t)}return a.jsxs("div",{children:[a.jsxs("form",{className:"search-box",onSubmit:x,children:[a.jsx("input",{type:"text",value:t,onChange:S=>n(S.target.value),placeholder:'e.g. "how are branch filters compiled to SQL" repo:acme/widgets',"aria-label":"Semantic search query",autoFocus:!0}),a.jsx("button",{type:"submit",children:"Search"})]}),a.jsxs("p",{className:"result-summary",children:["Scope with in-query ",a.jsx("code",{children:"repo:"}),", ",a.jsx("code",{children:"file:"}),", or ",a.jsx("code",{children:"lang:"})," atoms -- the rest of the query is embedded for similarity ranking."]}),ch(r,u,i,d)]})}function dh(){Rp();const e=Tp(),[t,n]=P.useState(!1);return P.useEffect(()=>{_c().then(r=>n(r.semantic_enabled)).catch(()=>n(!1))},[]),a.jsxs("div",{className:"app-shell",children:[a.jsxs("header",{className:"app-header",children:[a.jsx("span",{className:"brand",children:"Code Search"}),a.jsxs("nav",{children:[a.jsx("a",{href:"/",children:"Search"}),t&&a.jsx("a",{href:"/semantic",children:"Semantic"}),a.jsx("a",{href:"/references",children:"Graph"}),a.jsx("a",{href:"/repos",children:"Repos"})]}),a.jsx(wp,{})]}),a.jsxs("main",{className:"app-main",children:[e.page==="search"&&a.jsx(uh,{initialQuery:e.query}),e.page==="file"&&a.jsx(Dp,{repo:e.repo,path:e.path,line:e.line,endLine:e.endLine,find:e.find,branch:e.branch}),e.page==="repos"&&a.jsx(Vp,{}),e.page==="semantic"&&a.jsx(fh,{initialQuery:e.query}),e.page==="graph"&&a.jsx(Bp,{route:e},e.mode)]})]})}const $c=document.getElementById("root");if(!$c)throw new Error("#root element not found");kc($c).render(a.jsx(P.StrictMode,{children:a.jsx(dh,{})})); diff --git a/webui/frontend/dist/assets/index-f5Q-YFH4.js b/webui/frontend/dist/assets/index-f5Q-YFH4.js deleted file mode 100644 index e767e25..0000000 --- a/webui/frontend/dist/assets/index-f5Q-YFH4.js +++ /dev/null @@ -1,46 +0,0 @@ -const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/core-DqGsPV3B.js","assets/engine-compile-V7GR9ytC.js","assets/engine-javascript-ohkglBQk.js"])))=>i.map(i=>d[i]); -var Fc=Object.defineProperty;var Mc=(e,t,n)=>t in e?Fc(e,t,{enumerable:!0,configurable:!0,writable:!0,value:n}):e[t]=n;var Yo=(e,t,n)=>Mc(e,typeof t!="symbol"?t+"":t,n);(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const l of document.querySelectorAll('link[rel="modulepreload"]'))r(l);new MutationObserver(l=>{for(const i of l)if(i.type==="childList")for(const o of i.addedNodes)o.tagName==="LINK"&&o.rel==="modulepreload"&&r(o)}).observe(document,{childList:!0,subtree:!0});function n(l){const i={};return l.integrity&&(i.integrity=l.integrity),l.referrerPolicy&&(i.referrerPolicy=l.referrerPolicy),l.crossOrigin==="use-credentials"?i.credentials="include":l.crossOrigin==="anonymous"?i.credentials="omit":i.credentials="same-origin",i}function r(l){if(l.ep)return;l.ep=!0;const i=n(l);fetch(l.href,i)}})();var cs={exports:{}},ll={},fs={exports:{}},T={};/** - * @license React - * react.production.min.js - * - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */var Jn=Symbol.for("react.element"),$c=Symbol.for("react.portal"),Uc=Symbol.for("react.fragment"),Ac=Symbol.for("react.strict_mode"),Vc=Symbol.for("react.profiler"),Bc=Symbol.for("react.provider"),Hc=Symbol.for("react.context"),Wc=Symbol.for("react.forward_ref"),Qc=Symbol.for("react.suspense"),Kc=Symbol.for("react.memo"),Yc=Symbol.for("react.lazy"),Xo=Symbol.iterator;function Xc(e){return e===null||typeof e!="object"?null:(e=Xo&&e[Xo]||e["@@iterator"],typeof e=="function"?e:null)}var ds={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},ps=Object.assign,hs={};function un(e,t,n){this.props=e,this.context=t,this.refs=hs,this.updater=n||ds}un.prototype.isReactComponent={};un.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")};un.prototype.forceUpdate=function(e){this.updater.enqueueForceUpdate(this,e,"forceUpdate")};function ms(){}ms.prototype=un.prototype;function Zi(e,t,n){this.props=e,this.context=t,this.refs=hs,this.updater=n||ds}var Ji=Zi.prototype=new ms;Ji.constructor=Zi;ps(Ji,un.prototype);Ji.isPureReactComponent=!0;var Go=Array.isArray,vs=Object.prototype.hasOwnProperty,qi={current:null},gs={key:!0,ref:!0,__self:!0,__source:!0};function ys(e,t,n){var r,l={},i=null,o=null;if(t!=null)for(r in t.ref!==void 0&&(o=t.ref),t.key!==void 0&&(i=""+t.key),t)vs.call(t,r)&&!gs.hasOwnProperty(r)&&(l[r]=t[r]);var u=arguments.length-2;if(u===1)l.children=n;else if(1>>1,Z=_[Q];if(0>>1;Ql(Cl,L))ytl(lr,Cl)?(_[Q]=lr,_[yt]=L,Q=yt):(_[Q]=Cl,_[gt]=L,Q=gt);else if(ytl(lr,L))_[Q]=lr,_[yt]=L,Q=yt;else break e}}return P}function l(_,P){var L=_.sortIndex-P.sortIndex;return L!==0?L:_.id-P.id}if(typeof performance=="object"&&typeof performance.now=="function"){var i=performance;e.unstable_now=function(){return i.now()}}else{var o=Date,u=o.now();e.unstable_now=function(){return o.now()-u}}var s=[],f=[],v=1,h=null,m=3,g=!1,x=!1,S=!1,O=typeof setTimeout=="function"?setTimeout:null,c=typeof clearTimeout=="function"?clearTimeout:null,a=typeof setImmediate<"u"?setImmediate:null;typeof navigator<"u"&&navigator.scheduling!==void 0&&navigator.scheduling.isInputPending!==void 0&&navigator.scheduling.isInputPending.bind(navigator.scheduling);function p(_){for(var P=n(f);P!==null;){if(P.callback===null)r(f);else if(P.startTime<=_)r(f),P.sortIndex=P.expirationTime,t(s,P);else break;P=n(f)}}function y(_){if(S=!1,p(_),!x)if(n(s)!==null)x=!0,El(E);else{var P=n(f);P!==null&&_l(y,P.startTime-_)}}function E(_,P){x=!1,S&&(S=!1,c(j),j=-1),g=!0;var L=m;try{for(p(P),h=n(s);h!==null&&(!(h.expirationTime>P)||_&&!je());){var Q=h.callback;if(typeof Q=="function"){h.callback=null,m=h.priorityLevel;var Z=Q(h.expirationTime<=P);P=e.unstable_now(),typeof Z=="function"?h.callback=Z:h===n(s)&&r(s),p(P)}else r(s);h=n(s)}if(h!==null)var rr=!0;else{var gt=n(f);gt!==null&&_l(y,gt.startTime-P),rr=!1}return rr}finally{h=null,m=L,g=!1}}var C=!1,N=null,j=-1,W=5,z=-1;function je(){return!(e.unstable_now()-z_||125<_?console.error("forceFrameRate takes a positive int between 0 and 125, forcing frame rates higher than 125 fps is not supported"):W=0<_?Math.floor(1e3/_):5},e.unstable_getCurrentPriorityLevel=function(){return m},e.unstable_getFirstCallbackNode=function(){return n(s)},e.unstable_next=function(_){switch(m){case 1:case 2:case 3:var P=3;break;default:P=m}var L=m;m=P;try{return _()}finally{m=L}},e.unstable_pauseExecution=function(){},e.unstable_requestPaint=function(){},e.unstable_runWithPriority=function(_,P){switch(_){case 1:case 2:case 3:case 4:case 5:break;default:_=3}var L=m;m=_;try{return P()}finally{m=L}},e.unstable_scheduleCallback=function(_,P,L){var Q=e.unstable_now();switch(typeof L=="object"&&L!==null?(L=L.delay,L=typeof L=="number"&&0Q?(_.sortIndex=L,t(f,_),n(s)===null&&_===n(f)&&(S?(c(j),j=-1):S=!0,_l(y,L-Q))):(_.sortIndex=Z,t(s,_),x||g||(x=!0,El(E))),_},e.unstable_shouldYield=je,e.unstable_wrapCallback=function(_){var P=m;return function(){var L=m;m=P;try{return _.apply(this,arguments)}finally{m=L}}}})(Es);ks.exports=Es;var of=ks.exports;/** - * @license React - * react-dom.production.min.js - * - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */var uf=R,ye=of;function w(e){for(var t="https://reactjs.org/docs/error-decoder.html?invariant="+e,n=1;n"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),bl=Object.prototype.hasOwnProperty,sf=/^[: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]*$/,Jo={},qo={};function af(e){return bl.call(qo,e)?!0:bl.call(Jo,e)?!1:sf.test(e)?qo[e]=!0:(Jo[e]=!0,!1)}function cf(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?!n.acceptsBooleans:(e=e.toLowerCase().slice(0,5),e!=="data-"&&e!=="aria-");default:return!1}}function ff(e,t,n,r){if(t===null||typeof t>"u"||cf(e,t,n,r))return!0;if(r)return!1;if(n!==null)switch(n.type){case 3:return!t;case 4:return t===!1;case 5:return isNaN(t);case 6:return isNaN(t)||1>t}return!1}function ae(e,t,n,r,l,i,o){this.acceptsBooleans=t===2||t===3||t===4,this.attributeName=r,this.attributeNamespace=l,this.mustUseProperty=n,this.propertyName=e,this.type=t,this.sanitizeURL=i,this.removeEmptyString=o}var te={};"children dangerouslySetInnerHTML defaultValue defaultChecked innerHTML suppressContentEditableWarning suppressHydrationWarning style".split(" ").forEach(function(e){te[e]=new ae(e,0,!1,e,null,!1,!1)});[["acceptCharset","accept-charset"],["className","class"],["htmlFor","for"],["httpEquiv","http-equiv"]].forEach(function(e){var t=e[0];te[t]=new ae(t,1,!1,e[1],null,!1,!1)});["contentEditable","draggable","spellCheck","value"].forEach(function(e){te[e]=new ae(e,2,!1,e.toLowerCase(),null,!1,!1)});["autoReverse","externalResourcesRequired","focusable","preserveAlpha"].forEach(function(e){te[e]=new ae(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){te[e]=new ae(e,3,!1,e.toLowerCase(),null,!1,!1)});["checked","multiple","muted","selected"].forEach(function(e){te[e]=new ae(e,3,!0,e,null,!1,!1)});["capture","download"].forEach(function(e){te[e]=new ae(e,4,!1,e,null,!1,!1)});["cols","rows","size","span"].forEach(function(e){te[e]=new ae(e,6,!1,e,null,!1,!1)});["rowSpan","start"].forEach(function(e){te[e]=new ae(e,5,!1,e.toLowerCase(),null,!1,!1)});var eo=/[\-:]([a-z])/g;function to(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(eo,to);te[t]=new ae(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(eo,to);te[t]=new ae(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(eo,to);te[t]=new ae(t,1,!1,e,"http://www.w3.org/XML/1998/namespace",!1,!1)});["tabIndex","crossOrigin"].forEach(function(e){te[e]=new ae(e,1,!1,e.toLowerCase(),null,!1,!1)});te.xlinkHref=new ae("xlinkHref",1,!1,"xlink:href","http://www.w3.org/1999/xlink",!0,!1);["src","href","action","formAction"].forEach(function(e){te[e]=new ae(e,1,!1,e.toLowerCase(),null,!0,!0)});function no(e,t,n,r){var l=te.hasOwnProperty(t)?te[t]:null;(l!==null?l.type!==0:r||!(2u||l[o]!==i[u]){var s=` -`+l[o].replace(" at new "," at ");return e.displayName&&s.includes("")&&(s=s.replace("",e.displayName)),s}while(1<=o&&0<=u);break}}}finally{Pl=!1,Error.prepareStackTrace=n}return(e=e?e.displayName||e.name:"")?Sn(e):""}function df(e){switch(e.tag){case 5:return Sn(e.type);case 16:return Sn("Lazy");case 13:return Sn("Suspense");case 19:return Sn("SuspenseList");case 0:case 2:case 15:return e=Ll(e.type,!1),e;case 11:return e=Ll(e.type.render,!1),e;case 1:return e=Ll(e.type,!0),e;default:return""}}function ri(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 Ft:return"Fragment";case Dt:return"Portal";case ei:return"Profiler";case ro:return"StrictMode";case ti:return"Suspense";case ni:return"SuspenseList"}if(typeof e=="object")switch(e.$$typeof){case Ns:return(e.displayName||"Context")+".Consumer";case Cs:return(e._context.displayName||"Context")+".Provider";case lo:var t=e.render;return e=e.displayName,e||(e=t.displayName||t.name||"",e=e!==""?"ForwardRef("+e+")":"ForwardRef"),e;case io:return t=e.displayName||null,t!==null?t:ri(e.type)||"Memo";case qe:t=e._payload,e=e._init;try{return ri(e(t))}catch{}}return null}function pf(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("+e+")":"ForwardRef");case 7:return"Fragment";case 5:return t;case 4:return"Portal";case 3:return"Root";case 6:return"Text";case 16:return ri(t);case 8:return t===ro?"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 dt(e){switch(typeof e){case"boolean":case"number":case"string":case"undefined":return e;case"object":return e;default:return""}}function Ps(e){var t=e.type;return(e=e.nodeName)&&e.toLowerCase()==="input"&&(t==="checkbox"||t==="radio")}function hf(e){var t=Ps(e)?"checked":"value",n=Object.getOwnPropertyDescriptor(e.constructor.prototype,t),r=""+e[t];if(!e.hasOwnProperty(t)&&typeof n<"u"&&typeof n.get=="function"&&typeof n.set=="function"){var l=n.get,i=n.set;return Object.defineProperty(e,t,{configurable:!0,get:function(){return l.call(this)},set:function(o){r=""+o,i.call(this,o)}}),Object.defineProperty(e,t,{enumerable:n.enumerable}),{getValue:function(){return r},setValue:function(o){r=""+o},stopTracking:function(){e._valueTracker=null,delete e[t]}}}}function ur(e){e._valueTracker||(e._valueTracker=hf(e))}function Ls(e){if(!e)return!1;var t=e._valueTracker;if(!t)return!0;var n=t.getValue(),r="";return e&&(r=Ps(e)?e.checked?"true":"false":e.value),e=r,e!==n?(t.setValue(e),!0):!1}function Ir(e){if(e=e||(typeof document<"u"?document:void 0),typeof e>"u")return null;try{return e.activeElement||e.body}catch{return e.body}}function li(e,t){var n=t.checked;return B({},t,{defaultChecked:void 0,defaultValue:void 0,value:void 0,checked:n??e._wrapperState.initialChecked})}function eu(e,t){var n=t.defaultValue==null?"":t.defaultValue,r=t.checked!=null?t.checked:t.defaultChecked;n=dt(t.value!=null?t.value:n),e._wrapperState={initialChecked:r,initialValue:n,controlled:t.type==="checkbox"||t.type==="radio"?t.checked!=null:t.value!=null}}function Ts(e,t){t=t.checked,t!=null&&no(e,"checked",t,!1)}function ii(e,t){Ts(e,t);var n=dt(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")?oi(e,t.type,n):t.hasOwnProperty("defaultValue")&&oi(e,t.type,dt(t.defaultValue)),t.checked==null&&t.defaultChecked!=null&&(e.defaultChecked=!!t.defaultChecked)}function tu(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 oi(e,t,n){(t!=="number"||Ir(e.ownerDocument)!==e)&&(n==null?e.defaultValue=""+e._wrapperState.initialValue:e.defaultValue!==""+n&&(e.defaultValue=""+n))}var xn=Array.isArray;function Yt(e,t,n,r){if(e=e.options,t){t={};for(var l=0;l"+t.valueOf().toString()+"",t=sr.firstChild;e.firstChild;)e.removeChild(e.firstChild);for(;t.firstChild;)e.appendChild(t.firstChild)}});function In(e,t){if(t){var n=e.firstChild;if(n&&n===e.lastChild&&n.nodeType===3){n.nodeValue=t;return}}e.textContent=t}var _n={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},mf=["Webkit","ms","Moz","O"];Object.keys(_n).forEach(function(e){mf.forEach(function(t){t=t+e.charAt(0).toUpperCase()+e.substring(1),_n[t]=_n[e]})});function Is(e,t,n){return t==null||typeof t=="boolean"||t===""?"":n||typeof t!="number"||t===0||_n.hasOwnProperty(e)&&_n[e]?(""+t).trim():t+"px"}function Ds(e,t){e=e.style;for(var n in t)if(t.hasOwnProperty(n)){var r=n.indexOf("--")===0,l=Is(n,t[n],r);n==="float"&&(n="cssFloat"),r?e.setProperty(n,l):e[n]=l}}var vf=B({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 ai(e,t){if(t){if(vf[e]&&(t.children!=null||t.dangerouslySetInnerHTML!=null))throw Error(w(137,e));if(t.dangerouslySetInnerHTML!=null){if(t.children!=null)throw Error(w(60));if(typeof t.dangerouslySetInnerHTML!="object"||!("__html"in t.dangerouslySetInnerHTML))throw Error(w(61))}if(t.style!=null&&typeof t.style!="object")throw Error(w(62))}}function ci(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 fi=null;function oo(e){return e=e.target||e.srcElement||window,e.correspondingUseElement&&(e=e.correspondingUseElement),e.nodeType===3?e.parentNode:e}var di=null,Xt=null,Gt=null;function lu(e){if(e=er(e)){if(typeof di!="function")throw Error(w(280));var t=e.stateNode;t&&(t=al(t),di(e.stateNode,e.type,t))}}function Fs(e){Xt?Gt?Gt.push(e):Gt=[e]:Xt=e}function Ms(){if(Xt){var e=Xt,t=Gt;if(Gt=Xt=null,lu(e),t)for(e=0;e>>=0,e===0?32:31-(jf(e)/Pf|0)|0}var ar=64,cr=4194304;function kn(e){switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return e&4194240;case 4194304:case 8388608:case 16777216:case 33554432:case 67108864:return e&130023424;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 1073741824;default:return e}}function $r(e,t){var n=e.pendingLanes;if(n===0)return 0;var r=0,l=e.suspendedLanes,i=e.pingedLanes,o=n&268435455;if(o!==0){var u=o&~l;u!==0?r=kn(u):(i&=o,i!==0&&(r=kn(i)))}else o=n&~l,o!==0?r=kn(o):i!==0&&(r=kn(i));if(r===0)return 0;if(t!==0&&t!==r&&!(t&l)&&(l=r&-r,i=t&-t,l>=i||l===16&&(i&4194240)!==0))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 qn(e,t,n){e.pendingLanes|=t,t!==536870912&&(e.suspendedLanes=0,e.pingedLanes=0),e=e.eventTimes,t=31-Oe(t),e[t]=n}function Rf(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=Nn),pu=" ",hu=!1;function ra(e,t){switch(e){case"keyup":return od.indexOf(t.keyCode)!==-1;case"keydown":return t.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function la(e){return e=e.detail,typeof e=="object"&&"data"in e?e.data:null}var Mt=!1;function sd(e,t){switch(e){case"compositionend":return la(t);case"keypress":return t.which!==32?null:(hu=!0,pu);case"textInput":return e=t.data,e===pu&&hu?null:e;default:return null}}function ad(e,t){if(Mt)return e==="compositionend"||!mo&&ra(e,t)?(e=ta(),Cr=fo=nt=null,Mt=!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}e:{for(;n;){if(n.nextSibling){n=n.nextSibling;break e}n=n.parentNode}n=void 0}n=yu(n)}}function sa(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?sa(e,t.parentNode):"contains"in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function aa(){for(var e=window,t=Ir();t instanceof e.HTMLIFrameElement;){try{var n=typeof t.contentWindow.location.href=="string"}catch{n=!1}if(n)e=t.contentWindow;else break;t=Ir(e.document)}return t}function vo(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 yd(e){var t=aa(),n=e.focusedElem,r=e.selectionRange;if(t!==n&&n&&n.ownerDocument&&sa(n.ownerDocument.documentElement,n)){if(r!==null&&vo(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 l=n.textContent.length,i=Math.min(r.start,l);r=r.end===void 0?i:Math.min(r.end,l),!e.extend&&i>r&&(l=r,r=i,i=l),l=wu(n,i);var o=wu(n,r);l&&o&&(e.rangeCount!==1||e.anchorNode!==l.node||e.anchorOffset!==l.offset||e.focusNode!==o.node||e.focusOffset!==o.offset)&&(t=t.createRange(),t.setStart(l.node,l.offset),e.removeAllRanges(),i>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,$t=null,yi=null,Pn=null,wi=!1;function Su(e,t,n){var r=n.window===n?n.document:n.nodeType===9?n:n.ownerDocument;wi||$t==null||$t!==Ir(r)||(r=$t,"selectionStart"in r&&vo(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}),Pn&&An(Pn,r)||(Pn=r,r=Vr(yi,"onSelect"),0Vt||(e.current=Ci[Vt],Ci[Vt]=null,Vt--)}function F(e,t){Vt++,Ci[Vt]=e.current,e.current=t}var pt={},ie=mt(pt),de=mt(!1),Nt=pt;function en(e,t){var n=e.type.contextTypes;if(!n)return pt;var r=e.stateNode;if(r&&r.__reactInternalMemoizedUnmaskedChildContext===t)return r.__reactInternalMemoizedMaskedChildContext;var l={},i;for(i in n)l[i]=t[i];return r&&(e=e.stateNode,e.__reactInternalMemoizedUnmaskedChildContext=t,e.__reactInternalMemoizedMaskedChildContext=l),l}function pe(e){return e=e.childContextTypes,e!=null}function Hr(){$(de),$(ie)}function ju(e,t,n){if(ie.current!==pt)throw Error(w(168));F(ie,t),F(de,n)}function ya(e,t,n){var r=e.stateNode;if(t=t.childContextTypes,typeof r.getChildContext!="function")return n;r=r.getChildContext();for(var l in r)if(!(l in t))throw Error(w(108,pf(e)||"Unknown",l));return B({},n,r)}function Wr(e){return e=(e=e.stateNode)&&e.__reactInternalMemoizedMergedChildContext||pt,Nt=ie.current,F(ie,e),F(de,de.current),!0}function Pu(e,t,n){var r=e.stateNode;if(!r)throw Error(w(169));n?(e=ya(e,t,Nt),r.__reactInternalMemoizedMergedChildContext=e,$(de),$(ie),F(ie,e)):$(de),F(de,n)}var Be=null,cl=!1,Hl=!1;function wa(e){Be===null?Be=[e]:Be.push(e)}function Td(e){cl=!0,wa(e)}function vt(){if(!Hl&&Be!==null){Hl=!0;var e=0,t=D;try{var n=Be;for(D=1;e>=o,l-=o,He=1<<32-Oe(t)+l|n<j?(W=N,N=null):W=N.sibling;var z=m(c,N,p[j],y);if(z===null){N===null&&(N=W);break}e&&N&&z.alternate===null&&t(c,N),a=i(z,a,j),C===null?E=z:C.sibling=z,C=z,N=W}if(j===p.length)return n(c,N),U&&wt(c,j),E;if(N===null){for(;jj?(W=N,N=null):W=N.sibling;var je=m(c,N,z.value,y);if(je===null){N===null&&(N=W);break}e&&N&&je.alternate===null&&t(c,N),a=i(je,a,j),C===null?E=je:C.sibling=je,C=je,N=W}if(z.done)return n(c,N),U&&wt(c,j),E;if(N===null){for(;!z.done;j++,z=p.next())z=h(c,z.value,y),z!==null&&(a=i(z,a,j),C===null?E=z:C.sibling=z,C=z);return U&&wt(c,j),E}for(N=r(c,N);!z.done;j++,z=p.next())z=g(N,c,j,z.value,y),z!==null&&(e&&z.alternate!==null&&N.delete(z.key===null?j:z.key),a=i(z,a,j),C===null?E=z:C.sibling=z,C=z);return e&&N.forEach(function(cn){return t(c,cn)}),U&&wt(c,j),E}function O(c,a,p,y){if(typeof p=="object"&&p!==null&&p.type===Ft&&p.key===null&&(p=p.props.children),typeof p=="object"&&p!==null){switch(p.$$typeof){case or:e:{for(var E=p.key,C=a;C!==null;){if(C.key===E){if(E=p.type,E===Ft){if(C.tag===7){n(c,C.sibling),a=l(C,p.props.children),a.return=c,c=a;break e}}else if(C.elementType===E||typeof E=="object"&&E!==null&&E.$$typeof===qe&&zu(E)===C.type){n(c,C.sibling),a=l(C,p.props),a.ref=gn(c,C,p),a.return=c,c=a;break e}n(c,C);break}else t(c,C);C=C.sibling}p.type===Ft?(a=Ct(p.props.children,c.mode,y,p.key),a.return=c,c=a):(y=Or(p.type,p.key,p.props,null,c.mode,y),y.ref=gn(c,a,p),y.return=c,c=y)}return o(c);case Dt:e:{for(C=p.key;a!==null;){if(a.key===C)if(a.tag===4&&a.stateNode.containerInfo===p.containerInfo&&a.stateNode.implementation===p.implementation){n(c,a.sibling),a=l(a,p.children||[]),a.return=c,c=a;break e}else{n(c,a);break}else t(c,a);a=a.sibling}a=Jl(p,c.mode,y),a.return=c,c=a}return o(c);case qe:return C=p._init,O(c,a,C(p._payload),y)}if(xn(p))return x(c,a,p,y);if(dn(p))return S(c,a,p,y);gr(c,p)}return typeof p=="string"&&p!==""||typeof p=="number"?(p=""+p,a!==null&&a.tag===6?(n(c,a.sibling),a=l(a,p),a.return=c,c=a):(n(c,a),a=Zl(p,c.mode,y),a.return=c,c=a),o(c)):n(c,a)}return O}var nn=Ea(!0),_a=Ea(!1),Yr=mt(null),Xr=null,Wt=null,So=null;function xo(){So=Wt=Xr=null}function ko(e){var t=Yr.current;$(Yr),e._currentValue=t}function Pi(e,t,n){for(;e!==null;){var r=e.alternate;if((e.childLanes&t)!==t?(e.childLanes|=t,r!==null&&(r.childLanes|=t)):r!==null&&(r.childLanes&t)!==t&&(r.childLanes|=t),e===n)break;e=e.return}}function Jt(e,t){Xr=e,So=Wt=null,e=e.dependencies,e!==null&&e.firstContext!==null&&(e.lanes&t&&(fe=!0),e.firstContext=null)}function Ce(e){var t=e._currentValue;if(So!==e)if(e={context:e,memoizedValue:t,next:null},Wt===null){if(Xr===null)throw Error(w(308));Wt=e,Xr.dependencies={lanes:0,firstContext:e}}else Wt=Wt.next=e;return t}var kt=null;function Eo(e){kt===null?kt=[e]:kt.push(e)}function Ca(e,t,n,r){var l=t.interleaved;return l===null?(n.next=n,Eo(t)):(n.next=l.next,l.next=n),t.interleaved=n,Xe(e,r)}function Xe(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 be=!1;function _o(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,interleaved:null,lanes:0},effects:null}}function Na(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 Qe(e,t){return{eventTime:e,lane:t,tag:0,payload:null,callback:null,next:null}}function st(e,t,n){var r=e.updateQueue;if(r===null)return null;if(r=r.shared,I&2){var l=r.pending;return l===null?t.next=t:(t.next=l.next,l.next=t),r.pending=t,Xe(e,n)}return l=r.interleaved,l===null?(t.next=t,Eo(r)):(t.next=l.next,l.next=t),r.interleaved=t,Xe(e,n)}function jr(e,t,n){if(t=t.updateQueue,t!==null&&(t=t.shared,(n&4194240)!==0)){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,so(e,n)}}function Ru(e,t){var n=e.updateQueue,r=e.alternate;if(r!==null&&(r=r.updateQueue,n===r)){var l=null,i=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};i===null?l=i=o:i=i.next=o,n=n.next}while(n!==null);i===null?l=i=t:i=i.next=t}else l=i=t;n={baseState:r.baseState,firstBaseUpdate:l,lastBaseUpdate:i,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 Gr(e,t,n,r){var l=e.updateQueue;be=!1;var i=l.firstBaseUpdate,o=l.lastBaseUpdate,u=l.shared.pending;if(u!==null){l.shared.pending=null;var s=u,f=s.next;s.next=null,o===null?i=f:o.next=f,o=s;var v=e.alternate;v!==null&&(v=v.updateQueue,u=v.lastBaseUpdate,u!==o&&(u===null?v.firstBaseUpdate=f:u.next=f,v.lastBaseUpdate=s))}if(i!==null){var h=l.baseState;o=0,v=f=s=null,u=i;do{var m=u.lane,g=u.eventTime;if((r&m)===m){v!==null&&(v=v.next={eventTime:g,lane:0,tag:u.tag,payload:u.payload,callback:u.callback,next:null});e:{var x=e,S=u;switch(m=t,g=n,S.tag){case 1:if(x=S.payload,typeof x=="function"){h=x.call(g,h,m);break e}h=x;break e;case 3:x.flags=x.flags&-65537|128;case 0:if(x=S.payload,m=typeof x=="function"?x.call(g,h,m):x,m==null)break e;h=B({},h,m);break e;case 2:be=!0}}u.callback!==null&&u.lane!==0&&(e.flags|=64,m=l.effects,m===null?l.effects=[u]:m.push(u))}else g={eventTime:g,lane:m,tag:u.tag,payload:u.payload,callback:u.callback,next:null},v===null?(f=v=g,s=h):v=v.next=g,o|=m;if(u=u.next,u===null){if(u=l.shared.pending,u===null)break;m=u,u=m.next,m.next=null,l.lastBaseUpdate=m,l.shared.pending=null}}while(!0);if(v===null&&(s=h),l.baseState=s,l.firstBaseUpdate=f,l.lastBaseUpdate=v,t=l.shared.interleaved,t!==null){l=t;do o|=l.lane,l=l.next;while(l!==t)}else i===null&&(l.shared.lanes=0);Lt|=o,e.lanes=o,e.memoizedState=h}}function Ou(e,t,n){if(e=t.effects,t.effects=null,e!==null)for(t=0;tn?n:4,e(!0);var r=Ql.transition;Ql.transition={};try{e(!1),t()}finally{D=n,Ql.transition=r}}function Ha(){return Ne().memoizedState}function Id(e,t,n){var r=ct(e);if(n={lane:r,action:n,hasEagerState:!1,eagerState:null,next:null},Wa(e))Qa(t,n);else if(n=Ca(e,t,n,r),n!==null){var l=ue();Ie(n,e,r,l),Ka(n,t,r)}}function Dd(e,t,n){var r=ct(e),l={lane:r,action:n,hasEagerState:!1,eagerState:null,next:null};if(Wa(e))Qa(t,l);else{var i=e.alternate;if(e.lanes===0&&(i===null||i.lanes===0)&&(i=t.lastRenderedReducer,i!==null))try{var o=t.lastRenderedState,u=i(o,n);if(l.hasEagerState=!0,l.eagerState=u,De(u,o)){var s=t.interleaved;s===null?(l.next=l,Eo(t)):(l.next=s.next,s.next=l),t.interleaved=l;return}}catch{}finally{}n=Ca(e,t,l,r),n!==null&&(l=ue(),Ie(n,e,r,l),Ka(n,t,r))}}function Wa(e){var t=e.alternate;return e===V||t!==null&&t===V}function Qa(e,t){Ln=Jr=!0;var n=e.pending;n===null?t.next=t:(t.next=n.next,n.next=t),e.pending=t}function Ka(e,t,n){if(n&4194240){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,so(e,n)}}var qr={readContext:Ce,useCallback:ne,useContext:ne,useEffect:ne,useImperativeHandle:ne,useInsertionEffect:ne,useLayoutEffect:ne,useMemo:ne,useReducer:ne,useRef:ne,useState:ne,useDebugValue:ne,useDeferredValue:ne,useTransition:ne,useMutableSource:ne,useSyncExternalStore:ne,useId:ne,unstable_isNewReconciler:!1},Fd={readContext:Ce,useCallback:function(e,t){return Me().memoizedState=[e,t===void 0?null:t],e},useContext:Ce,useEffect:Du,useImperativeHandle:function(e,t,n){return n=n!=null?n.concat([e]):null,Lr(4194308,4,$a.bind(null,t,e),n)},useLayoutEffect:function(e,t){return Lr(4194308,4,e,t)},useInsertionEffect:function(e,t){return Lr(4,2,e,t)},useMemo:function(e,t){var n=Me();return t=t===void 0?null:t,e=e(),n.memoizedState=[e,t],e},useReducer:function(e,t,n){var r=Me();return t=n!==void 0?n(t):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=Id.bind(null,V,e),[r.memoizedState,e]},useRef:function(e){var t=Me();return e={current:e},t.memoizedState=e},useState:Iu,useDebugValue:Ro,useDeferredValue:function(e){return Me().memoizedState=e},useTransition:function(){var e=Iu(!1),t=e[0];return e=Od.bind(null,e[1]),Me().memoizedState=e,[t,e]},useMutableSource:function(){},useSyncExternalStore:function(e,t,n){var r=V,l=Me();if(U){if(n===void 0)throw Error(w(407));n=n()}else{if(n=t(),q===null)throw Error(w(349));Pt&30||Ta(r,t,n)}l.memoizedState=n;var i={value:n,getSnapshot:t};return l.queue=i,Du(Ra.bind(null,r,i,e),[e]),r.flags|=2048,Xn(9,za.bind(null,r,i,n,t),void 0,null),n},useId:function(){var e=Me(),t=q.identifierPrefix;if(U){var n=We,r=He;n=(r&~(1<<32-Oe(r)-1)).toString(32)+n,t=":"+t+"R"+n,n=Kn++,0<\/script>",e=e.removeChild(e.firstChild)):typeof r.is=="string"?e=o.createElement(n,{is:r.is}):(e=o.createElement(n),n==="select"&&(o=e,r.multiple?o.multiple=!0:r.size&&(o.size=r.size))):e=o.createElementNS(e,n),e[$e]=t,e[Hn]=r,nc(e,t,!1,!1),t.stateNode=e;e:{switch(o=ci(n,r),n){case"dialog":M("cancel",e),M("close",e),l=r;break;case"iframe":case"object":case"embed":M("load",e),l=r;break;case"video":case"audio":for(l=0;lon&&(t.flags|=128,r=!0,yn(i,!1),t.lanes=4194304)}else{if(!r)if(e=Zr(o),e!==null){if(t.flags|=128,r=!0,n=e.updateQueue,n!==null&&(t.updateQueue=n,t.flags|=4),yn(i,!0),i.tail===null&&i.tailMode==="hidden"&&!o.alternate&&!U)return re(t),null}else 2*K()-i.renderingStartTime>on&&n!==1073741824&&(t.flags|=128,r=!0,yn(i,!1),t.lanes=4194304);i.isBackwards?(o.sibling=t.child,t.child=o):(n=i.last,n!==null?n.sibling=o:t.child=o,i.last=o)}return i.tail!==null?(t=i.tail,i.rendering=t,i.tail=t.sibling,i.renderingStartTime=K(),t.sibling=null,n=A.current,F(A,r?n&1|2:n&1),t):(re(t),null);case 22:case 23:return $o(),r=t.memoizedState!==null,e!==null&&e.memoizedState!==null!==r&&(t.flags|=8192),r&&t.mode&1?me&1073741824&&(re(t),t.subtreeFlags&6&&(t.flags|=8192)):re(t),null;case 24:return null;case 25:return null}throw Error(w(156,t.tag))}function Wd(e,t){switch(yo(t),t.tag){case 1:return pe(t.type)&&Hr(),e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 3:return rn(),$(de),$(ie),jo(),e=t.flags,e&65536&&!(e&128)?(t.flags=e&-65537|128,t):null;case 5:return No(t),null;case 13:if($(A),e=t.memoizedState,e!==null&&e.dehydrated!==null){if(t.alternate===null)throw Error(w(340));tn()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 19:return $(A),null;case 4:return rn(),null;case 10:return ko(t.type._context),null;case 22:case 23:return $o(),null;case 24:return null;default:return null}}var wr=!1,le=!1,Qd=typeof WeakSet=="function"?WeakSet:Set,k=null;function Qt(e,t){var n=e.ref;if(n!==null)if(typeof n=="function")try{n(null)}catch(r){H(e,t,r)}else n.current=null}function Mi(e,t,n){try{n()}catch(r){H(e,t,r)}}var Ku=!1;function Kd(e,t){if(Si=Ur,e=aa(),vo(e)){if("selectionStart"in e)var n={start:e.selectionStart,end:e.selectionEnd};else e:{n=(n=e.ownerDocument)&&n.defaultView||window;var r=n.getSelection&&n.getSelection();if(r&&r.rangeCount!==0){n=r.anchorNode;var l=r.anchorOffset,i=r.focusNode;r=r.focusOffset;try{n.nodeType,i.nodeType}catch{n=null;break e}var o=0,u=-1,s=-1,f=0,v=0,h=e,m=null;t:for(;;){for(var g;h!==n||l!==0&&h.nodeType!==3||(u=o+l),h!==i||r!==0&&h.nodeType!==3||(s=o+r),h.nodeType===3&&(o+=h.nodeValue.length),(g=h.firstChild)!==null;)m=h,h=g;for(;;){if(h===e)break t;if(m===n&&++f===l&&(u=o),m===i&&++v===r&&(s=o),(g=h.nextSibling)!==null)break;h=m,m=h.parentNode}h=g}n=u===-1||s===-1?null:{start:u,end:s}}else n=null}n=n||{start:0,end:0}}else n=null;for(xi={focusedElem:e,selectionRange:n},Ur=!1,k=t;k!==null;)if(t=k,e=t.child,(t.subtreeFlags&1028)!==0&&e!==null)e.return=t,k=e;else for(;k!==null;){t=k;try{var x=t.alternate;if(t.flags&1024)switch(t.tag){case 0:case 11:case 15:break;case 1:if(x!==null){var S=x.memoizedProps,O=x.memoizedState,c=t.stateNode,a=c.getSnapshotBeforeUpdate(t.elementType===t.type?S:Le(t.type,S),O);c.__reactInternalSnapshotBeforeUpdate=a}break;case 3:var p=t.stateNode.containerInfo;p.nodeType===1?p.textContent="":p.nodeType===9&&p.documentElement&&p.removeChild(p.documentElement);break;case 5:case 6:case 4:case 17:break;default:throw Error(w(163))}}catch(y){H(t,t.return,y)}if(e=t.sibling,e!==null){e.return=t.return,k=e;break}k=t.return}return x=Ku,Ku=!1,x}function Tn(e,t,n){var r=t.updateQueue;if(r=r!==null?r.lastEffect:null,r!==null){var l=r=r.next;do{if((l.tag&e)===e){var i=l.destroy;l.destroy=void 0,i!==void 0&&Mi(t,n,i)}l=l.next}while(l!==r)}}function pl(e,t){if(t=t.updateQueue,t=t!==null?t.lastEffect:null,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 $i(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 ic(e){var t=e.alternate;t!==null&&(e.alternate=null,ic(t)),e.child=null,e.deletions=null,e.sibling=null,e.tag===5&&(t=e.stateNode,t!==null&&(delete t[$e],delete t[Hn],delete t[_i],delete t[Pd],delete t[Ld])),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 oc(e){return e.tag===5||e.tag===3||e.tag===4}function Yu(e){e:for(;;){for(;e.sibling===null;){if(e.return===null||oc(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.flags&2||e.child===null||e.tag===4)continue e;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function Ui(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=Br));else if(r!==4&&(e=e.child,e!==null))for(Ui(e,t,n),e=e.sibling;e!==null;)Ui(e,t,n),e=e.sibling}function Ai(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(Ai(e,t,n),e=e.sibling;e!==null;)Ai(e,t,n),e=e.sibling}var b=null,Te=!1;function Je(e,t,n){for(n=n.child;n!==null;)uc(e,t,n),n=n.sibling}function uc(e,t,n){if(Ue&&typeof Ue.onCommitFiberUnmount=="function")try{Ue.onCommitFiberUnmount(il,n)}catch{}switch(n.tag){case 5:le||Qt(n,t);case 6:var r=b,l=Te;b=null,Je(e,t,n),b=r,Te=l,b!==null&&(Te?(e=b,n=n.stateNode,e.nodeType===8?e.parentNode.removeChild(n):e.removeChild(n)):b.removeChild(n.stateNode));break;case 18:b!==null&&(Te?(e=b,n=n.stateNode,e.nodeType===8?Bl(e.parentNode,n):e.nodeType===1&&Bl(e,n),$n(e)):Bl(b,n.stateNode));break;case 4:r=b,l=Te,b=n.stateNode.containerInfo,Te=!0,Je(e,t,n),b=r,Te=l;break;case 0:case 11:case 14:case 15:if(!le&&(r=n.updateQueue,r!==null&&(r=r.lastEffect,r!==null))){l=r=r.next;do{var i=l,o=i.destroy;i=i.tag,o!==void 0&&(i&2||i&4)&&Mi(n,t,o),l=l.next}while(l!==r)}Je(e,t,n);break;case 1:if(!le&&(Qt(n,t),r=n.stateNode,typeof r.componentWillUnmount=="function"))try{r.props=n.memoizedProps,r.state=n.memoizedState,r.componentWillUnmount()}catch(u){H(n,t,u)}Je(e,t,n);break;case 21:Je(e,t,n);break;case 22:n.mode&1?(le=(r=le)||n.memoizedState!==null,Je(e,t,n),le=r):Je(e,t,n);break;default:Je(e,t,n)}}function Xu(e){var t=e.updateQueue;if(t!==null){e.updateQueue=null;var n=e.stateNode;n===null&&(n=e.stateNode=new Qd),t.forEach(function(r){var l=tp.bind(null,e,r);n.has(r)||(n.add(r),r.then(l,l))})}}function Pe(e,t){var n=t.deletions;if(n!==null)for(var r=0;rl&&(l=o),r&=~i}if(r=l,r=K()-r,r=(120>r?120:480>r?480:1080>r?1080:1920>r?1920:3e3>r?3e3:4320>r?4320:1960*Xd(r/1960))-r,10e?16:e,rt===null)var r=!1;else{if(e=rt,rt=null,tl=0,I&6)throw Error(w(331));var l=I;for(I|=4,k=e.current;k!==null;){var i=k,o=i.child;if(k.flags&16){var u=i.deletions;if(u!==null){for(var s=0;sK()-Fo?_t(e,0):Do|=n),he(e,t)}function mc(e,t){t===0&&(e.mode&1?(t=cr,cr<<=1,!(cr&130023424)&&(cr=4194304)):t=1);var n=ue();e=Xe(e,t),e!==null&&(qn(e,t,n),he(e,n))}function ep(e){var t=e.memoizedState,n=0;t!==null&&(n=t.retryLane),mc(e,n)}function tp(e,t){var n=0;switch(e.tag){case 13:var r=e.stateNode,l=e.memoizedState;l!==null&&(n=l.retryLane);break;case 19:r=e.stateNode;break;default:throw Error(w(314))}r!==null&&r.delete(t),mc(e,n)}var vc;vc=function(e,t,n){if(e!==null)if(e.memoizedProps!==t.pendingProps||de.current)fe=!0;else{if(!(e.lanes&n)&&!(t.flags&128))return fe=!1,Bd(e,t,n);fe=!!(e.flags&131072)}else fe=!1,U&&t.flags&1048576&&Sa(t,Kr,t.index);switch(t.lanes=0,t.tag){case 2:var r=t.type;Tr(e,t),e=t.pendingProps;var l=en(t,ie.current);Jt(t,n),l=Lo(null,t,r,e,l,n);var i=To();return t.flags|=1,typeof l=="object"&&l!==null&&typeof l.render=="function"&&l.$$typeof===void 0?(t.tag=1,t.memoizedState=null,t.updateQueue=null,pe(r)?(i=!0,Wr(t)):i=!1,t.memoizedState=l.state!==null&&l.state!==void 0?l.state:null,_o(t),l.updater=dl,t.stateNode=l,l._reactInternals=t,Ti(t,r,e,n),t=Oi(null,t,r,!0,i,n)):(t.tag=0,U&&i&&go(t),oe(null,t,l,n),t=t.child),t;case 16:r=t.elementType;e:{switch(Tr(e,t),e=t.pendingProps,l=r._init,r=l(r._payload),t.type=r,l=t.tag=rp(r),e=Le(r,e),l){case 0:t=Ri(null,t,r,e,n);break e;case 1:t=Hu(null,t,r,e,n);break e;case 11:t=Vu(null,t,r,e,n);break e;case 14:t=Bu(null,t,r,Le(r.type,e),n);break e}throw Error(w(306,r,""))}return t;case 0:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:Le(r,l),Ri(e,t,r,l,n);case 1:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:Le(r,l),Hu(e,t,r,l,n);case 3:e:{if(ba(t),e===null)throw Error(w(387));r=t.pendingProps,i=t.memoizedState,l=i.element,Na(e,t),Gr(t,r,null,n);var o=t.memoizedState;if(r=o.element,i.isDehydrated)if(i={element:r,isDehydrated:!1,cache:o.cache,pendingSuspenseBoundaries:o.pendingSuspenseBoundaries,transitions:o.transitions},t.updateQueue.baseState=i,t.memoizedState=i,t.flags&256){l=ln(Error(w(423)),t),t=Wu(e,t,r,n,l);break e}else if(r!==l){l=ln(Error(w(424)),t),t=Wu(e,t,r,n,l);break e}else for(ve=ut(t.stateNode.containerInfo.firstChild),ge=t,U=!0,Re=null,n=_a(t,null,r,n),t.child=n;n;)n.flags=n.flags&-3|4096,n=n.sibling;else{if(tn(),r===l){t=Ge(e,t,n);break e}oe(e,t,r,n)}t=t.child}return t;case 5:return ja(t),e===null&&ji(t),r=t.type,l=t.pendingProps,i=e!==null?e.memoizedProps:null,o=l.children,ki(r,l)?o=null:i!==null&&ki(r,i)&&(t.flags|=32),qa(e,t),oe(e,t,o,n),t.child;case 6:return e===null&&ji(t),null;case 13:return ec(e,t,n);case 4:return Co(t,t.stateNode.containerInfo),r=t.pendingProps,e===null?t.child=nn(t,null,r,n):oe(e,t,r,n),t.child;case 11:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:Le(r,l),Vu(e,t,r,l,n);case 7:return oe(e,t,t.pendingProps,n),t.child;case 8:return oe(e,t,t.pendingProps.children,n),t.child;case 12:return oe(e,t,t.pendingProps.children,n),t.child;case 10:e:{if(r=t.type._context,l=t.pendingProps,i=t.memoizedProps,o=l.value,F(Yr,r._currentValue),r._currentValue=o,i!==null)if(De(i.value,o)){if(i.children===l.children&&!de.current){t=Ge(e,t,n);break e}}else for(i=t.child,i!==null&&(i.return=t);i!==null;){var u=i.dependencies;if(u!==null){o=i.child;for(var s=u.firstContext;s!==null;){if(s.context===r){if(i.tag===1){s=Qe(-1,n&-n),s.tag=2;var f=i.updateQueue;if(f!==null){f=f.shared;var v=f.pending;v===null?s.next=s:(s.next=v.next,v.next=s),f.pending=s}}i.lanes|=n,s=i.alternate,s!==null&&(s.lanes|=n),Pi(i.return,n,t),u.lanes|=n;break}s=s.next}}else if(i.tag===10)o=i.type===t.type?null:i.child;else if(i.tag===18){if(o=i.return,o===null)throw Error(w(341));o.lanes|=n,u=o.alternate,u!==null&&(u.lanes|=n),Pi(o,n,t),o=i.sibling}else o=i.child;if(o!==null)o.return=i;else for(o=i;o!==null;){if(o===t){o=null;break}if(i=o.sibling,i!==null){i.return=o.return,o=i;break}o=o.return}i=o}oe(e,t,l.children,n),t=t.child}return t;case 9:return l=t.type,r=t.pendingProps.children,Jt(t,n),l=Ce(l),r=r(l),t.flags|=1,oe(e,t,r,n),t.child;case 14:return r=t.type,l=Le(r,t.pendingProps),l=Le(r.type,l),Bu(e,t,r,l,n);case 15:return Za(e,t,t.type,t.pendingProps,n);case 17:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:Le(r,l),Tr(e,t),t.tag=1,pe(r)?(e=!0,Wr(t)):e=!1,Jt(t,n),Ya(t,r,l),Ti(t,r,l,n),Oi(null,t,r,!0,e,n);case 19:return tc(e,t,n);case 22:return Ja(e,t,n)}throw Error(w(156,t.tag))};function gc(e,t){return Ws(e,t)}function np(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 Ee(e,t,n,r){return new np(e,t,n,r)}function Ao(e){return e=e.prototype,!(!e||!e.isReactComponent)}function rp(e){if(typeof e=="function")return Ao(e)?1:0;if(e!=null){if(e=e.$$typeof,e===lo)return 11;if(e===io)return 14}return 2}function ft(e,t){var n=e.alternate;return n===null?(n=Ee(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 Or(e,t,n,r,l,i){var o=2;if(r=e,typeof e=="function")Ao(e)&&(o=1);else if(typeof e=="string")o=5;else e:switch(e){case Ft:return Ct(n.children,l,i,t);case ro:o=8,l|=8;break;case ei:return e=Ee(12,n,t,l|2),e.elementType=ei,e.lanes=i,e;case ti:return e=Ee(13,n,t,l),e.elementType=ti,e.lanes=i,e;case ni:return e=Ee(19,n,t,l),e.elementType=ni,e.lanes=i,e;case js:return ml(n,l,i,t);default:if(typeof e=="object"&&e!==null)switch(e.$$typeof){case Cs:o=10;break e;case Ns:o=9;break e;case lo:o=11;break e;case io:o=14;break e;case qe:o=16,r=null;break e}throw Error(w(130,e==null?e:typeof e,""))}return t=Ee(o,n,t,l),t.elementType=e,t.type=r,t.lanes=i,t}function Ct(e,t,n,r){return e=Ee(7,e,r,t),e.lanes=n,e}function ml(e,t,n,r){return e=Ee(22,e,r,t),e.elementType=js,e.lanes=n,e.stateNode={isHidden:!1},e}function Zl(e,t,n){return e=Ee(6,e,null,t),e.lanes=n,e}function Jl(e,t,n){return t=Ee(4,e.children!==null?e.children:[],e.key,t),t.lanes=n,t.stateNode={containerInfo:e.containerInfo,pendingChildren:null,implementation:e.implementation},t}function lp(e,t,n,r,l){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=zl(0),this.expirationTimes=zl(-1),this.entangledLanes=this.finishedLanes=this.mutableReadLanes=this.expiredLanes=this.pingedLanes=this.suspendedLanes=this.pendingLanes=0,this.entanglements=zl(0),this.identifierPrefix=r,this.onRecoverableError=l,this.mutableSourceEagerHydrationData=null}function Vo(e,t,n,r,l,i,o,u,s){return e=new lp(e,t,n,u,s),t===1?(t=1,i===!0&&(t|=8)):t=0,i=Ee(3,null,null,t),e.current=i,i.stateNode=e,i.memoizedState={element:r,isDehydrated:n,cache:null,transitions:null,pendingSuspenseBoundaries:null},_o(i),e}function ip(e,t,n){var r=3"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(xc)}catch(e){console.error(e)}}xc(),xs.exports=we;var cp=xs.exports,kc,ns=cp;kc=ns.createRoot,ns.hydrateRoot;class zt extends Error{constructor(n,r){super(n);Yo(this,"status");this.name="ApiError",this.status=r}}async function nr(e){const t=await fetch(e);if(!t.ok){const n=await t.json().catch(()=>null),r=(n&&typeof n.error=="string"?n.error:null)??t.statusText;throw new zt(r,t.status)}return await t.json()}function rs(e,t={}){const n=new URLSearchParams({q:e});return t.limit&&n.set("limit",String(t.limit)),t.cursor&&n.set("cursor",t.cursor),nr(`/api/search?${n.toString()}`)}function fp(e,t={}){const n=new URLSearchParams({q:e});return t.limit&&n.set("limit",String(t.limit)),t.branch&&n.set("branch",t.branch),nr(`/api/semantic?${n.toString()}`)}function Ec(){return nr("/api/semantic/status")}function dp(e,t,n){const r=new URLSearchParams({repo:e,path:t});return n&&r.set("branch",n),nr(`/api/file?${r.toString()}`)}function _c(){return nr("/api/repos")}const Cc="webui-theme";function Nc(){return window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light"}function jc(){const e=window.localStorage.getItem(Cc);return e==="light"||e==="dark"?e:null}let Sl=jc()??Nc();const Qi=new Set;function Pc(e){document.documentElement.setAttribute("data-theme",e)}Pc(Sl);function Lc(e){Sl=e,window.localStorage.setItem(Cc,e),Pc(e),Qi.forEach(t=>t())}function pp(){Lc(Sl==="dark"?"light":"dark")}function hp(e){return Qi.add(e),()=>Qi.delete(e)}function Tc(){return R.useEffect(()=>{if(jc()!==null)return;const e=window.matchMedia("(prefers-color-scheme: dark)"),t=()=>Lc(Nc());return e.addEventListener("change",t),()=>e.removeEventListener("change",t)},[]),R.useSyncExternalStore(hp,()=>Sl)}function mp(){const e=Tc();return d.jsx("button",{type:"button",className:"theme-toggle",onClick:pp,"aria-label":`Switch to ${e==="dark"?"light":"dark"} theme`,title:`Switch to ${e==="dark"?"light":"dark"} theme`,children:e==="dark"?"🌙":"☀️"})}const vp="modulepreload",gp=function(e){return"/"+e},ls={},ze=function(t,n,r){let l=Promise.resolve();if(n&&n.length>0){document.getElementsByTagName("link");const o=document.querySelector("meta[property=csp-nonce]"),u=(o==null?void 0:o.nonce)||(o==null?void 0:o.getAttribute("nonce"));l=Promise.allSettled(n.map(s=>{if(s=gp(s),s in ls)return;ls[s]=!0;const f=s.endsWith(".css"),v=f?'[rel="stylesheet"]':"";if(document.querySelector(`link[href="${s}"]${v}`))return;const h=document.createElement("link");if(h.rel=f?"stylesheet":vp,f||(h.as="script"),h.crossOrigin="",h.href=s,u&&h.setAttribute("nonce",u),document.head.appendChild(h),f)return new Promise((m,g)=>{h.addEventListener("load",m),h.addEventListener("error",()=>g(new Error(`Unable to preload CSS for ${s}`)))})}))}function i(o){const u=new Event("vite:preloadError",{cancelable:!0});if(u.payload=o,window.dispatchEvent(u),!u.defaultPrevented)throw o}return l.then(o=>{for(const u of o||[])u.status==="rejected"&&i(u.reason);return t().catch(i)})},yp="github-light",wp="github-dark",Ki={python:()=>ze(()=>import("./python-DhUJRlN_.js"),[]),javascript:()=>ze(()=>import("./javascript-ySlJ1b_l.js"),[]),typescript:()=>ze(()=>import("./typescript-Dj6nwHGl.js"),[]),tsx:()=>ze(()=>import("./tsx-B6W0miNI.js"),[]),go:()=>ze(()=>import("./go-B1SYOhNW.js"),[]),java:()=>ze(()=>import("./java-xI-RfyKK.js"),[]),rust:()=>ze(()=>import("./rust-Be6lgOlo.js"),[])};let ql=null;function Sp(){return ql||(ql=(async()=>{const[{createHighlighterCore:e},{createJavaScriptRegexEngine:t}]=await Promise.all([ze(()=>import("./core-DqGsPV3B.js"),__vite__mapDeps([0,1])),ze(()=>import("./engine-javascript-ohkglBQk.js"),__vite__mapDeps([2,1]))]);return e({themes:[ze(()=>import("./github-light-DAi9KRSo.js"),[]),ze(()=>import("./github-dark-DHJKELXO.js"),[])],langs:Object.values(Ki).map(n=>n()),engine:t()})})()),ql}async function xp(e,t,n){try{const r=await Sp(),l=t&&t in Ki?t:"text";return l!=="text"&&!r.getLoadedLanguages().includes(l)&&await r.loadLanguage(Ki[l]()),r.codeToTokens(e,{lang:l,theme:n}).tokens.map(o=>o.map(u=>({content:u.content,color:u.color})))}catch{return null}}function kp({content:e,lang:t,targetLine:n,targetEndLine:r=null}){const l=Tc(),[i,o]=R.useState(null),u=R.useRef(null),s=l==="dark"?wp:yp;R.useEffect(()=>{let h=!1;return o(null),xp(e,t,s).then(m=>{h||o(m)}),()=>{h=!0}},[e,t,s]),R.useEffect(()=>{var h;(h=u.current)==null||h.scrollIntoView({block:"center"})},[i,n]);const f=e.split(` -`),v=i??f.map(h=>[{content:h}]);return d.jsx("div",{className:"code-view",children:d.jsx("pre",{children:v.map((h,m)=>{const g=m+1,x=n!==null&&g>=n&&g<=(r??n);return d.jsxs("div",{id:`L${g}`,ref:g===n?u:void 0,className:`code-line${x?" target":""}`,children:[d.jsx("a",{className:"line-no",href:`#L${g}`,children:g}),d.jsx("span",{className:"line-text",children:h.map((S,O)=>d.jsx("span",{style:S.color?{color:S.color}:void 0,children:S.content},O))})]},g)})})})}function xl(){const{pathname:e,search:t,hash:n}=window.location,r=new URLSearchParams(t);if(e==="/file"){const l=n.match(/^#L(\d+)(?:-L(\d+))?$/);return{page:"file",repo:r.get("repo")??"",path:r.get("path")??"",line:l?Number(l[1]):null,endLine:l!=null&&l[2]?Number(l[2]):null,find:r.get("find"),branch:r.get("branch")}}return e==="/repos"?{page:"repos"}:e==="/semantic"?{page:"semantic",query:r.get("q")??""}:{page:"search",query:r.get("q")??""}}let kl=xl();const Zn=new Set;window.addEventListener("popstate",()=>{kl=xl(),Zn.forEach(e=>e())});function Ep(e){return Zn.add(e),()=>Zn.delete(e)}function _p(){return kl}function Cp(e){window.history.pushState(null,"",e),kl=xl(),Zn.forEach(t=>t())}function Qo(e){window.history.replaceState(null,"",e),kl=xl(),Zn.forEach(t=>t())}function Np(){return R.useSyncExternalStore(Ep,_p)}function jp(){R.useEffect(()=>{function e(t){if(t.defaultPrevented||t.button!==0||t.metaKey||t.ctrlKey||t.shiftKey||t.altKey)return;const n=t.target.closest("a");if(!n||n.target||n.hasAttribute("download"))return;const r=n.getAttribute("href");!r||!r.startsWith("/")||r.startsWith("//")||(t.preventDefault(),Cp(r))}return document.addEventListener("click",e),()=>document.removeEventListener("click",e)},[])}function Pp(e){let t=null;for(const n of e.split(` -`)){const r=n.trim();r.length>0&&(t===null||r.length>t.length)&&(t=r)}return t}function Lp(e,t){const n=e.indexOf(t);if(n===-1)return null;const r=e.slice(0,n).split(` -`).length;let l=1,i=e.indexOf(t,n+1);for(;i!==-1;)l+=1,i=e.indexOf(t,i+1);return{line:r,occurrences:l}}function Tp(e){var r;const t=(r=e.split(".").pop())==null?void 0:r.toLowerCase();return t?{py:"python",js:"javascript",jsx:"javascript",ts:"typescript",tsx:"tsx",go:"go",java:"java",rs:"rust"}[t]??null:null}function zp({repo:e,path:t,line:n,endLine:r,find:l,branch:i}){const[o,u]=R.useState({status:"loading"}),[s,f]=R.useState(!1),[v,h]=R.useState(null);R.useEffect(()=>{u({status:"loading"}),h(null),dp(e,t,i).then(g=>{if(u({status:"loaded",file:g}),l&&n===null&&g.content!=null){const x=Lp(g.content,l);if(x===null){h("Couldn't locate the chunk in the current file content — content may have been re-indexed.");return}const S=new URLSearchParams({repo:e,path:t});i&&S.set("branch",i),Qo(`/file?${S.toString()}#L${x.line}`),x.occurrences>1&&h(`This line appears ${x.occurrences} times — showing the first occurrence, which may not be the chunk's exact location.`)}}).catch(g=>{if(g instanceof zt&&g.status===404){u({status:"not_found"});return}const x=g instanceof zt?g.message:"Failed to load file.";u({status:"error",message:x})})},[e,t,i]);function m(){const g=new URL(window.location.href);navigator.clipboard.writeText(g.toString()).then(()=>{f(!0),setTimeout(()=>f(!1),1500)})}return o.status==="loading"?d.jsx("div",{className:"result-summary",children:"Loading…"}):o.status==="not_found"?d.jsxs("div",{className:"banner warn",children:["File not found: ",e,"/",t]}):o.status==="error"?d.jsx("div",{className:"banner error",children:o.message}):o.file.content==null?d.jsxs("div",{className:"banner warn",children:["File not found: ",e,"/",t]}):d.jsxs("div",{children:[d.jsxs("div",{className:"file-view-header",children:[d.jsxs("h2",{children:[e,"/",t]}),d.jsx("span",{className:"badge",children:o.file.branch}),o.file.commit&&d.jsx("span",{className:"badge commit-badge",children:o.file.commit.slice(0,12)}),d.jsx("button",{type:"button",className:"theme-toggle",onClick:m,children:s?"Copied!":"Copy permalink"})]}),v&&d.jsxs("div",{className:"banner warn",children:[v,d.jsx("button",{type:"button",className:"theme-toggle",onClick:()=>h(null),children:"Dismiss"})]}),d.jsx(kp,{content:o.file.content,lang:Tp(t),targetLine:n,targetEndLine:r})]})}function Rp(){const[e,t]=R.useState({status:"loading"});return R.useEffect(()=>{_c().then(n=>t({status:"loaded",repos:n.repos})).catch(n=>{const r=n instanceof zt?n.message:"Failed to load repositories.";t({status:"error",message:r})})},[]),e.status==="loading"?d.jsx("div",{className:"result-summary",children:"Loading…"}):e.status==="error"?d.jsx("div",{className:"banner error",children:e.message}):d.jsxs("table",{className:"repos-table",children:[d.jsx("thead",{children:d.jsxs("tr",{children:[d.jsx("th",{children:"Repository"}),d.jsx("th",{children:"Default branch"}),d.jsx("th",{children:"Last indexed commit"}),d.jsx("th",{children:"Last indexed at"})]})}),d.jsx("tbody",{children:e.repos.map(n=>d.jsxs("tr",{children:[d.jsx("td",{children:d.jsx("a",{href:`/?q=${encodeURIComponent(`repo:${n.name} `)}`,children:n.name})}),d.jsx("td",{children:n.default_branch??"—"}),d.jsx("td",{children:n.last_indexed_commit?n.last_indexed_commit.slice(0,12):"—"}),d.jsx("td",{children:n.index_time?new Date(n.index_time).toLocaleString():"—"})]},n.name))})]})}const Op=new Set([" "," ",` -`,"(",")"]),zc={repo:"repo",file:"file",lang:"lang",sym:"sym",branch:"branch",commit:"commit"},is=new Set(["content","r","f","l","b","c","s"]),Ip=new Set([...Object.keys(zc),"case"]);function Yi(e){return e===" "||e===" "||e===` -`}function Dp(e){return e>="a"&&e<="z"}function os(e,t){const n=e.length;let r=t;for(;rr&&os.field==="repo"),r=e.atoms.filter(s=>s.field==="lang"),l=e.atoms.filter(s=>s.field==="branch"),i=n.length===1?n[0].value:null,o=r.length===1?r[0].value:null;let u=us;if(n.length===1&&l.length<=1){const s=t.find(f=>f.name===n[0].value);s&&(u={available:!0,options:s.branches,active:l.length===1?l[0].value:null})}return{editable:!0,repoActive:i,langActive:o,branch:u}}function Mp(e){if(e.length===0)return!1;const t=e[0];if(t==="("||t===")"||t==='"'||t==="/")return!1;for(const n of e)if(Yi(n)||n==="("||n===")"||n==='"')return!1;return!0}function $p(e){return Mp(e)?e:`"${e.replace(/"/g,'\\"')}"`}function Up(e,t,n){const r=e.atoms.filter(t).map(l=>e.source.slice(l.start,l.end));return n!==null&&r.push(n),r.join(" ")}function Xi(e,t,n){const r=n===null?null:`${t}:${$p(n)}`;return Up(e,l=>l.field!==t,r)}function Ap(e,t){const n=e.atoms.filter(l=>l.field==="branch"),r=n.length===1&&n[0].value===t;return Xi(e,"branch",r?null:t)}function Vp(e){const t=Rc(e);return t.safe?t.atoms.filter(n=>n.field==="commit").map(n=>n.value):[]}function Bp(e,t){return t.find(r=>e.toLowerCase().startsWith(r.toLowerCase()))??e.slice(0,12)}function Hp({banners:e,query:t}){const n=[];if(e.queryParseError&&n.push({key:"parse",tone:"error",text:`Query error: ${e.queryParseError}`}),e.queryTooBroad&&n.push({key:"broad",tone:"warn",text:"Query too broad — results were cut short by the time budget."}),e.regexIncompatible&&n.push({key:"regex",tone:"warn",text:"One or more /regex/ atoms are not supported and were ignored."}),e.truncated){const r=e.truncationReason?` (${e.truncationReason})`:"";n.push({key:"truncated",tone:"warn",text:`Results truncated${r}.`})}if(e.commitNotIndexed&&n.push({key:"commit-not-indexed",tone:"warn",text:"No indexed branch at this commit."}),e.resolved.length>0){const r=Vp(t);e.resolved.forEach((l,i)=>{const o=Bp(l.commit,r);n.push({key:`resolved-${i}`,tone:"info",text:`✓ ${o} → ${l.repo} @ ${l.branch} (${l.commit.slice(0,12)})`})})}return n.length===0?null:d.jsx(d.Fragment,{children:n.map(r=>d.jsx("div",{className:`banner ${r.tone}`,children:r.text},r.key))})}const ss="This query has OR/parens/quotes/regex/negation (-) -- edit the text directly.";function Wp({repos:e,languages:t,chips:n,onToggleRepo:r,onToggleLanguage:l,onToggleBranch:i}){if(e.length===0&&t.length===0)return null;const o=!n.editable;return d.jsxs("div",{className:"chips",children:[e.map(u=>d.jsxs("button",{type:"button",className:`chip${n.repoActive===u.name?" active":""}`,onClick:()=>r(u.name),disabled:o,title:o?ss:void 0,children:["repo:",u.name]},`repo-${u.name}`)),t.map(u=>d.jsxs("button",{type:"button",className:`chip${n.langActive===u?" active":""}`,onClick:()=>l(u),disabled:o,title:o?ss:void 0,children:["lang:",u]},`lang-${u}`)),n.branch.available&&n.branch.options.map(u=>d.jsxs("button",{type:"button",className:`chip${n.branch.active===u?" active":""}`,onClick:()=>i(u),children:["branch:",u]},`branch-${u}`))]})}function Qp(e,t){const n=new TextEncoder().encode(e);if(t.length===0)return[{text:e,highlighted:!1}];const r=Kp(t,n.length),l=new TextDecoder("utf-8"),i=[];let o=0;for(const[u,s]of r)u>o&&i.push({text:l.decode(n.subarray(o,u)),highlighted:!1}),i.push({text:l.decode(n.subarray(u,s)),highlighted:!0}),o=s;return o[Math.max(0,l),Math.min(t,i)]).filter(([l,i])=>i>l).sort((l,i)=>l[0]-i[0]),r=[];for(const l of n){const i=r[r.length-1];i&&l[0]<=i[1]?i[1]=Math.max(i[1],l[1]):r.push(l)}return r}function Gi(e,t,n,r){const l=new URLSearchParams({repo:e,path:t});r&&l.set("branch",r);const i=n!=null?`#L${n}`:"";return`/file?${l.toString()}${i}`}function Yp({repo:e,path:t,branch:n,match:r}){if(r.symbols&&r.symbols.length>0)return d.jsxs("div",{className:"result-line",children:[d.jsx("a",{className:"line-no",href:Gi(e,t,r.line,n),children:r.line??""}),d.jsx("span",{className:"line-text",children:r.symbols.map((i,o)=>d.jsxs("span",{children:[d.jsx("mark",{children:i.name})," ",d.jsxs("span",{className:"lang",children:["(",i.kind,")"]})," "]},o))})]});const l=Qp(r.text,r.byte_ranges);return d.jsxs("div",{className:"result-line",children:[d.jsx("a",{className:"line-no",href:Gi(e,t,r.line,n),children:r.line??""}),d.jsx("span",{className:"line-text",children:l.map((i,o)=>i.highlighted?d.jsx("mark",{children:i.text},o):d.jsx("span",{children:i.text},o))})]})}function Xp({files:e}){return d.jsx("div",{children:e.map(t=>d.jsxs("div",{className:"result-file",children:[d.jsxs("div",{className:"result-file-header",children:[d.jsxs("a",{href:Gi(t.repo,t.file,null,t.permalink_branch),children:[t.repo,"/",t.file]}),t.language&&d.jsx("span",{className:"lang",children:t.language}),t.commit&&d.jsx("span",{className:"commit-badge",children:t.commit.slice(0,12)})]}),t.matches.map((n,r)=>d.jsx(Yp,{repo:t.repo,path:t.file,branch:t.permalink_branch,match:n},r))]},`${t.repo}:${t.file}:${t.content_sha}`))})}function Gp(){return d.jsxs("details",{className:"syntax-help",children:[d.jsx("summary",{children:"Query syntax"}),d.jsx("table",{children:d.jsxs("tbody",{children:[d.jsxs("tr",{children:[d.jsx("td",{children:d.jsx("code",{children:"repo:name"})}),d.jsx("td",{children:"Limit to a repository (see the Repos page for names)."})]}),d.jsxs("tr",{children:[d.jsx("td",{children:d.jsx("code",{children:"file:pattern"})}),d.jsxs("td",{children:["Limit by file path glob/substring, e.g. ",d.jsx("code",{children:"file:*.py"}),"."]})]}),d.jsxs("tr",{children:[d.jsx("td",{children:d.jsx("code",{children:"lang:go"})}),d.jsx("td",{children:"Limit by detected language."})]}),d.jsxs("tr",{children:[d.jsx("td",{children:d.jsx("code",{children:"sym:Name"})}),d.jsx("td",{children:"Match a symbol definition (function, class, etc.) by name."})]}),d.jsxs("tr",{children:[d.jsx("td",{children:d.jsx("code",{children:"branch:name"})}),d.jsx("td",{children:"Limit to files present on a branch (exact membership, not a glob or regex). Omitted, search covers each repo's default branch."})]}),d.jsxs("tr",{children:[d.jsx("td",{children:d.jsx("code",{children:"case:yes"})}),d.jsx("td",{children:"Case-sensitive match (default is case-insensitive)."})]}),d.jsxs("tr",{children:[d.jsx("td",{children:d.jsx("code",{children:"-term"})}),d.jsxs("td",{children:["Exclude (negate) the next atom, e.g. ",d.jsx("code",{children:"-repo:acme"})," or"," ",d.jsx("code",{children:"-lang:go"}),". Binds tighter than AND; only a leading"," ",d.jsx("code",{children:"-"})," immediately before a non-space, non-",d.jsx("code",{children:")"})," character negates -- a trailing or standalone ",d.jsx("code",{children:"-"})," (as in"," ",d.jsx("code",{children:"foo -"}),") stays a literal dash. Quote it to search for a literal leading dash, e.g. ",d.jsx("code",{children:'"-foo"'}),". Not supported in semantic search."]})]}),d.jsxs("tr",{children:[d.jsx("td",{children:d.jsx("code",{children:"foo bar"})}),d.jsx("td",{children:"Space between atoms is AND."})]}),d.jsxs("tr",{children:[d.jsx("td",{children:d.jsx("code",{children:"foo or bar"})}),d.jsx("td",{children:"Boolean OR between atoms."})]}),d.jsxs("tr",{children:[d.jsx("td",{children:d.jsx("code",{children:"/regex/"})}),d.jsx("td",{children:"Regular expression content match."})]}),d.jsxs("tr",{children:[d.jsx("td",{children:d.jsx("code",{children:'"exact phrase"'})}),d.jsx("td",{children:"Quote a phrase containing spaces or special characters."})]})]})})]})}const Oc={query:"",status:"idle",files:[],fileCount:0,matchCount:0,cursor:null,hasSearched:!1,banners:{truncated:!1,truncationReason:null,queryTooBroad:!1,regexIncompatible:!1,queryParseError:null,resolved:[],commitNotIndexed:!1},error:null};function as(e){return{truncated:e.truncated,truncationReason:e.truncation_reason,queryTooBroad:e.query_too_broad,regexIncompatible:e.regex_incompatible,queryParseError:e.query_parse_error,resolved:e.resolved??[],commitNotIndexed:e.commit_not_indexed??!1}}function Zp(e,t){switch(t.type){case"search_start":return{...Oc,query:t.query,status:"loading",hasSearched:!0};case"search_success":return{...e,status:"idle",files:t.payload.files,fileCount:t.payload.file_count,matchCount:t.payload.match_count,cursor:t.payload.next_cursor??null,banners:as(t.payload),error:null};case"search_error":return{...e,status:"error",error:t.error,cursor:null};case"load_more_start":return{...e,status:"loading_more"};case"load_more_success":return{...e,status:"idle",files:[...e.files,...t.payload.files],fileCount:e.fileCount+t.payload.file_count,matchCount:e.matchCount+t.payload.match_count,cursor:t.payload.next_cursor??null,banners:as(t.payload),error:null};case"load_more_error":return{...e,status:"error",error:t.error};default:return e}}function Jp({initialQuery:e}){const[t,n]=R.useState(e),[r,l]=R.useReducer(Zp,Oc),[i,o]=R.useState([]),u=R.useRef(!1);R.useEffect(()=>{_c().then(c=>o(c.repos)).catch(()=>o([]))},[]);async function s(c){if(c.trim()){Qo(`/?q=${encodeURIComponent(c)}`),l({type:"search_start",query:c});try{const a=await rs(c,{cursor:null});l({type:"search_success",payload:a})}catch(a){const p=a instanceof zt?a.message:"Search request failed.";l({type:"search_error",error:p})}}}R.useEffect(()=>{u.current||(u.current=!0,e.trim()&&s(e))},[]);async function f(){if(r.cursor){l({type:"load_more_start"});try{const c=await rs(r.query,{cursor:r.cursor});l({type:"load_more_success",payload:c})}catch(c){const a=c instanceof zt?c.message:"Failed to load more results.";l({type:"load_more_error",error:a})}}}function v(c){c.preventDefault(),s(t)}const h=Rc(t),m=Fp(h,i);function g(c){if(!h.safe)return;const a=Xi(h,"repo",m.repoActive===c?null:c);n(a),s(a)}function x(c){if(!h.safe)return;const a=Xi(h,"lang",m.langActive===c?null:c);n(a),s(a)}function S(c){if(!h.safe)return;const a=Ap(h,c);n(a),s(a)}const O=Array.from(new Set(r.files.map(c=>c.language).filter(c=>!!c))).sort();return d.jsxs("div",{children:[d.jsxs("form",{className:"search-box",onSubmit:v,children:[d.jsx("input",{type:"text",value:t,onChange:c=>n(c.target.value),placeholder:'e.g. repo:myrepo lang:go "http.Handler"',"aria-label":"Search query",autoFocus:!0}),d.jsx("button",{type:"submit",children:"Search"})]}),d.jsx(Gp,{}),d.jsx(Wp,{repos:i,languages:O,chips:m,onToggleRepo:g,onToggleLanguage:x,onToggleBranch:S}),d.jsx(Hp,{banners:r.banners,query:r.query}),r.status==="error"&&d.jsx("div",{className:"banner error",children:r.error}),r.hasSearched&&r.status!=="loading"&&d.jsxs("div",{className:"result-summary",children:[r.fileCount," file",r.fileCount===1?"":"s",", ",r.matchCount," match",r.matchCount===1?"":"es"]}),r.status==="loading"&&d.jsx("div",{className:"result-summary",children:"Searching…"}),d.jsx(Xp,{files:r.files}),r.cursor&&d.jsx("button",{type:"button",className:"load-more",onClick:()=>void f(),disabled:r.status==="loading_more",children:r.status==="loading_more"?"Loading…":"Load more"})]})}function qp(e){const t=new URLSearchParams({repo:e.repo,path:e.file});if(e.start_line!==null&&e.end_line!==null){const r=e.end_line>e.start_line?`#L${e.start_line}-L${e.end_line}`:`#L${e.start_line}`;return`/file?${t.toString()}${r}`}const n=Pp(e.content);return n&&t.set("find",n),`/file?${t.toString()}`}function bp({result:e}){return d.jsxs("div",{className:"chunk-card",children:[d.jsxs("div",{className:"chunk-card-header",children:[d.jsxs("a",{href:qp(e),children:[e.repo,"/",e.file]}),d.jsxs("span",{className:"lang",children:["chunk ",e.chunk_index]}),d.jsxs("span",{className:"lang",children:["score ",e.rrf_score.toFixed(4)]}),d.jsxs("span",{className:"lang",children:["sim ",e.similarity===null?"—":e.similarity.toFixed(3)]})]}),d.jsx("pre",{className:"chunk-card-body",children:e.content})]})}function eh(e,t,n,r){return e==="loading"?d.jsx("div",{className:"result-summary",children:"Searching…"}):e==="error"?d.jsx("div",{className:"banner error",children:t}):n?n.semantic_enabled===!1?d.jsxs("div",{className:"banner warn",children:["Semantic search is not enabled for this deployment.",n.reason?` ${n.reason}`:""]}):n.semantic_schema_missing?d.jsx("div",{className:"banner warn",children:n.reason}):n.query_parse_error?d.jsx("div",{className:"banner error",children:n.query_parse_error}):n.unsupported_filter?d.jsxs("div",{className:"banner error",children:[n.unsupported_filter,n.reason?` ${n.reason}`:""]}):n.nothing_to_embed?d.jsx("div",{className:"banner warn",children:n.reason??"Nothing to search -- the query has no text left to embed."}):d.jsxs(d.Fragment,{children:[d.jsxs("div",{className:"result-summary",children:[n.count," chunk",n.count===1?"":"s",", ranked by hybrid relevance"]}),n.results.map((l,i)=>d.jsx(bp,{result:l},i))]}):r===!1?d.jsx("div",{className:"banner warn",children:"Semantic search is not enabled for this deployment."}):null}function th({initialQuery:e}){const[t,n]=R.useState(e),[r,l]=R.useState("idle"),[i,o]=R.useState(null),[u,s]=R.useState(null),[f,v]=R.useState(null),h=R.useRef(!1),m=R.useRef(!1);async function g(S){if(S.trim()){Qo(`/semantic?q=${encodeURIComponent(S)}`),l("loading");try{const O=await fp(S);o(O),l("idle")}catch(O){const c=O instanceof zt?O.message:"Semantic search request failed.";s(c),l("error")}}}R.useEffect(()=>{h.current||(h.current=!0,e.trim()&&g(e))},[]),R.useEffect(()=>{m.current||(m.current=!0,Ec().then(S=>v(S.semantic_enabled)).catch(()=>{}))},[]);function x(S){S.preventDefault(),g(t)}return d.jsxs("div",{children:[d.jsxs("form",{className:"search-box",onSubmit:x,children:[d.jsx("input",{type:"text",value:t,onChange:S=>n(S.target.value),placeholder:'e.g. "how are branch filters compiled to SQL" repo:acme/widgets',"aria-label":"Semantic search query",autoFocus:!0}),d.jsx("button",{type:"submit",children:"Search"})]}),d.jsxs("p",{className:"result-summary",children:["Scope with in-query ",d.jsx("code",{children:"repo:"}),", ",d.jsx("code",{children:"file:"}),", or ",d.jsx("code",{children:"lang:"})," atoms -- the rest of the query is embedded for similarity ranking."]}),eh(r,u,i,f)]})}function nh(){jp();const e=Np(),[t,n]=R.useState(!1);return R.useEffect(()=>{Ec().then(r=>n(r.semantic_enabled)).catch(()=>n(!1))},[]),d.jsxs("div",{className:"app-shell",children:[d.jsxs("header",{className:"app-header",children:[d.jsx("span",{className:"brand",children:"Code Search"}),d.jsxs("nav",{children:[d.jsx("a",{href:"/",children:"Search"}),t&&d.jsx("a",{href:"/semantic",children:"Semantic"}),d.jsx("a",{href:"/repos",children:"Repos"})]}),d.jsx(mp,{})]}),d.jsxs("main",{className:"app-main",children:[e.page==="search"&&d.jsx(Jp,{initialQuery:e.query}),e.page==="file"&&d.jsx(zp,{repo:e.repo,path:e.path,line:e.line,endLine:e.endLine,find:e.find,branch:e.branch}),e.page==="repos"&&d.jsx(Rp,{}),e.page==="semantic"&&d.jsx(th,{initialQuery:e.query})]})]})}const Ic=document.getElementById("root");if(!Ic)throw new Error("#root element not found");kc(Ic).render(d.jsx(R.StrictMode,{children:d.jsx(nh,{})})); diff --git a/webui/frontend/dist/index.html b/webui/frontend/dist/index.html index dc82ebf..74302e5 100644 --- a/webui/frontend/dist/index.html +++ b/webui/frontend/dist/index.html @@ -5,7 +5,7 @@ Code Search - + diff --git a/webui/frontend/src/App.tsx b/webui/frontend/src/App.tsx index 0c9dd6e..a67e766 100644 --- a/webui/frontend/src/App.tsx +++ b/webui/frontend/src/App.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import { getSemanticStatus } from "./api/client"; import { ThemeToggle } from "./components/ThemeToggle"; import { FilePage } from "./pages/FilePage"; +import { GraphPage } from "./pages/GraphPage"; import { ReposPage } from "./pages/ReposPage"; import { SearchPage } from "./pages/SearchPage"; import { SemanticPage } from "./pages/SemanticPage"; @@ -27,6 +28,7 @@ export function App(): JSX.Element { @@ -45,6 +47,7 @@ export function App(): JSX.Element { )} {route.page === "repos" && } {route.page === "semantic" && } + {route.page === "graph" && } ); diff --git a/webui/frontend/src/api/client.ts b/webui/frontend/src/api/client.ts index 490543b..a81c3dd 100644 --- a/webui/frontend/src/api/client.ts +++ b/webui/frontend/src/api/client.ts @@ -109,3 +109,102 @@ export interface ReposResponse { export function listRepos(): Promise { return getJson("/api/repos"); } + +// -------------------------------------------------------------- reference/import graph tools +// +// Wire types for /api/references + /api/imports -- thin passthroughs over the SAME +// app/service.py builders the MCP find_references/list_imports tools wrap (see +// webui/main.py::api_references / api_imports and docs/runbooks/webui.md). CANDIDATE-SET +// semantics throughout: a site is a place that names something; its `candidates` are the +// definitions that name could plausibly mean, ranked, never collapsed to one answer. + +export interface GraphCandidate { + repo: string; + file: string; + line: number; + name: string; + kind: string; + same_repo: boolean; + same_file: boolean; + kind_match: boolean; +} + +export interface ReferenceSite { + repo: string; + file: string; + line: number; + edge_kind: string; + target_name: string; + enclosing_symbol: { name: string; kind: string } | null; + resolution: "unique" | "ambiguous" | "unresolved"; + // True pre-cap count -- correct even when `candidates` itself is capped. + candidate_count: number; + candidates_truncated: boolean; + candidates: GraphCandidate[]; +} + +interface ResolutionSummary { + unique: number; + ambiguous: number; + unresolved: number; +} + +// Fields shared by both envelopes (app.service._reference_result_to_payload). +interface ReferenceEnvelopeBase { + sites: ReferenceSite[]; + site_count: number; + resolution_summary: ResolutionSummary; + truncated: boolean; + truncation_reason: string | null; +} + +export interface ReferencesEnvelope extends ReferenceEnvelopeBase { + query: string; + kind: "references"; + symbol: string; + branch: string | null; + // Folded QueryTooBroadError -- never an exception; a structured signal like every other + // recoverable condition on this surface. + query_too_broad: boolean; +} + +export interface ImportsEnvelope extends ReferenceEnvelopeBase { + query: string; + kind: "imports"; + direction: string; + repo: string | null; + // False is a structured "no such repo" miss; always true when no repo scope was requested. + repo_known: boolean; + target: string | null; + branch: string | null; + query_too_broad: boolean; + // PRE-DB validation states -- mutually exclusive with each other and with a results payload, + // each with a remedy `reason` (see app.service.list_imports_payload). + unsupported_direction?: string; + missing_repo?: boolean; + missing_target?: boolean; + reason?: string; +} + +export function findReferences( + symbol: string, + opts: { branch?: string | null } = {} +): Promise { + const params = new URLSearchParams({ symbol }); + if (opts.branch) params.set("branch", opts.branch); + return getJson(`/api/references?${params.toString()}`); +} + +export function listImports(opts: { + repo?: string | null; + target?: string | null; + direction?: string; + branch?: string | null; +}): Promise { + const params = new URLSearchParams(); + if (opts.repo) params.set("repo", opts.repo); + if (opts.target) params.set("target", opts.target); + if (opts.direction) params.set("direction", opts.direction); + if (opts.branch) params.set("branch", opts.branch); + return getJson(`/api/imports?${params.toString()}`); +} diff --git a/webui/frontend/src/components/ResultsList.test.tsx b/webui/frontend/src/components/ResultsList.test.tsx index 89be35f..da30662 100644 --- a/webui/frontend/src/components/ResultsList.test.tsx +++ b/webui/frontend/src/components/ResultsList.test.tsx @@ -52,4 +52,25 @@ describe("ResultsList", () => { const markup = renderToStaticMarkup(); expect(markup).not.toContain("commit-badge"); }); + + it("renders a /references?symbol= link next to a symbol match", () => { + const markup = renderToStaticMarkup( + + ); + expect(markup).toContain('href="/references?symbol=Handler"'); + expect(markup).toContain(">refs<"); + }); }); diff --git a/webui/frontend/src/components/ResultsList.tsx b/webui/frontend/src/components/ResultsList.tsx index 5c63ea1..8f6a54e 100644 --- a/webui/frontend/src/components/ResultsList.tsx +++ b/webui/frontend/src/components/ResultsList.tsx @@ -29,6 +29,7 @@ function MatchLine({ {match.symbols.map((sym, i) => ( {sym.name} ({sym.kind}){" "} + refs{" "} ))} diff --git a/webui/frontend/src/components/SiteList.test.tsx b/webui/frontend/src/components/SiteList.test.tsx new file mode 100644 index 0000000..e636df0 --- /dev/null +++ b/webui/frontend/src/components/SiteList.test.tsx @@ -0,0 +1,173 @@ +// vitest runs with environment: "node" (vite.config.ts) -- no jsdom, no @testing-library. +// SiteList.tsx has no module-scope window reads (unlike router.ts-importing pages), so no +// vi.stubGlobal("window") stub is needed here, unlike GraphPage.test.tsx / router.test.ts. +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import type { ReferenceSite } from "../api/client"; +import { SiteList } from "./SiteList"; + +function site(overrides: Partial = {}): ReferenceSite { + return { + repo: "acme/widgets", + file: "src/caller.py", + line: 5, + edge_kind: "call", + target_name: "process", + enclosing_symbol: { name: "run", kind: "function" }, + resolution: "ambiguous", + candidate_count: 2, + candidates_truncated: false, + candidates: [], + ...overrides, + }; +} + +const summary = { unique: 0, ambiguous: 1, unresolved: 0 }; + +describe("SiteList", () => { + it("renders 'module scope' when enclosing_symbol is null", () => { + const markup = renderToStaticMarkup( + + ); + expect(markup).toContain("module scope"); + }); + + it("renders the resolution badge", () => { + const markup = renderToStaticMarkup( + + ); + expect(markup).toContain("resolution-unique"); + expect(markup).toContain(">unique<"); + }); + + it("renders same_repo/kind_match signal chips per candidate but not same_file when false", () => { + const markup = renderToStaticMarkup( + + ); + expect(markup).toContain("same repo"); + expect(markup).toContain("kind match"); + expect(markup).not.toContain("same file"); + }); + + it("renders a 'showing N of M' note when candidates_truncated", () => { + const markup = renderToStaticMarkup( + + ); + expect(markup).toContain("showing 1 of 5"); + }); + + it("deep-links a site and its candidates via the fileHref idiom, branch threaded", () => { + const markup = renderToStaticMarkup( + + ); + expect(markup).toContain( + "/file?repo=acme%2Fwidgets&path=src%2Fcaller.py&branch=feature%2Fx#L5" + ); + expect(markup).toContain( + "/file?repo=acme%2Fwidgets&path=src%2Fservice.py&branch=feature%2Fx#L10" + ); + }); + + it("shows the emptyMessage when there are no sites, and the row-cap truncation banner", () => { + const markup = renderToStaticMarkup( + + ); + expect(markup).toContain("No reference sites."); + expect(markup).toContain("row_cap"); + }); +}); diff --git a/webui/frontend/src/components/SiteList.tsx b/webui/frontend/src/components/SiteList.tsx new file mode 100644 index 0000000..2f53721 --- /dev/null +++ b/webui/frontend/src/components/SiteList.tsx @@ -0,0 +1,110 @@ +import type { GraphCandidate, ReferenceSite } from "../api/client"; + +// Shared candidate-set renderer for /references and /imports: both `_site_payload` shapes are +// identical (app/service.py), so one component covers both GraphPage modes. + +function fileHref(repo: string, path: string, line: number | null, branch: string | null): string { + const params = new URLSearchParams({ repo, path }); + if (branch) params.set("branch", branch); + const anchor = line != null ? `#L${line}` : ""; + return `/file?${params.toString()}${anchor}`; +} + +function SignalChips({ candidate }: { candidate: GraphCandidate }): JSX.Element | null { + const chips: string[] = []; + if (candidate.same_repo) chips.push("same repo"); + if (candidate.same_file) chips.push("same file"); + if (candidate.kind_match) chips.push("kind match"); + if (chips.length === 0) return null; + return ( + + {chips.map((chip) => ( + + {chip} + + ))} + + ); +} + +function CandidateRow({ candidate, branch }: { candidate: GraphCandidate; branch: string | null }): JSX.Element { + return ( +
  • + + {candidate.name} ({candidate.kind}) + + + {candidate.repo}/{candidate.file}:{candidate.line} + + +
  • + ); +} + +function SiteCard({ site, branch }: { site: ReferenceSite; branch: string | null }): JSX.Element { + const enclosing = site.enclosing_symbol + ? `${site.enclosing_symbol.name} (${site.enclosing_symbol.kind})` + : "module scope"; + return ( +
    + +
    + {site.target_name} in {enclosing} +
    + {site.candidates.length > 0 && ( +
      + {site.candidates.map((candidate, i) => ( + + ))} +
    + )} + {site.candidates_truncated && ( +
    + showing {site.candidates.length} of {site.candidate_count} candidates +
    + )} +
    + ); +} + +export function SiteList({ + sites, + siteCount, + resolutionSummary, + truncated, + truncationReason, + branch, + emptyMessage, +}: { + sites: ReferenceSite[]; + siteCount: number; + resolutionSummary: { unique: number; ambiguous: number; unresolved: number }; + truncated: boolean; + truncationReason: string | null; + branch: string | null; + emptyMessage: string; +}): JSX.Element { + return ( +
    +
    + {siteCount} site{siteCount === 1 ? "" : "s"} — {resolutionSummary.unique} unique,{" "} + {resolutionSummary.ambiguous} ambiguous, {resolutionSummary.unresolved} unresolved +
    + {truncated && ( +
    + Results truncated{truncationReason ? ` (${truncationReason})` : ""}. +
    + )} + {sites.length === 0 ? ( +
    {emptyMessage}
    + ) : ( + sites.map((site, i) => ) + )} +
    + ); +} diff --git a/webui/frontend/src/pages/GraphPage.test.tsx b/webui/frontend/src/pages/GraphPage.test.tsx new file mode 100644 index 0000000..9c946e4 --- /dev/null +++ b/webui/frontend/src/pages/GraphPage.test.tsx @@ -0,0 +1,210 @@ +// vitest runs with environment: "node" (vite.config.ts) -- no jsdom, no @testing-library. +// renderToStaticMarkup gives us plain HTML strings to assert substrings against without either. +// referencesBody/importsBody are pure functions extracted from GraphPage specifically so this +// branching is testable without hook-driven component state (the SemanticPage.tsx/ +// semanticBody pattern). GraphPage.tsx transitively imports router.ts, which reads +// `window.location`/`window.addEventListener` at module scope -- stub a minimal fake window +// before importing, same pattern as router.test.ts / SemanticPage.test.tsx. +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vitest"; +import type { ImportsEnvelope, ReferenceSite, ReferencesEnvelope } from "../api/client"; + +vi.stubGlobal("window", { + location: { pathname: "/references", search: "", hash: "", href: "http://localhost/references" }, + history: { replaceState: vi.fn(), pushState: vi.fn() }, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), +}); + +const { GraphPage, referencesBody, importsBody } = await import("./GraphPage"); + +function refEnvelope(overrides: Partial = {}): ReferencesEnvelope { + return { + query: "process", + kind: "references", + symbol: "process", + branch: null, + query_too_broad: false, + sites: [], + site_count: 0, + resolution_summary: { unique: 0, ambiguous: 0, unresolved: 0 }, + truncated: false, + truncation_reason: null, + ...overrides, + }; +} + +function importEnvelope(overrides: Partial = {}): ImportsEnvelope { + return { + query: "acme/widgets", + kind: "imports", + direction: "imports", + repo: "acme/widgets", + repo_known: true, + target: null, + branch: null, + query_too_broad: false, + sites: [], + site_count: 0, + resolution_summary: { unique: 0, ambiguous: 0, unresolved: 0 }, + truncated: false, + truncation_reason: null, + ...overrides, + }; +} + +const site: ReferenceSite = { + repo: "acme/widgets", + file: "src/caller.py", + line: 5, + edge_kind: "call", + target_name: "process", + enclosing_symbol: { name: "run", kind: "function" }, + resolution: "ambiguous", + candidate_count: 2, + candidates_truncated: false, + candidates: [], +}; + +function markupFor(body: JSX.Element | null): string { + return body ? renderToStaticMarkup(body) : ""; +} + +describe("referencesBody", () => { + it("shows the loading state", () => { + expect(markupFor(referencesBody("loading", null, null))).toContain("Searching"); + }); + + it("shows the request error and never falls through to results", () => { + const markup = markupFor(referencesBody("error", "boom", null)); + expect(markup).toContain("boom"); + }); + + it("returns null when idle with no envelope (nothing searched yet)", () => { + expect(referencesBody("idle", null, null)).toBeNull(); + }); + + it("shows the query_too_broad banner and never falls through to a site list", () => { + const markup = markupFor(referencesBody("idle", null, refEnvelope({ query_too_broad: true }))); + expect(markup).toContain("too broad"); + expect(markup).not.toContain("No reference sites"); + }); + + it("shows 'No reference sites.' for an empty results envelope", () => { + const markup = markupFor(referencesBody("idle", null, refEnvelope())); + expect(markup).toContain("No reference sites."); + }); + + it("renders sites and the row-cap truncation banner when truncated", () => { + const markup = markupFor( + referencesBody( + "idle", + null, + refEnvelope({ + sites: [site], + site_count: 1, + resolution_summary: { unique: 0, ambiguous: 1, unresolved: 0 }, + truncated: true, + truncation_reason: "row_cap", + }) + ) + ); + expect(markup).toContain("src/caller.py"); + expect(markup).toContain("ambiguous"); + expect(markup).toContain("row_cap"); + }); +}); + +describe("importsBody", () => { + it("shows the unsupported_direction banner with the echoed value and reason, never falls through", () => { + const markup = markupFor( + importsBody( + "idle", + null, + importEnvelope({ + unsupported_direction: "sideways", + reason: "direction must be one of 'imports' or 'imported_by'", + }) + ) + ); + expect(markup).toContain("sideways"); + expect(markup).toContain("direction must be one of"); + expect(markup).not.toContain("No import sites"); + }); + + it("shows the missing_repo banner with its reason", () => { + const markup = markupFor( + importsBody( + "idle", + null, + importEnvelope({ missing_repo: true, reason: "direction=imports requires a repo" }) + ) + ); + expect(markup).toContain("direction=imports requires a repo"); + }); + + it("shows the missing_target banner with its reason", () => { + const markup = markupFor( + importsBody( + "idle", + null, + importEnvelope({ missing_target: true, reason: "direction=imported_by requires a target" }) + ) + ); + expect(markup).toContain("direction=imported_by requires a target"); + }); + + it("shows the no-such-repo banner when repo_known is false", () => { + const markup = markupFor( + importsBody("idle", null, importEnvelope({ repo_known: false, repo: "acme/ghost" })) + ); + expect(markup).toContain("acme/ghost"); + }); + + it("shows the query_too_broad banner", () => { + const markup = markupFor(importsBody("idle", null, importEnvelope({ query_too_broad: true }))); + expect(markup).toContain("too broad"); + }); + + it("shows the external-by-design standing copy and 'No import sites.' for an empty envelope", () => { + const markup = markupFor(importsBody("idle", null, importEnvelope())); + expect(markup).toContain("external/stdlib"); + expect(markup).toContain("No import sites."); + }); +}); + +describe("GraphPage mode switch (D8 keyed remount contract)", () => { + it("renders ONLY the references form for a references route, with no imports leakage", () => { + const markup = renderToStaticMarkup( + + ); + expect(markup).toContain("Find references"); + expect(markup).not.toContain("List imports"); + expect(markup).not.toContain('aria-label="Repo"'); + expect(markup).not.toContain('aria-label="Target"'); + }); + + it("renders ONLY the imports form for an imports route, with no references leakage", () => { + // App.tsx mounts GraphPage with key={route.mode}, so a references<->imports navigation + // fully unmounts and remounts this component -- equivalent to two independent renders of + // fresh instances with different route props, exercised here as two separate + // renderToStaticMarkup calls. Each must show ONLY its own mode's form/copy: proof that no + // symbol/repo/target/direction state -- or the "ranInitial" auto-run guard -- can bleed + // from one mode into the other across the remount. + const markup = renderToStaticMarkup( + + ); + expect(markup).toContain("List imports"); + expect(markup).not.toContain("Find references"); + expect(markup).not.toContain('placeholder="symbol name, e.g. process"'); + }); +}); diff --git a/webui/frontend/src/pages/GraphPage.tsx b/webui/frontend/src/pages/GraphPage.tsx new file mode 100644 index 0000000..9ee1ea3 --- /dev/null +++ b/webui/frontend/src/pages/GraphPage.tsx @@ -0,0 +1,236 @@ +import { useEffect, useRef, useState } from "react"; +import { + ApiError, + findReferences, + listImports, + type ImportsEnvelope, + type ReferencesEnvelope, +} from "../api/client"; +import { SiteList } from "../components/SiteList"; +import { replaceRoute, type Route } from "../router"; + +type Status = "idle" | "loading" | "error"; +type GraphRoute = Extract; + +// Pure render-decision functions, extracted for the same reason as SemanticPage's +// semanticBody: testable via renderToStaticMarkup without hook-driven state. Strict +// early-return ordering so an error/validation envelope can never fall through to results. + +export function referencesBody( + status: Status, + error: string | null, + envelope: ReferencesEnvelope | null +): JSX.Element | null { + if (status === "loading") return
    Searching…
    ; + if (status === "error") return
    {error}
    ; + if (!envelope) return null; + if (envelope.query_too_broad) { + return ( +
    + Query too broad — results were cut short by the time budget. +
    + ); + } + return ( + + ); +} + +export function importsBody( + status: Status, + error: string | null, + envelope: ImportsEnvelope | null +): JSX.Element | null { + if (status === "loading") return
    Searching…
    ; + if (status === "error") return
    {error}
    ; + if (!envelope) return null; + if (envelope.unsupported_direction !== undefined) { + return ( +
    + Unsupported direction "{envelope.unsupported_direction}". + {envelope.reason ? ` ${envelope.reason}` : ""} +
    + ); + } + if (envelope.missing_repo) { + return ( +
    + A repo is required.{envelope.reason ? ` ${envelope.reason}` : ""} +
    + ); + } + if (envelope.missing_target) { + return ( +
    + A target is required.{envelope.reason ? ` ${envelope.reason}` : ""} +
    + ); + } + if (envelope.repo_known === false) { + return
    No such repo: {envelope.repo}.
    ; + } + if (envelope.query_too_broad) { + return ( +
    + Query too broad — results were cut short by the time budget. +
    + ); + } + return ( + <> +

    + Import edges target the full dotted path as written, so most sites are external/stdlib + and resolve "unresolved" — that is expected, not an error. +

    + + + ); +} + +export function GraphPage({ route }: { route: GraphRoute }): JSX.Element { + const mode = route.mode; + const [symbol, setSymbol] = useState(route.mode === "references" ? route.symbol : ""); + const [repo, setRepo] = useState(route.mode === "imports" ? route.repo : ""); + const [target, setTarget] = useState(route.mode === "imports" ? route.target : ""); + const [direction, setDirection] = useState(route.mode === "imports" ? route.direction : "imports"); + const [branch, setBranch] = useState(route.branch ?? ""); + const [status, setStatus] = useState("idle"); + const [error, setError] = useState(null); + const [referencesEnvelope, setReferencesEnvelope] = useState(null); + const [importsEnvelope, setImportsEnvelope] = useState(null); + // Guards the mount-time auto-run so StrictMode's double-invoke (dev only) can't double-fire. + // App.tsx mounts GraphPage with key={route.mode}, so a references<->imports switch remounts + // this component and re-arms the guard rather than bleeding state across modes. + const ranInitial = useRef(false); + + async function runReferences(sym: string, br: string) { + if (!sym.trim()) return; + const params = new URLSearchParams({ symbol: sym }); + if (br) params.set("branch", br); + replaceRoute(`/references?${params.toString()}`); + setStatus("loading"); + try { + const payload = await findReferences(sym, { branch: br || null }); + setReferencesEnvelope(payload); + setStatus("idle"); + } catch (err) { + const message = err instanceof ApiError ? err.message : "References request failed."; + setError(message); + setStatus("error"); + } + } + + async function runImports(r: string, t: string, dir: string, br: string) { + const params = new URLSearchParams(); + if (r) params.set("repo", r); + if (t) params.set("target", t); + if (dir) params.set("direction", dir); + if (br) params.set("branch", br); + replaceRoute(`/imports?${params.toString()}`); + setStatus("loading"); + try { + const payload = await listImports({ + repo: r || null, + target: t || null, + direction: dir, + branch: br || null, + }); + setImportsEnvelope(payload); + setStatus("idle"); + } catch (err) { + const message = err instanceof ApiError ? err.message : "Imports request failed."; + setError(message); + setStatus("error"); + } + } + + useEffect(() => { + if (ranInitial.current) return; + ranInitial.current = true; + if (route.mode === "references") { + if (route.symbol.trim()) void runReferences(route.symbol, route.branch ?? ""); + } else if (route.repo.trim() || route.target.trim()) { + void runImports(route.repo, route.target, route.direction, route.branch ?? ""); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (mode === "references") { + void runReferences(symbol, branch); + } else { + void runImports(repo, target, direction, branch); + } + } + + return ( +
    +
    + {mode === "references" ? ( + setSymbol(e.target.value)} + placeholder="symbol name, e.g. process" + aria-label="Symbol" + autoFocus + /> + ) : ( + <> + + setRepo(e.target.value)} + placeholder="repo, e.g. acme/widgets" + aria-label="Repo" + /> + setTarget(e.target.value)} + placeholder="target dotted path, e.g. os.path" + aria-label="Target" + /> + + )} + setBranch(e.target.value)} + placeholder="branch (optional)" + aria-label="Branch" + /> + +
    +

    + Candidate-set results, not compiler-precise references — name-resolved over raw{" "} + {mode === "references" ? "call" : "import"} edges (grep-not-LSP); ambiguity is preserved, + never collapsed to one answer. +

    + {mode === "references" + ? referencesBody(status, error, referencesEnvelope) + : importsBody(status, error, importsEnvelope)} +
    + ); +} diff --git a/webui/frontend/src/pages/ReposPage.tsx b/webui/frontend/src/pages/ReposPage.tsx index 88752d4..47f4cb5 100644 --- a/webui/frontend/src/pages/ReposPage.tsx +++ b/webui/frontend/src/pages/ReposPage.tsx @@ -36,7 +36,8 @@ export function ReposPage(): JSX.Element { {state.repos.map((repo) => ( - {repo.name} + {repo.name}{" "} + imports {repo.default_branch ?? "—"} {repo.last_indexed_commit ? repo.last_indexed_commit.slice(0, 12) : "—"} diff --git a/webui/frontend/src/router.test.ts b/webui/frontend/src/router.test.ts index 5954f40..d808a8a 100644 --- a/webui/frontend/src/router.test.ts +++ b/webui/frontend/src/router.test.ts @@ -55,4 +55,53 @@ describe("parseLocation", () => { const route = await parseLocationFor("/file?repo=acme%2Fwidgets&path=a.py#L7"); expect(route).toMatchObject({ page: "file", line: 7, endLine: null }); }); + + it("parses /references?symbol=X&branch=Y", async () => { + const route = await parseLocationFor("/references?symbol=process&branch=feature%2Fx"); + expect(route).toEqual({ + page: "graph", + mode: "references", + symbol: "process", + branch: "feature/x", + }); + }); + + it("parses /references with no branch as branch: null", async () => { + const route = await parseLocationFor("/references?symbol=process"); + expect(route).toMatchObject({ page: "graph", mode: "references", branch: null }); + }); + + it("parses /imports?repo=R&direction=imports", async () => { + const route = await parseLocationFor("/imports?repo=acme%2Fwidgets&direction=imports"); + expect(route).toEqual({ + page: "graph", + mode: "imports", + repo: "acme/widgets", + target: "", + direction: "imports", + branch: null, + }); + }); + + it("parses /imports?target=T&direction=imported_by", async () => { + const route = await parseLocationFor("/imports?target=os.path&direction=imported_by"); + expect(route).toEqual({ + page: "graph", + mode: "imports", + repo: "", + target: "os.path", + direction: "imported_by", + branch: null, + }); + }); + + it("parses /imports?direction=bogus verbatim, no validation/coercion", async () => { + const route = await parseLocationFor("/imports?direction=bogus"); + expect(route).toMatchObject({ page: "graph", mode: "imports", direction: "bogus" }); + }); + + it("defaults /imports direction to \"imports\" when omitted", async () => { + const route = await parseLocationFor("/imports?repo=acme%2Fwidgets"); + expect(route).toMatchObject({ page: "graph", mode: "imports", direction: "imports" }); + }); }); diff --git a/webui/frontend/src/router.ts b/webui/frontend/src/router.ts index 31d2b17..0900436 100644 --- a/webui/frontend/src/router.ts +++ b/webui/frontend/src/router.ts @@ -16,7 +16,18 @@ export type Route = branch: string | null; } | { page: "repos" } - | { page: "semantic"; query: string }; + | { page: "semantic"; query: string } + | { page: "graph"; mode: "references"; symbol: string; branch: string | null } + | { + page: "graph"; + mode: "imports"; + repo: string; + target: string; + // Verbatim string, NOT a union: an unknown value must flow through to the builder's + // structured unsupported_direction 200 rather than being coerced/validated here. + direction: string; + branch: string | null; + }; export function parseLocation(): Route { const { pathname, search, hash } = window.location; @@ -40,6 +51,24 @@ export function parseLocation(): Route { if (pathname === "/semantic") { return { page: "semantic", query: params.get("q") ?? "" }; } + if (pathname === "/references") { + return { + page: "graph", + mode: "references", + symbol: params.get("symbol") ?? "", + branch: params.get("branch"), + }; + } + if (pathname === "/imports") { + return { + page: "graph", + mode: "imports", + repo: params.get("repo") ?? "", + target: params.get("target") ?? "", + direction: params.get("direction") ?? "imports", + branch: params.get("branch"), + }; + } return { page: "search", query: params.get("q") ?? "" }; } diff --git a/webui/main.py b/webui/main.py index 785baa8..e6bfd97 100644 --- a/webui/main.py +++ b/webui/main.py @@ -280,6 +280,67 @@ async def api_semantic( ) from error +async def api_references( + engine: EngineDep, + cfg: SettingsDep, + symbol: Annotated[str, Query(min_length=1)], + limit: Annotated[int, Query()] = 200, + branch: Annotated[str | None, Query()] = None, +) -> dict[str, Any]: + """Candidate-set call sites of ``symbol`` corpus-wide -- pure passthrough to + :func:`app.service.find_references_payload`, the SAME builder the MCP ``find_references`` + tool wraps (byte-identical payload at the same clamped ``limit``, see + ``docs/runbooks/webui.md``). + + ``symbol`` requiring a non-empty value (422 on missing/empty) is a webui-layer HTTP input + guard, NOT shared builder semantics -- the MCP tool has no such gate and would run the + builder to an empty/unresolved payload instead. ``limit`` defaults to 200, matching the MCP + tool's default (not ``/api/search``'s ``0 -> row_limit`` convention). Recoverable conditions + (``query_too_broad``, ambiguous/unresolved sites, truncation) all pass through unchanged as + 200 bodies -- this route never inspects the payload. + """ + clamped = service.clamp_limit(limit, cfg) + try: + return await _run_blocking( + lambda: service.find_references_payload(engine, cfg, symbol, clamped, branch) + ) + except DataError as error: + raise HTTPException(status_code=400, detail={"error": "invalid parameter"}) from error + + +async def api_imports( + engine: EngineDep, + cfg: SettingsDep, + repo: Annotated[str | None, Query()] = None, + target: Annotated[str | None, Query()] = None, + direction: Annotated[str, Query()] = "imports", + limit: Annotated[int, Query()] = 200, + branch: Annotated[str | None, Query()] = None, +) -> dict[str, Any]: + """Candidate-set ``import`` edge sites in one of two directions -- pure passthrough to + :func:`app.service.list_imports_payload`, the SAME builder the MCP ``list_imports`` tool + wraps (byte-identical payload at the same clamped ``limit``, see + ``docs/runbooks/webui.md``). + + ``repo``/``target`` are optional at this HTTP layer: which one is required depends on + ``direction`` and is the builder's job to decide, returning a structured 200 + (``missing_repo``/``missing_target``) rather than a 422 -- never gate on them here. An + unknown ``direction`` is passed through verbatim and returns the builder's structured + ``unsupported_direction`` 200. ``limit`` defaults to 200, matching the MCP tool's default. + This route never inspects the payload; all recoverable conditions pass through as 200 + bodies. + """ + clamped = service.clamp_limit(limit, cfg) + try: + return await _run_blocking( + lambda: service.list_imports_payload( + engine, cfg, repo, clamped, branch, target=target, direction=direction + ) + ) + except DataError as error: + raise HTTPException(status_code=400, detail={"error": "invalid parameter"}) from error + + # ----------------------------------------------------------------------- security headers @@ -315,6 +376,8 @@ def create_app() -> FastAPI: app.get("/api/repos")(api_repos) app.get("/api/semantic/status")(api_semantic_status) app.get("/api/semantic")(api_semantic) + app.get("/api/references")(api_references) + app.get("/api/imports")(api_imports) if _FRONTEND_DIST.is_dir(): app.mount("/", SPAStaticFiles(directory=_FRONTEND_DIST, html=True), name="spa")